From ce2e23bc3892a62ac88d9e3355e26a7b708d9147 Mon Sep 17 00:00:00 2001 From: Severin Ibarluzea Date: Mon, 16 Jun 2025 13:34:37 -0700 Subject: [PATCH 1/4] feat: add polygon smtpad support --- src/gerber/any_gerber_command.ts | 4 +- src/gerber/commands/end_region_statement.ts | 20 ++++++--- src/gerber/commands/start_region_statement.ts | 24 ++++++---- .../convert-soup-to-gerber-commands/index.ts | 32 +++++++++++++ .../polygon-smtpad-bottom.snap.svg | 7 +++ .../__snapshots__/polygon-smtpad-top.snap.svg | 7 +++ ...nerate-gerber-with-polygon-smtpad.test.tsx | 45 +++++++++++++++++++ 7 files changed, 121 insertions(+), 18 deletions(-) create mode 100644 tests/gerber/__snapshots__/polygon-smtpad-bottom.snap.svg create mode 100644 tests/gerber/__snapshots__/polygon-smtpad-top.snap.svg create mode 100644 tests/gerber/generate-gerber-with-polygon-smtpad.test.tsx diff --git a/src/gerber/any_gerber_command.ts b/src/gerber/any_gerber_command.ts index 48d604b..953918c 100644 --- a/src/gerber/any_gerber_command.ts +++ b/src/gerber/any_gerber_command.ts @@ -45,7 +45,7 @@ export const gerber_command_map = { end_of_file, move_operation, flash_operation, - // end_region_statement, + end_region_statement, // flash_operation, format_specification, // load_mirroring, @@ -62,7 +62,7 @@ export const gerber_command_map = { select_aperture, set_unit, set_layer_polarity, - // start_region_statement, + start_region_statement, // step_and_repeat, } as const satisfies Record> diff --git a/src/gerber/commands/end_region_statement.ts b/src/gerber/commands/end_region_statement.ts index e8f85c2..fc32ee5 100644 --- a/src/gerber/commands/end_region_statement.ts +++ b/src/gerber/commands/end_region_statement.ts @@ -1,10 +1,16 @@ import { z } from "zod" +import { defineGerberCommand } from "../define-gerber-command" -export const end_region_statement = z - .object({ - command_code: z.literal("G37"), - statement: z.string(), - }) - .describe("End region statement: Ends the region statement") +export const end_region_statement = defineGerberCommand({ + command_code: "G37", + schema: z + .object({ + command_code: z.literal("G37").default("G37"), + }) + .describe("End region statement: Ends the region statement"), + stringify() { + return "G37*" + }, +}) -export type EndRegionStatement = z.infer +export type EndRegionStatement = z.infer diff --git a/src/gerber/commands/start_region_statement.ts b/src/gerber/commands/start_region_statement.ts index 29af2b0..0de2ec6 100644 --- a/src/gerber/commands/start_region_statement.ts +++ b/src/gerber/commands/start_region_statement.ts @@ -1,12 +1,18 @@ import { z } from "zod" +import { defineGerberCommand } from "../define-gerber-command" -export const start_region_statement = z - .object({ - command_code: z.literal("G36"), - statement: z.string(), - }) - .describe( - "Start region statement: Starts a region statement which creates a region by defining its contours.", - ) +export const start_region_statement = defineGerberCommand({ + command_code: "G36", + schema: z + .object({ + command_code: z.literal("G36").default("G36"), + }) + .describe( + "Start region statement: Starts a region statement which creates a region by defining its contours.", + ), + stringify() { + return "G36*" + }, +}) -export type StartRegionStatement = z.infer +export type StartRegionStatement = z.infer diff --git a/src/gerber/convert-soup-to-gerber-commands/index.ts b/src/gerber/convert-soup-to-gerber-commands/index.ts index e0589ae..1ad4ddc 100644 --- a/src/gerber/convert-soup-to-gerber-commands/index.ts +++ b/src/gerber/convert-soup-to-gerber-commands/index.ts @@ -289,6 +289,38 @@ export const convertSoupToGerberCommands = ( gb.add("load_rotation", { rotation_degrees: 0 }) } + glayer.push(...gb.build()) + } + } + } else if (element.type === "pcb_smtpad" && element.shape === "polygon") { + if (element.layer === layer) { + for (const glayer of [ + glayers[getGerberLayerName(layer, "copper")], + glayers[getGerberLayerName(layer, "soldermask")], + ]) { + const gb = gerberBuilder() + .add("select_aperture", { aperture_number: 10 }) + .add("start_region_statement", {}) + + if (element.points.length > 0) { + gb.add("move_operation", { + x: element.points[0].x, + y: mfy(element.points[0].y), + }) + for (let i = 1; i < element.points.length; i++) { + gb.add("plot_operation", { + x: element.points[i].x, + y: mfy(element.points[i].y), + }) + } + gb.add("plot_operation", { + x: element.points[0].x, + y: mfy(element.points[0].y), + }) + } + + gb.add("end_region_statement", {}) + glayer.push(...gb.build()) } } diff --git a/tests/gerber/__snapshots__/polygon-smtpad-bottom.snap.svg b/tests/gerber/__snapshots__/polygon-smtpad-bottom.snap.svg new file mode 100644 index 0000000..ba24595 --- /dev/null +++ b/tests/gerber/__snapshots__/polygon-smtpad-bottom.snap.svg @@ -0,0 +1,7 @@ + diff --git a/tests/gerber/__snapshots__/polygon-smtpad-top.snap.svg b/tests/gerber/__snapshots__/polygon-smtpad-top.snap.svg new file mode 100644 index 0000000..ad6c282 --- /dev/null +++ b/tests/gerber/__snapshots__/polygon-smtpad-top.snap.svg @@ -0,0 +1,7 @@ + diff --git a/tests/gerber/generate-gerber-with-polygon-smtpad.test.tsx b/tests/gerber/generate-gerber-with-polygon-smtpad.test.tsx new file mode 100644 index 0000000..156a657 --- /dev/null +++ b/tests/gerber/generate-gerber-with-polygon-smtpad.test.tsx @@ -0,0 +1,45 @@ +import { test, expect } from "bun:test" +import { convertSoupToGerberCommands } from "src/gerber/convert-soup-to-gerber-commands" +import { + convertSoupToExcellonDrillCommands, + stringifyExcellonDrill, +} from "src/excellon-drill" +import { stringifyGerberCommandLayers } from "src/gerber/stringify-gerber" +import { maybeOutputGerber } from "tests/fixtures/maybe-output-gerber" +import { Circuit } from "@tscircuit/core" + +test("Generate gerber with polygon smtpad", async () => { + const circuit = new Circuit() + circuit.add( + + + , + ) + + const circuitJson = circuit.getCircuitJson() + + const gerber_cmds = convertSoupToGerberCommands(circuitJson as any) + const excellon_drill_cmds = convertSoupToExcellonDrillCommands({ + circuitJson: circuitJson as any, + is_plated: true, + }) + + const excellonDrillOutput = stringifyExcellonDrill(excellon_drill_cmds) + const gerberOutput = stringifyGerberCommandLayers(gerber_cmds) + + await maybeOutputGerber(gerberOutput, excellonDrillOutput) + + expect({ + ...gerberOutput, + "drill.drl": excellonDrillOutput, + }).toMatchGerberSnapshot(import.meta.path, "polygon-smtpad") +}) From 66ff6b6e99165cbf8b7f1b2a992367c9c5a2f3e1 Mon Sep 17 00:00:00 2001 From: Severin Ibarluzea Date: Mon, 16 Jun 2025 14:07:03 -0700 Subject: [PATCH 2/4] Use star polygon in smtpad polygon test --- .../generate-gerber-with-polygon-smtpad.test.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/tests/gerber/generate-gerber-with-polygon-smtpad.test.tsx b/tests/gerber/generate-gerber-with-polygon-smtpad.test.tsx index 156a657..771ace5 100644 --- a/tests/gerber/generate-gerber-with-polygon-smtpad.test.tsx +++ b/tests/gerber/generate-gerber-with-polygon-smtpad.test.tsx @@ -16,10 +16,16 @@ test("Generate gerber with polygon smtpad", async () => { shape="polygon" layer="top" points={[ - { x: -1, y: -1 }, - { x: 1, y: -1 }, - { x: 1, y: 1 }, - { x: -1, y: 1 }, + { x: 0, y: 2 }, + { x: -0.588, y: 0.809 }, + { x: -1.902, y: 0.618 }, + { x: -0.951, y: -0.309 }, + { x: -1.176, y: -1.618 }, + { x: 0, y: -1 }, + { x: 1.176, y: -1.618 }, + { x: 0.951, y: -0.309 }, + { x: 1.902, y: 0.618 }, + { x: 0.588, y: 0.809 }, ]} /> , From 627f05059e6d9cb35259ef10b741cd52b58a7b7b Mon Sep 17 00:00:00 2001 From: Severin Ibarluzea Date: Mon, 16 Jun 2025 21:12:16 -0700 Subject: [PATCH 3/4] Add inline snapshot for polygon SMT pad --- ...nerate-gerber-with-polygon-smtpad.test.tsx | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/tests/gerber/generate-gerber-with-polygon-smtpad.test.tsx b/tests/gerber/generate-gerber-with-polygon-smtpad.test.tsx index 771ace5..d5a48e4 100644 --- a/tests/gerber/generate-gerber-with-polygon-smtpad.test.tsx +++ b/tests/gerber/generate-gerber-with-polygon-smtpad.test.tsx @@ -44,6 +44,68 @@ test("Generate gerber with polygon smtpad", async () => { await maybeOutputGerber(gerberOutput, excellonDrillOutput) + const sanitizedFCu = gerberOutput.F_Cu.replace( + /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z/g, + "DATE", + ) + expect(sanitizedFCu).toMatchInlineSnapshot(` + "%TF.GenerationSoftware,tscircuit,circuit-json-to-gerber,0.0.23*% + %TF.CreationDate,DATE*% + %TF.SameCoordinates,Original*% + %TF.FileFunction,Copper,L1,Top*% + %TF.FilePolarity,Positive*% + %FSLAX46Y46*% + %MOMM*% + G04 Gerber Fmt 4.6, Leading zero omitted, Abs format (unit mm)* + G04 Created by tscircuit (builder) date DATE* + G01* + G04 APERTURE MACROS START* + %AMHORZPILL* + 0 Horizontal pill (stadium) shape macro* + 0 Parameters:* + 0 $1 = Total width* + 0 $2 = Total height* + 0 $3 = Circle diameter (equal to height)* + 0 $4 = Circle center offset + 0 21 = Center Line(Exposure, Width, Height, Center X, Center Y, Rotation)* + 0 1 = Circle(Exposure, Diameter, Center X, Center Y, Rotation)* + 21,1,$1,$2,0.0,0.0,0.0* + 1,1,$3,0.0-$4,0.0* + 1,1,$3,$4,0.0*% + %AMVERTPILL* + 0 Vertical pill (stadium) shape macro* + 0 Parameters:* + 0 $1 = Total width* + 0 $2 = Total height* + 0 $3 = Circle diameter (equal to width)* + 0 $4 = Circle center offset + 0 21 = Center Line(Exposure, Width, Height, Center X, Center Y, Rotation)* + 21,1,$1,$2,0.0,0.0,0.0* + 1,1,$3,0.0,0.0-$4* + 1,1,$3,0.0,$4*% + %AMRoundRect* + 0 Rectangle with rounded corners* + 0 $1 Corner radius* + 0 $2 $3 $4 $5 $6 $7 $8 $9 X,Y Position of each corner* + 0 Polygon box body* + 4,1,4,$2,$3,$4,$5,$6,$7,$8,$9,$2,$3,0* + 0 Circles for rounded corners* + 1,1,$1+$1,$2,$3* + 1,1,$1+$1,$4,$5* + 1,1,$1+$1,$6,$7* + 1,1,$1+$1,$8,$9* + 0 Rectangles between the rounded corners* + 20,1,$1+$1,$2,$3,$4,$5,0* + 20,1,$1+$1,$4,$5,$6,$7,0* + 20,1,$1+$1,$6,$7,$8,$9,0* + 20,1,$1+$1,$8,$9,$2,$3,0*% + G04 APERTURE MACROS END* + G04 aperture START LIST* + %TD*% + G04 aperture END LIST* + M02*" + `) + expect({ ...gerberOutput, "drill.drl": excellonDrillOutput, From c81778ed4bec86c7fe09d9841b3c534ef7596f6f Mon Sep 17 00:00:00 2001 From: Severin Ibarluzea Date: Tue, 17 Jun 2025 11:19:46 -0700 Subject: [PATCH 4/4] Fix polygon SMT pad aperture and region --- .../define-common-macros.ts | 12 ++++++------ .../defineAperturesForLayer.ts | 9 +++++++++ src/gerber/convert-soup-to-gerber-commands/index.ts | 7 ++++++- .../generate-gerber-with-polygon-smtpad.test.tsx | 12 ++++++------ 4 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/gerber/convert-soup-to-gerber-commands/define-common-macros.ts b/src/gerber/convert-soup-to-gerber-commands/define-common-macros.ts index e4bdb8c..58388ce 100644 --- a/src/gerber/convert-soup-to-gerber-commands/define-common-macros.ts +++ b/src/gerber/convert-soup-to-gerber-commands/define-common-macros.ts @@ -16,9 +16,9 @@ export const defineCommonMacros = (glayer: Array) => { 0 $4 = Circle center offset 0 21 = Center Line(Exposure, Width, Height, Center X, Center Y, Rotation)* 0 1 = Circle(Exposure, Diameter, Center X, Center Y, Rotation)* -21,1,$1,$2,0.0,0.0,0.0* -1,1,$3,0.0-$4,0.0* -1,1,$3,$4,0.0* + 21,1,$1,$2,0.0,0.0,0.0* + 1,1,$3,0.0,-$4,0.0* + 1,1,$3,0.0,$4,0.0* `.trim(), }) .add("define_macro_aperture_template", { @@ -31,9 +31,9 @@ export const defineCommonMacros = (glayer: Array) => { 0 $3 = Circle diameter (equal to width)* 0 $4 = Circle center offset 0 21 = Center Line(Exposure, Width, Height, Center X, Center Y, Rotation)* -21,1,$1,$2,0.0,0.0,0.0* -1,1,$3,0.0,0.0-$4* -1,1,$3,0.0,$4* + 21,1,$1,$2,0.0,0.0,0.0* + 1,1,$3,0.0,-$4,0.0* + 1,1,$3,0.0,$4,0.0* `.trim(), }) .add("define_macro_aperture_template", { diff --git a/src/gerber/convert-soup-to-gerber-commands/defineAperturesForLayer.ts b/src/gerber/convert-soup-to-gerber-commands/defineAperturesForLayer.ts index 4f34911..4401c2e 100644 --- a/src/gerber/convert-soup-to-gerber-commands/defineAperturesForLayer.ts +++ b/src/gerber/convert-soup-to-gerber-commands/defineAperturesForLayer.ts @@ -110,6 +110,15 @@ export const getApertureConfigFromPcbSmtpad = ( y_size: elm.height, } } + if (elm.shape === "polygon") { + // Polygon pads are rendered using region statements. The aperture + // width is arbitrary but must be defined so that a D-code exists when + // the region is drawn. A small round aperture is sufficient. + return { + standard_template_code: "C", + diameter: 0.05, + } + } throw new Error(`Unsupported shape ${(elm as any).shape}`) } diff --git a/src/gerber/convert-soup-to-gerber-commands/index.ts b/src/gerber/convert-soup-to-gerber-commands/index.ts index 1ad4ddc..476d24e 100644 --- a/src/gerber/convert-soup-to-gerber-commands/index.ts +++ b/src/gerber/convert-soup-to-gerber-commands/index.ts @@ -298,8 +298,13 @@ export const convertSoupToGerberCommands = ( glayers[getGerberLayerName(layer, "copper")], glayers[getGerberLayerName(layer, "soldermask")], ]) { + const apertureConfig = { + standard_template_code: "C", + diameter: 0.05, + } + const apertureNumber = findApertureNumber(glayer, apertureConfig) const gb = gerberBuilder() - .add("select_aperture", { aperture_number: 10 }) + .add("select_aperture", { aperture_number: apertureNumber }) .add("start_region_statement", {}) if (element.points.length > 0) { diff --git a/tests/gerber/generate-gerber-with-polygon-smtpad.test.tsx b/tests/gerber/generate-gerber-with-polygon-smtpad.test.tsx index d5a48e4..01d1590 100644 --- a/tests/gerber/generate-gerber-with-polygon-smtpad.test.tsx +++ b/tests/gerber/generate-gerber-with-polygon-smtpad.test.tsx @@ -69,9 +69,9 @@ test("Generate gerber with polygon smtpad", async () => { 0 $4 = Circle center offset 0 21 = Center Line(Exposure, Width, Height, Center X, Center Y, Rotation)* 0 1 = Circle(Exposure, Diameter, Center X, Center Y, Rotation)* - 21,1,$1,$2,0.0,0.0,0.0* - 1,1,$3,0.0-$4,0.0* - 1,1,$3,$4,0.0*% + 21,1,$1,$2,0.0,0.0,0.0* + 1,1,$3,0.0,-$4,0.0* + 1,1,$3,0.0,$4,0.0*% %AMVERTPILL* 0 Vertical pill (stadium) shape macro* 0 Parameters:* @@ -80,9 +80,9 @@ test("Generate gerber with polygon smtpad", async () => { 0 $3 = Circle diameter (equal to width)* 0 $4 = Circle center offset 0 21 = Center Line(Exposure, Width, Height, Center X, Center Y, Rotation)* - 21,1,$1,$2,0.0,0.0,0.0* - 1,1,$3,0.0,0.0-$4* - 1,1,$3,0.0,$4*% + 21,1,$1,$2,0.0,0.0,0.0* + 1,1,$3,0.0,-$4,0.0* + 1,1,$3,0.0,$4,0.0*% %AMRoundRect* 0 Rectangle with rounded corners* 0 $1 Corner radius*