Skip to content

Commit 7c62e14

Browse files
evanclaude
andcommitted
fix: emit In1_Cu/In2_Cu gerbers for 4-layer board inner copper traces
4-layer boards with traces on inner1/inner2 layers were silently dropped from gerber output — only F_Cu and B_Cu were emitted. Changes: - GerberLayerName: add string index signature to allow In1_Cu...In4_Cu - getGerberLayerName: map inner1-inner4 layer refs to In1_-In4_ prefixes - getCommandHeaders: add inner1-inner4 FileFunction entries (Copper,L2,Inr etc.) - getAllTraceWidths: collect trace widths for inner layers too - defineAperturesForLayer: resolve correct layer ref for inner glayer names - index.ts: detect used inner layers, initialize and render them Fixes #78 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 99b9f3f commit 7c62e14

7 files changed

Lines changed: 261 additions & 26 deletions

File tree

src/gerber/convert-soup-to-gerber-commands/GerberLayerName.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ export type LayerToGerberCommandsMap = {
1111
B_Mask: AnyGerberCommand[]
1212
B_Paste: AnyGerberCommand[]
1313
Edge_Cuts: AnyGerberCommand[]
14+
[key: string]: AnyGerberCommand[]
1415
}
1516

1617
export type GerberLayerName = keyof LayerToGerberCommandsMap

