diff --git a/src/gerber/convert-soup-to-gerber-commands/defineAperturesForLayer.ts b/src/gerber/convert-soup-to-gerber-commands/defineAperturesForLayer.ts
index c3e4eac..3da7f8c 100644
--- a/src/gerber/convert-soup-to-gerber-commands/defineAperturesForLayer.ts
+++ b/src/gerber/convert-soup-to-gerber-commands/defineAperturesForLayer.ts
@@ -3,6 +3,7 @@ import type {
LayerRef,
PCBPlatedHole,
PCBSMTPad,
+ PcbCopperText,
PcbVia,
PcbHole,
PcbSolderPaste,
@@ -141,6 +142,18 @@ export const getApertureConfigFromPcbSilkscreenText = (
throw new Error(`Provide font_size for: ${elm as any}`)
}
+export const getApertureConfigFromPcbCopperText = (
+ elm: PcbCopperText,
+): ApertureTemplateConfig => {
+ if ("font_size" in elm) {
+ return {
+ standard_template_code: "C",
+ diameter: elm.font_size / 8, // font size and diamater have different units of measurement
+ }
+ }
+ throw new Error(`Provide font_size for: ${elm as any}`)
+}
+
export const getApertureConfigFromPcbSolderPaste = (
elm: PcbSolderPaste,
): ApertureTemplateConfig => {
@@ -317,6 +330,10 @@ function getAllApertureTemplateConfigsForLayer(
addConfigIfNew(getApertureConfigFromPcbSilkscreenPath(elm))
else if (elm.type === "pcb_silkscreen_text")
addConfigIfNew(getApertureConfigFromPcbSilkscreenText(elm))
+ else if (elm.type === "pcb_copper_text") {
+ if (elm.layer === layer)
+ addConfigIfNew(getApertureConfigFromPcbCopperText(elm))
+ }
}
return configs
diff --git a/src/gerber/convert-soup-to-gerber-commands/index.ts b/src/gerber/convert-soup-to-gerber-commands/index.ts
index 15a5ca1..7abb558 100644
--- a/src/gerber/convert-soup-to-gerber-commands/index.ts
+++ b/src/gerber/convert-soup-to-gerber-commands/index.ts
@@ -7,6 +7,7 @@ import {
defineAperturesForLayer,
getApertureConfigFromCirclePcbHole,
getApertureConfigFromPcbPlatedHole,
+ getApertureConfigFromPcbCopperText,
getApertureConfigFromPcbSilkscreenPath,
getApertureConfigFromPcbSilkscreenText,
getApertureConfigFromPcbSmtpad,
@@ -125,6 +126,223 @@ export const convertSoupToGerberCommands = (
}
}
+ const renderVectorText = (
+ element: any,
+ layer: "top" | "bottom",
+ layerType: "copper" | "silkscreen",
+ getApertureConfig: (elm: any) => any,
+ ) => {
+ if (element.layer !== layer) return
+ // The acctual Uppercase letters are 70% of the font size
+ // sources: https://forum.generic-mapping-tools.org/t/what-exactly-is-the-custom-font-s-height-point-size-ratio/1265/2
+ const CAP_HEIGHT_SCALE = 0.7 // Adjust based on your font's metrics
+ const glayer = glayers[getGerberLayerName(layer, layerType)]
+ const apertureConfig = getApertureConfig(element)
+ const gerber = gerberBuilder().add("select_aperture", {
+ aperture_number: findApertureNumber(glayer, apertureConfig),
+ })
+
+ let initialX = element.anchor_position.x
+ let initialY = element.anchor_position.y
+ const fontSize = element.font_size * CAP_HEIGHT_SCALE
+ const letterSpacing = fontSize * 0.4
+ const spaceWidth = fontSize * 0.5
+
+ const textWidth =
+ element.text.split("").reduce((width: number, char: string) => {
+ if (char === " ") {
+ return width + spaceWidth + letterSpacing
+ }
+ return width + fontSize + letterSpacing
+ }, 0) - letterSpacing
+
+ const textHeight = fontSize
+
+ const anchorAlignment =
+ element.anchor_alignment ||
+ (():
+ | "top_center"
+ | "bottom_center"
+ | "center_left"
+ | "center_right"
+ | undefined => {
+ const side = (element as any).anchor_side as
+ | "top"
+ | "bottom"
+ | "left"
+ | "right"
+ | undefined
+ if (!side) return undefined
+ switch (side) {
+ case "top":
+ return "top_center"
+ case "bottom":
+ return "bottom_center"
+ case "left":
+ return "center_left"
+ case "right":
+ return "center_right"
+ }
+ })() ||
+ "center"
+
+ switch (anchorAlignment) {
+ case "top_left":
+ break
+ case "top_center":
+ initialX -= textWidth / 2
+ break
+ case "top_right":
+ initialX -= textWidth
+ break
+ case "center_right":
+ initialY -= textHeight / 2
+ break
+ case "center_left":
+ initialX -= textWidth
+ initialY -= textHeight / 2
+ break
+ case "bottom_left":
+ initialY -= textHeight
+ break
+ case "bottom_center":
+ initialX -= textWidth / 2
+ initialY -= textHeight
+ break
+ case "bottom_right":
+ initialX -= textWidth
+ initialY -= textHeight
+ break
+ default:
+ initialX -= textWidth / 2
+ initialY -= textHeight / 2
+ break
+ }
+
+ let anchoredX = initialX
+ const anchoredY = initialY
+
+ let rotation = element.ccw_rotation || 0
+ const cx = anchoredX + textWidth / 2
+ const cy = anchoredY + textHeight / 2
+ const transforms: Matrix[] = []
+
+ const shouldMirror =
+ element.is_mirrored !== undefined
+ ? element.is_mirrored
+ : element.layer === "bottom"
+
+ if (shouldMirror) {
+ transforms.push(
+ translate(cx, cy),
+ { a: -1, b: 0, c: 0, d: 1, e: 0, f: 0 },
+ translate(-cx, -cy),
+ )
+ rotation = -rotation
+ }
+
+ if (rotation) {
+ const rad = (rotation * Math.PI) / 180
+ transforms.push(translate(cx, cy), rotate(rad), translate(-cx, -cy))
+ }
+
+ const transformMatrix =
+ transforms.length > 0 ? compose(...transforms) : undefined
+
+ const applyTransform = (point: { x: number; y: number }) =>
+ transformMatrix ? applyToPoint(transformMatrix, point) : point
+
+ if (layerType === "copper" && element.is_knockout) {
+ const padding = element.knockout_padding ?? {
+ left: 0.2,
+ right: 0.2,
+ top: 0.2,
+ bottom: 0.2,
+ }
+
+ const paddedRect = [
+ { x: initialX - padding.left, y: initialY - padding.top },
+ {
+ x: initialX + textWidth + padding.right,
+ y: initialY - padding.top,
+ },
+ {
+ x: initialX + textWidth + padding.right,
+ y: initialY + textHeight + padding.bottom,
+ },
+ {
+ x: initialX - padding.left,
+ y: initialY + textHeight + padding.bottom,
+ },
+ ].map(applyTransform)
+
+ glayer.push(
+ ...gerberBuilder()
+ .add("select_aperture", {
+ aperture_number: findApertureNumber(glayer, apertureConfig),
+ })
+ .add("start_region_statement", {})
+ .add("move_operation", {
+ x: paddedRect[0].x,
+ y: mfy(paddedRect[0].y),
+ })
+ .add("plot_operation", {
+ x: paddedRect[1].x,
+ y: mfy(paddedRect[1].y),
+ })
+ .add("plot_operation", {
+ x: paddedRect[2].x,
+ y: mfy(paddedRect[2].y),
+ })
+ .add("plot_operation", {
+ x: paddedRect[3].x,
+ y: mfy(paddedRect[3].y),
+ })
+ .add("plot_operation", {
+ x: paddedRect[0].x,
+ y: mfy(paddedRect[0].y),
+ })
+ .add("end_region_statement", {})
+ .build(),
+ )
+
+ glayer.push(
+ ...gerberBuilder().add("set_layer_polarity", { polarity: "C" }).build(),
+ )
+ }
+
+ for (const char of element.text.toUpperCase()) {
+ if (char === " ") {
+ anchoredX += spaceWidth + letterSpacing
+ continue
+ }
+
+ const letterPaths = lineAlphabet[char] || []
+ for (const path of letterPaths) {
+ const x1 = anchoredX + path.x1 * fontSize
+ const y1 = anchoredY + path.y1 * fontSize
+ const x2 = anchoredX + path.x2 * fontSize
+ const y2 = anchoredY + path.y2 * fontSize
+
+ const p1 = applyTransform({ x: x1, y: y1 })
+ const p2 = applyTransform({ x: x2, y: y2 })
+
+ gerber.add("move_operation", { x: p1.x, y: mfy(p1.y) })
+ gerber.add("plot_operation", { x: p2.x, y: mfy(p2.y) })
+ }
+
+ anchoredX += fontSize + letterSpacing
+ }
+
+ glayer.push(...gerber.build())
+
+ if (layerType === "copper" && element.is_knockout) {
+ glayer.push(
+ ...gerberBuilder().add("set_layer_polarity", { polarity: "D" }).build(),
+ )
+ }
+ }
+
for (const layer of ["top", "bottom", "edgecut"] as const) {
for (const element of soup) {
if (element.type === "pcb_trace") {
@@ -176,154 +394,23 @@ export const convertSoupToGerberCommands = (
glayer.push(...gerber.build())
}
- } else if (element.type === "pcb_silkscreen_text") {
- if (element.layer === layer) {
- // The acctual Uppercase letters are 70% of the font size
- // sources: https://forum.generic-mapping-tools.org/t/what-exactly-is-the-custom-font-s-height-point-size-ratio/1265/2?utm_source=chatgpt.com
- const CAP_HEIGHT_SCALE = 0.7 // Adjust based on your font's metrics
- const glayer = glayers[getGerberLayerName(layer, "silkscreen")]
- const apertureConfig = getApertureConfigFromPcbSilkscreenText(element)
- const gerber = gerberBuilder().add("select_aperture", {
- aperture_number: findApertureNumber(glayer, apertureConfig),
- })
-
- let initialX = element.anchor_position.x
- let initialY = element.anchor_position.y
- const fontSize = element.font_size * CAP_HEIGHT_SCALE
- const letterSpacing = fontSize * 0.4
- const spaceWidth = fontSize * 0.5
-
- const textWidth =
- element.text.split("").reduce((width, char) => {
- if (char === " ") {
- return width + spaceWidth + letterSpacing
- }
- return width + fontSize + letterSpacing
- }, 0) - letterSpacing
-
- const textHeight = fontSize
-
- const anchorAlignment =
- element.anchor_alignment ||
- (():
- | "top_center"
- | "bottom_center"
- | "center_left"
- | "center_right"
- | undefined => {
- const side = (element as any).anchor_side as
- | "top"
- | "bottom"
- | "left"
- | "right"
- | undefined
- if (!side) return undefined
- switch (side) {
- case "top":
- return "top_center"
- case "bottom":
- return "bottom_center"
- case "left":
- return "center_left"
- case "right":
- return "center_right"
- }
- })() ||
- "center"
-
- switch (anchorAlignment) {
- case "top_left":
- break
- case "top_center":
- initialX -= textWidth / 2
- break
- case "top_right":
- initialX -= textWidth
- break
- case "center_right":
- initialY -= textHeight / 2
- break
- case "center_left":
- initialX -= textWidth
- initialY -= textHeight / 2
- break
- case "bottom_left":
- initialY -= textHeight
- break
- case "bottom_center":
- initialX -= textWidth / 2
- initialY -= textHeight
- break
- case "bottom_right":
- initialX -= textWidth
- initialY -= textHeight
- break
- default:
- initialX -= textWidth / 2
- initialY -= textHeight / 2
- break
- }
-
- let anchoredX = initialX
- const anchoredY = initialY
-
- let rotation = element.ccw_rotation || 0
- const cx = anchoredX + textWidth / 2
- const cy = anchoredY + textHeight / 2
- const transforms: Matrix[] = []
-
- // Apply mirroring and rotation for the bottom layer only
- if (element.layer === "bottom") {
- transforms.push(
- translate(cx, cy),
- { a: -1, b: 0, c: 0, d: 1, e: 0, f: 0 }, // Horizontal flip
- translate(-cx, -cy),
- )
- rotation = -rotation // Reverse rotation for bottom layer
- }
-
- // Apply rotation if present
- if (rotation) {
- const rad = (rotation * Math.PI) / 180
- transforms.push(
- translate(cx, cy), // Translate to center of rotation
- rotate(rad), // Apply rotation
- translate(-cx, -cy), // Translate back
- )
- }
-
- // Process each character in the text
- for (const char of element.text.toUpperCase()) {
- if (char === " ") {
- anchoredX += spaceWidth + letterSpacing
- continue
- }
-
- const letterPaths = lineAlphabet[char] || []
- for (const path of letterPaths) {
- const x1 = anchoredX + path.x1 * fontSize
- const y1 = anchoredY + path.y1 * fontSize
- const x2 = anchoredX + path.x2 * fontSize
- const y2 = anchoredY + path.y2 * fontSize
-
- // Apply transformations after positioning
- let p1 = { x: x1, y: y1 }
- let p2 = { x: x2, y: y2 }
-
- if (transforms.length > 0) {
- const transformMatrix = compose(...transforms)
- p1 = applyToPoint(transformMatrix, p1)
- p2 = applyToPoint(transformMatrix, p2)
- }
-
- gerber.add("move_operation", { x: p1.x, y: mfy(p1.y) })
- gerber.add("plot_operation", { x: p2.x, y: mfy(p2.y) })
- }
-
- anchoredX += fontSize + letterSpacing // Move to next character position
- }
- glayer.push(...gerber.build())
- }
+ } else if (
+ element.type === "pcb_silkscreen_text" &&
+ layer !== "edgecut"
+ ) {
+ renderVectorText(
+ element,
+ layer,
+ "silkscreen",
+ getApertureConfigFromPcbSilkscreenText,
+ )
+ } else if (element.type === "pcb_copper_text" && layer !== "edgecut") {
+ renderVectorText(
+ element,
+ layer,
+ "copper",
+ getApertureConfigFromPcbCopperText,
+ )
} else if (element.type === "pcb_smtpad" && element.shape !== "polygon") {
if (element.layer === layer) {
for (const glayer of [
diff --git a/tests/gerber/__snapshots__/copper-text-bottom.snap.svg b/tests/gerber/__snapshots__/copper-text-bottom.snap.svg
new file mode 100644
index 0000000..c961375
--- /dev/null
+++ b/tests/gerber/__snapshots__/copper-text-bottom.snap.svg
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/tests/gerber/__snapshots__/copper-text-knockout-bottom.snap.svg b/tests/gerber/__snapshots__/copper-text-knockout-bottom.snap.svg
new file mode 100644
index 0000000..537060e
--- /dev/null
+++ b/tests/gerber/__snapshots__/copper-text-knockout-bottom.snap.svg
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/tests/gerber/__snapshots__/copper-text-knockout-top.snap.svg b/tests/gerber/__snapshots__/copper-text-knockout-top.snap.svg
new file mode 100644
index 0000000..018d073
--- /dev/null
+++ b/tests/gerber/__snapshots__/copper-text-knockout-top.snap.svg
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/tests/gerber/__snapshots__/copper-text-top.snap.svg b/tests/gerber/__snapshots__/copper-text-top.snap.svg
new file mode 100644
index 0000000..c9a96fd
--- /dev/null
+++ b/tests/gerber/__snapshots__/copper-text-top.snap.svg
@@ -0,0 +1,7 @@
+
\ No newline at end of file
diff --git a/tests/gerber/generate-gerber-with-copper-text-knockout.test.tsx b/tests/gerber/generate-gerber-with-copper-text-knockout.test.tsx
new file mode 100644
index 0000000..912897e
--- /dev/null
+++ b/tests/gerber/generate-gerber-with-copper-text-knockout.test.tsx
@@ -0,0 +1,72 @@
+import { test, expect } from "bun:test"
+import type { AnyCircuitElement } from "circuit-json"
+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"
+
+const circuitJson: AnyCircuitElement[] = [
+ {
+ type: "pcb_board",
+ pcb_board_id: "pcb_board_0",
+ center: { x: 0, y: 0 },
+ width: 40,
+ height: 20,
+ num_layers: 2,
+ thickness: 1.6,
+ material: "fr4",
+ },
+ {
+ type: "pcb_copper_text",
+ pcb_copper_text_id: "pcb_copper_text_0",
+ pcb_component_id: "pcb_component_0",
+ text: "KNOCK",
+ font: "tscircuit2024",
+ font_size: 2,
+ layer: "top",
+ anchor_position: { x: -9, y: 0 },
+ anchor_alignment: "center",
+ is_knockout: true,
+ knockout_padding: {
+ left: 0.5,
+ right: 0.5,
+ top: 0.4,
+ bottom: 0.4,
+ },
+ } as AnyCircuitElement,
+ {
+ type: "pcb_copper_text",
+ pcb_copper_text_id: "pcb_copper_text_1",
+ pcb_component_id: "pcb_component_1",
+ text: "OUT",
+ font: "tscircuit2024",
+ font_size: 1.6,
+ layer: "bottom",
+ is_mirrored: true,
+ ccw_rotation: 20,
+ anchor_position: { x: 9, y: 0 },
+ anchor_alignment: "center",
+ is_knockout: true,
+ } as AnyCircuitElement,
+]
+
+test("Generate gerber with copper text knockout", async () => {
+ const gerber_cmds = convertSoupToGerberCommands(circuitJson)
+ const excellon_drill_cmds = convertSoupToExcellonDrillCommands({
+ circuitJson,
+ is_plated: true,
+ })
+
+ const gerberOutput = stringifyGerberCommandLayers(gerber_cmds)
+ const excellonDrillOutput = stringifyExcellonDrill(excellon_drill_cmds)
+
+ await maybeOutputGerber(gerberOutput, excellonDrillOutput)
+
+ expect(gerberOutput).toMatchGerberSnapshot(
+ import.meta.path,
+ "copper-text-knockout",
+ )
+})
diff --git a/tests/gerber/generate-gerber-with-copper-text.test.tsx b/tests/gerber/generate-gerber-with-copper-text.test.tsx
new file mode 100644
index 0000000..ab5e0a7
--- /dev/null
+++ b/tests/gerber/generate-gerber-with-copper-text.test.tsx
@@ -0,0 +1,61 @@
+import { test, expect } from "bun:test"
+import type { AnyCircuitElement } from "circuit-json"
+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"
+
+const circuitJson: AnyCircuitElement[] = [
+ {
+ type: "pcb_board",
+ pcb_board_id: "pcb_board_0",
+ center: { x: 0, y: 0 },
+ width: 40,
+ height: 20,
+ num_layers: 2,
+ thickness: 1.6,
+ material: "fr4",
+ },
+ {
+ type: "pcb_copper_text",
+ pcb_copper_text_id: "pcb_copper_text_0",
+ pcb_component_id: "pcb_component_0",
+ text: "TOP",
+ font: "tscircuit2024",
+ font_size: 2,
+ layer: "top",
+ anchor_position: { x: -10, y: 0 },
+ anchor_alignment: "center",
+ } as AnyCircuitElement,
+ {
+ type: "pcb_copper_text",
+ pcb_copper_text_id: "pcb_copper_text_1",
+ pcb_component_id: "pcb_component_1",
+ text: "BOTTOM",
+ font: "tscircuit2024",
+ font_size: 1.8,
+ layer: "bottom",
+ is_mirrored: true,
+ ccw_rotation: 15,
+ anchor_position: { x: 10, y: 0 },
+ anchor_alignment: "center",
+ } as AnyCircuitElement,
+]
+
+test("Generate gerber with copper text", async () => {
+ const gerber_cmds = convertSoupToGerberCommands(circuitJson)
+ const excellon_drill_cmds = convertSoupToExcellonDrillCommands({
+ circuitJson,
+ is_plated: true,
+ })
+
+ const gerberOutput = stringifyGerberCommandLayers(gerber_cmds)
+ const excellonDrillOutput = stringifyExcellonDrill(excellon_drill_cmds)
+
+ await maybeOutputGerber(gerberOutput, excellonDrillOutput)
+
+ expect(gerberOutput).toMatchGerberSnapshot(import.meta.path, "copper-text")
+})