src/gerber/convert-soup-to-gerber-commands/defineAperturesForLayer.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export function defineAperturesForLayer({
2525
}: {
2626
glayer: AnyGerberCommand[]
2727
soup: AnyCircuitElement[]
28-
glayer_name: GerberLayerName
28+
glayer_name: string
2929
}) {
3030
const getNextApertureNumber = () => {
3131
const highest_aperture_number = glayer.reduce((acc, command) => {
@@ -47,10 +47,21 @@ export function defineAperturesForLayer({
4747
)
4848

4949
// Add all trace width apertures
50-
const traceWidths: Record<LayerRef, number[]> = getAllTraceWidths(soup)
51-
for (const width of traceWidths[
52-
glayer_name.startsWith("F_") ? "top" : "bottom"
53-
]) {
50+
const traceWidths: Record<string, number[]> = getAllTraceWidths(soup)
51+
const layerRefForGlayer = glayer_name.startsWith("F_")
52+
? "top"
53+
: glayer_name.startsWith("B_")
54+
? "bottom"
55+
: glayer_name.startsWith("In1_")
56+
? "inner1"
57+
: glayer_name.startsWith("In2_")
58+
? "inner2"
59+
: glayer_name.startsWith("In3_")
60+
? "inner3"
61+
: glayer_name.startsWith("In4_")
62+
? "inner4"
63+
: "top"
64+
for (const width of traceWidths[layerRefForGlayer] ?? []) {
5465
glayer.push(
5566
...gerberBuilder()
5667
.add("define_aperture_template", {

src/gerber/convert-soup-to-gerber-commands/getAllTraceWidths.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,16 @@ export function getAllTraceWidths(
1818
}
1919
}
2020

21-
return {
21+
const result: Record<string, number[]> = {
2222
top: Array.from(widths.top || []),
2323
bottom: Array.from(widths.bottom || []),
24-
} as any
24+
}
25+
26+
for (const inner of ["inner1", "inner2", "inner3", "inner4"] as const) {
27+
if (widths[inner]) {
28+
result[inner] = Array.from(widths[inner])
29+
}
30+
}
31+
32+
return result as any
2533
}

src/gerber/convert-soup-to-gerber-commands/getCommandHeaders.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,12 @@ import type { AnyGerberCommand } from "src/gerber/any_gerber_command"
22
import { gerberBuilder } from "../gerber-builder"
33
import packageJson from "../../../package.json"
44

5-
const layerAndTypeToFileFunction = {
5+
const layerAndTypeToFileFunction: Record<string, string> = {
66
"top-copper": "Copper,L1,Top",
7+
"inner1-copper": "Copper,L2,Inr",
8+
"inner2-copper": "Copper,L3,Inr",
9+
"inner3-copper": "Copper,L4,Inr",
10+
"inner4-copper": "Copper,L5,Inr",
711
"bottom-copper": "Copper,L2,Bot",
812
"top-soldermask": "Soldermask,Top",
913
"bottom-soldermask": "Soldermask,Bot",
@@ -12,7 +16,6 @@ const layerAndTypeToFileFunction = {
1216
"top-paste": "Paste,Top",
1317
"bottom-paste": "Paste,Bot",
1418
edgecut: "Profile,NP",
15-
// TODO inner layers
1619
}
1720

1821
/**

src/gerber/convert-soup-to-gerber-commands/getGerberLayerName.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import type { LayerRef } from "circuit-json"
22
import type { GerberLayerName } from "./GerberLayerName"
33

4-
const layerRefToGerberPrefix = {
4+
const layerRefToGerberPrefix: Record<string, string> = {
55
top: "F_",
66
bottom: "B_",
7-
} as const
7+
inner1: "In1_",
8+
inner2: "In2_",
9+
inner3: "In3_",
10+
inner4: "In4_",
11+
}
812
const layerTypeToGerberSuffix = {
913
copper: "Cu",
1014
silkscreen: "SilkScreen",
@@ -18,5 +22,9 @@ export const getGerberLayerName = (
1822
layer_type: "copper" | "silkscreen" | "soldermask" | "paste",
1923
): GerberLayerName => {
2024
if (layer_ref === "edgecut") return "Edge_Cuts"
21-
return `${layerRefToGerberPrefix[layer_ref as keyof typeof layerRefToGerberPrefix]}${layerTypeToGerberSuffix[layer_type]}`
25+
const prefix = layerRefToGerberPrefix[layer_ref as string]
26+
if (!prefix) {
27+
throw new Error(`Unknown layer ref: ${layer_ref}`)
28+
}
29+
return `${prefix}${layerTypeToGerberSuffix[layer_type]}` as GerberLayerName
2230
}

src/gerber/convert-soup-to-gerber-commands/index.ts

Lines changed: 77 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,25 @@ export const convertSoupToGerberCommands = (
3838
): LayerToGerberCommandsMap => {
3939
opts.flip_y_axis ??= false
4040
const hasPanel = soup.some((e) => e.type === "pcb_panel")
41+
42+
// Detect which inner copper layers have traces in this design
43+
const innerLayersUsed = new Set<"inner1" | "inner2" | "inner3" | "inner4">()
44+
for (const element of soup) {
45+
if (element.type === "pcb_trace") {
46+
for (const segment of element.route) {
47+
if (
48+
segment.route_type === "wire" &&
49+
(segment.layer === "inner1" ||
50+
segment.layer === "inner2" ||
51+
segment.layer === "inner3" ||
52+
segment.layer === "inner4")
53+
) {
54+
innerLayersUsed.add(segment.layer as any)
55+
}
56+
}
57+
}
58+
}
59+
4160
const glayers: LayerToGerberCommandsMap = {
4261
F_Cu: getCommandHeaders({
4362
layer: "top",
@@ -76,6 +95,26 @@ export const convertSoupToGerberCommands = (
7695
}),
7796
}
7897

98+
// Initialize inner copper layers that are actually used
99+
const innerLayerGlayerNames: Array<{
100+
inner: "inner1" | "inner2" | "inner3" | "inner4"
101+
glayerKey: keyof LayerToGerberCommandsMap
102+
}> = [
103+
{ inner: "inner1", glayerKey: "In1_Cu" },
104+
{ inner: "inner2", glayerKey: "In2_Cu" },
105+
{ inner: "inner3", glayerKey: "In3_Cu" },
106+
{ inner: "inner4", glayerKey: "In4_Cu" },
107+
]
108+
109+
for (const { inner, glayerKey } of innerLayerGlayerNames) {
110+
if (innerLayersUsed.has(inner)) {
111+
glayers[glayerKey] = getCommandHeaders({
112+
layer: inner,
113+
layer_type: "copper",
114+
})
115+
}
116+
}
117+
79118
for (const glayer_name of [
80119
"F_Cu",
81120
"B_Cu",
@@ -95,6 +134,19 @@ export const convertSoupToGerberCommands = (
95134
})
96135
}
97136

137+
// Initialize apertures for inner copper layers
138+
for (const { glayerKey } of innerLayerGlayerNames) {
139+
const glayer = glayers[glayerKey]
140+
if (glayer) {
141+
defineCommonMacros(glayer)
142+
defineAperturesForLayer({
143+
soup,
144+
glayer,
145+
glayer_name: glayerKey as any,
146+
})
147+
}
148+
}
149+
98150
// Edgecuts has a single aperature
99151
glayers.Edge_Cuts.push(
100152
...gerberBuilder()
@@ -591,7 +643,13 @@ export const convertSoupToGerberCommands = (
591643
}
592644

593645
// SECOND PASS: Process all other elements (traces, pads, vias, etc.)
594-
for (const layer of ["top", "bottom", "edgecut"] as const) {
646+
const activeInnerLayers = Array.from(innerLayersUsed)
647+
for (const layer of [
648+
"top",
649+
...activeInnerLayers,
650+
"bottom",
651+
"edgecut",
652+
] as const) {
595653
for (const element of soup) {
596654
if (element.type === "pcb_trace") {
597655
const { route } = element
@@ -603,17 +661,19 @@ export const convertSoupToGerberCommands = (
603661
if (a.route_type === "wire") {
604662
if (a.layer === layer) {
605663
const glayer = glayers[getGerberLayerName(layer, "copper")]
606-
glayer.push(
607-
...gerberBuilder()
608-
.add("select_aperture", {
609-
aperture_number: findApertureNumber(glayer, {
610-
trace_width: a.width,
611-
}),
612-
})
613-
.add("move_operation", { x: a.x, y: mfy(a.y) })
614-
.add("plot_operation", { x: b.x, y: mfy(b.y) })
615-
.build(),
616-
)
664+
if (glayer) {
665+
glayer.push(
666+
...gerberBuilder()
667+
.add("select_aperture", {
668+
aperture_number: findApertureNumber(glayer, {
669+
trace_width: a.width,
670+
}),
671+
})
672+
.add("move_operation", { x: a.x, y: mfy(a.y) })
673+
.add("plot_operation", { x: b.x, y: mfy(b.y) })
674+
.build(),
675+
)
676+
}
617677
}
618678
}
619679
}
@@ -644,15 +704,18 @@ export const convertSoupToGerberCommands = (
644704
}
645705
} else if (
646706
element.type === "pcb_silkscreen_text" &&
647-
layer !== "edgecut"
707+
(layer === "top" || layer === "bottom")
648708
) {
649709
renderVectorText(
650710
element,
651711
layer,
652712
"silkscreen",
653713
getApertureConfigFromPcbSilkscreenText,
654714
)
655-
} else if (element.type === "pcb_copper_text" && layer !== "edgecut") {
715+
} else if (
716+
element.type === "pcb_copper_text" &&
717+
(layer === "top" || layer === "bottom")
718+
) {
656719
renderVectorText(
657720
element,
658721
layer,
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { test, expect } from "bun:test"
2+
import { convertSoupToGerberCommands } from "src/gerber/convert-soup-to-gerber-commands"
3+
import { stringifyGerberCommands } from "src/gerber/stringify-gerber"
4+
5+
// Test that 4-layer boards emit In1_Cu and In2_Cu gerber files
6+
// for traces routed on inner1 and inner2 layers
7+
test("4-layer board emits In1_Cu and In2_Cu gerbers for inner layer traces", () => {
8+
// Minimal circuit JSON with a trace on inner1 and inner2
9+
const circuitJson: any[] = [
10+
{
11+
type: "source_component",
12+
source_component_id: "sc0",
13+
name: "U1",
14+
ftype: "simple_chip",
15+
},
16+
{
17+
type: "pcb_board",
18+
pcb_board_id: "board0",
19+
center: { x: 0, y: 0 },
20+
width: 20,
21+
height: 20,
22+
num_layers: 4,
23+
},
24+
{
25+
type: "pcb_trace",
26+
pcb_trace_id: "trace_inner1",
27+
source_trace_id: "st0",
28+
route: [
29+
{
30+
route_type: "wire",
31+
x: 0,
32+
y: 0,
33+
width: 0.25,
34+
layer: "inner1",
35+
start_pcb_port_id: "pp0",
36+
},
37+
{
38+
route_type: "wire",
39+
x: 5,
40+
y: 0,
41+
width: 0.25,
42+
layer: "inner1",
43+
},
44+
],
45+
},
46+
{
47+
type: "pcb_trace",
48+
pcb_trace_id: "trace_inner2",
49+
source_trace_id: "st1",
50+
route: [
51+
{
52+
route_type: "wire",
53+
x: 0,
54+
y: 2,
55+
width: 0.3,
56+
layer: "inner2",
57+
start_pcb_port_id: "pp1",
58+
},
59+
{
60+
route_type: "wire",
61+
x: 5,
62+
y: 2,
63+
width: 0.3,
64+
layer: "inner2",
65+
},
66+
],
67+
},
68+
]
69+
70+
const gerberCmds = convertSoupToGerberCommands(circuitJson)
71+
72+
// In1_Cu and In2_Cu should be present for the inner layer traces
73+
expect(gerberCmds.In1_Cu).toBeDefined()
74+
expect(gerberCmds.In2_Cu).toBeDefined()
75+
76+
// In3_Cu and In4_Cu should NOT be present (no traces on those layers)
77+
expect(gerberCmds.In3_Cu).toBeUndefined()
78+
expect(gerberCmds.In4_Cu).toBeUndefined()
79+
80+
// Standard layers should still be present
81+
expect(gerberCmds.F_Cu).toBeDefined()
82+
expect(gerberCmds.B_Cu).toBeDefined()
83+
expect(gerberCmds.Edge_Cuts).toBeDefined()
84+
85+
// The In1_Cu gerber should contain a trace (wire plot operation)
86+
const in1GerberStr = stringifyGerberCommands(gerberCmds.In1_Cu!)
87+
expect(in1GerberStr).toContain("D01*") // plot operation (draw)
88+
expect(in1GerberStr).toContain("D02*") // move operation
89+
90+
const in2GerberStr = stringifyGerberCommands(gerberCmds.In2_Cu!)
91+
expect(in2GerberStr).toContain("D01*")
92+
expect(in2GerberStr).toContain("D02*")
93+
94+
// In1_Cu header should identify it as an inner copper layer
95+
expect(in1GerberStr).toContain("Copper,L2,Inr")
96+
expect(in2GerberStr).toContain("Copper,L3,Inr")
97+
})
98+
99+
// Test that 2-layer boards (the default) do NOT emit inner layer gerbers
100+
test("2-layer board does not emit inner copper layer gerbers", () => {
101+
const circuitJson: any[] = [
102+
{
103+
type: "pcb_board",
104+
pcb_board_id: "board0",
105+
center: { x: 0, y: 0 },
106+
width: 20,
107+
height: 20,
108+
},
109+
{
110+
type: "pcb_trace",
111+
pcb_trace_id: "trace_top",
112+
source_trace_id: "st0",
113+
route: [
114+
{
115+
route_type: "wire",
116+
x: 0,
117+
y: 0,
118+
width: 0.25,
119+
layer: "top",
120+
},
121+
{
122+
route_type: "wire",
123+
x: 5,
124+
y: 0,
125+
width: 0.25,
126+
layer: "top",
127+
},
128+
],
129+
},
130+
]
131+
132+
const gerberCmds = convertSoupToGerberCommands(circuitJson)
133+
134+
// Inner layers should not be present for a 2-layer board
135+
expect(gerberCmds.In1_Cu).toBeUndefined()
136+
expect(gerberCmds.In2_Cu).toBeUndefined()
137+
138+
// Standard layers still present
139+
expect(gerberCmds.F_Cu).toBeDefined()
140+
expect(gerberCmds.B_Cu).toBeDefined()
141+
})

0 commit comments

Comments
 (0)