Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/CIRCUIT_JSON_PCB_OVERVIEW.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ export interface PcbSilkscreenPill {
center: Point
width: Length
height: Length
rotation: Rotation
layer: LayerRef
}

Expand Down
109 changes: 94 additions & 15 deletions src/convert-kicad-json-to-tscircuit-soup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,16 @@ export const convertKicadLayerToTscircuitLayer = (kicadLayer: string) => {
export const convertKicadJsonToTsCircuitSoup = async (
kicadJson: KicadModJson,
): Promise<AnyCircuitElement[]> => {
const { fp_lines, fp_texts, fp_arcs, pads, properties, holes, fp_polys } =
kicadJson
const {
fp_lines,
fp_texts,
fp_arcs,
pads,
properties,
holes,
fp_polys,
fp_circles,
} = kicadJson

const circuitJson: AnyCircuitElement[] = []

Expand Down Expand Up @@ -453,12 +461,13 @@ export const convertKicadJsonToTsCircuitSoup = async (

let traceId = 0
let silkPathId = 0
let silkPillId = 0
let silkCircleId = 0
let fabPathId = 0
for (const fp_line of fp_lines) {
const route = [
{ x: fp_line.start[0], y: -fp_line.start[1] },
{ x: fp_line.end[0], y: -fp_line.end[1] },
]
const startPoint = { x: fp_line.start[0], y: -fp_line.start[1] }
const endPoint = { x: fp_line.end[0], y: -fp_line.end[1] }
const route = [startPoint, endPoint]
if (fp_line.layer === "F.Cu") {
circuitJson.push({
type: "pcb_trace",
Expand All @@ -468,15 +477,56 @@ export const convertKicadJsonToTsCircuitSoup = async (
route,
thickness: fp_line.stroke.width,
} as any)
} else if (fp_line.layer === "F.SilkS") {
circuitJson.push({
type: "pcb_silkscreen_path",
pcb_silkscreen_path_id: `pcb_silkscreen_path_${silkPathId++}`,
pcb_component_id,
layer: "top",
route,
stroke_width: fp_line.stroke.width,
} as any)
} else if (fp_line.layer === "F.SilkS" || fp_line.layer === "B.SilkS") {
const layerRef = convertKicadLayerToTscircuitLayer(fp_line.layer)
const strokeWidth = fp_line.stroke.width
const dx = endPoint.x - startPoint.x
const dy = endPoint.y - startPoint.y
const hasStrokeWidth = Number.isFinite(strokeWidth) && strokeWidth > 0

if (layerRef && hasStrokeWidth) {
const center = {
x: (startPoint.x + endPoint.x) / 2,
y: (startPoint.y + endPoint.y) / 2,
}
const length = Math.sqrt(dx * dx + dy * dy)

if (length < 1e-6) {
circuitJson.push({
type: "pcb_silkscreen_circle",
pcb_silkscreen_circle_id: `pcb_silkscreen_circle_${silkCircleId++}`,
pcb_component_id,
layer: layerRef,
center,
radius: strokeWidth / 2,
} as any)
continue
}

const width = length + strokeWidth
const height = strokeWidth
const rotation = ((Math.atan2(dy, dx) * 180) / Math.PI + 360) % 360

circuitJson.push({
type: "pcb_silkscreen_pill",
pcb_silkscreen_pill_id: `pcb_silkscreen_pill_${silkPillId++}`,
pcb_component_id,
layer: layerRef,
center,
width,
height,
rotation,
} as any)
} else if (layerRef) {
circuitJson.push({
type: "pcb_silkscreen_path",
pcb_silkscreen_path_id: `pcb_silkscreen_path_${silkPathId++}`,
pcb_component_id,
layer: layerRef,
route,
stroke_width: strokeWidth,
} as any)
}
} else if (fp_line.layer === "F.Fab") {
circuitJson.push({
type: "pcb_fabrication_note_path",
Expand Down Expand Up @@ -544,6 +594,35 @@ export const convertKicadJsonToTsCircuitSoup = async (
}
}

if (fp_circles) {
for (const fp_circle of fp_circles) {
const layerRef = convertKicadLayerToTscircuitLayer(fp_circle.layer)
if (!layerRef) continue

const center = {
x: fp_circle.center[0],
y: -fp_circle.center[1],
}
const radius = Math.hypot(
fp_circle.end[0] - fp_circle.center[0],
fp_circle.end[1] - fp_circle.center[1],
)

if (fp_circle.layer.endsWith(".SilkS")) {
circuitJson.push({
type: "pcb_silkscreen_circle",
pcb_silkscreen_circle_id: `pcb_silkscreen_circle_${silkCircleId++}`,
pcb_component_id,
layer: layerRef,
center,
radius,
} as any)
} else {
debug("Unhandled layer for fp_circle", fp_circle.layer)
}
}
}

for (const fp_arc of fp_arcs) {
const start = makePoint(fp_arc.start)
const mid = makePoint(fp_arc.mid)
Expand Down
3 changes: 2 additions & 1 deletion src/get-attr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ export const formatAttr = (val: any, attrKey: string) => {
attrKey === "size" ||
attrKey === "start" ||
attrKey === "mid" ||
attrKey === "end"
attrKey === "end" ||
attrKey === "center"
) {
// Some KiCad versions may include non-numeric flags like "unlocked" in
// the (at ...) attribute. Filter out any non-numeric tokens before parsing.
Expand Down
25 changes: 25 additions & 0 deletions src/kicad-zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,29 @@ export const fp_poly_def = z
} as MakeRequired<Omit<typeof data, "width">, "stroke">
})

export const fp_circle_def = z
.object({
center: point2,
end: point2,
stroke: z
.object({
width: z.number(),
type: z.string(),
})
.optional(),
width: z.number().optional(),
fill: z.any().optional(),
layer: z.string(),
uuid: z.string().optional(),
})
.transform((data) => {
const { width, stroke, ...rest } = data
return {
...rest,
stroke: stroke ?? { width: width ?? 0, type: "solid" },
} as MakeRequired<Omit<typeof data, "width">, "stroke">
})

export const fp_line = z
.object({
start: point2,
Expand Down Expand Up @@ -228,6 +251,7 @@ export const kicad_mod_json_def = z.object({
fp_texts: z.array(fp_text_def),
fp_arcs: z.array(fp_arc_def),
fp_polys: z.array(fp_poly_def).optional(),
fp_circles: z.array(fp_circle_def).optional(),
pads: z.array(pad_def),
holes: z.array(hole_def).optional(),
})
Expand All @@ -244,4 +268,5 @@ export type FpText = z.infer<typeof fp_text_def>
export type FpLine = z.infer<typeof fp_line>
export type FpArc = z.infer<typeof fp_arc_def>
export type FpPoly = z.infer<typeof fp_poly_def>
export type FpCircle = z.infer<typeof fp_circle_def>
export type KicadModJson = z.infer<typeof kicad_mod_json_def>
32 changes: 32 additions & 0 deletions src/parse-kicad-mod-to-kicad-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ import {
hole_def,
kicad_mod_json_def,
pad_def,
fp_circle_def,
type FpArc,
type FpLine,
type FpText,
type FpPoly,
type FpCircle,
type Hole,
type KicadModJson,
type Pad,
Expand Down Expand Up @@ -192,6 +194,35 @@ export const parseKicadModToKicadJson = (fileContent: string): KicadModJson => {
})
}

const fp_circles: FpCircle[] = []
const fp_circle_rows = kicadSExpr
.slice(2)
.filter((row: any[]) => row[0] === "fp_circle")

for (const fp_circle_row of fp_circle_rows) {
const center = getAttr(fp_circle_row, "center")
const end = getAttr(fp_circle_row, "end")
const stroke = getAttr(fp_circle_row, "stroke")
const width = getAttr(fp_circle_row, "width")
const fill = getAttr(fp_circle_row, "fill")
const layer = getAttr(fp_circle_row, "layer")
const uuid = getAttr(fp_circle_row, "uuid")

if (!center || !end || !layer) continue

fp_circles.push(
fp_circle_def.parse({
center,
end,
stroke,
width,
fill,
layer,
uuid,
}),
)
}

const holes: Hole[] = []

for (const row of kicadSExpr.slice(2)) {
Expand Down Expand Up @@ -254,5 +285,6 @@ export const parseKicadModToKicadJson = (fileContent: string): KicadModJson => {
pads,
holes,
fp_polys,
fp_circles,
})
}
66 changes: 66 additions & 0 deletions tests/silkscreen-shapes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { expect, test } from "bun:test"
import { parseKicadModToCircuitJson } from "src"

const SILKSCREEN_FIXTURE = `
(module silkscreen-shapes (layer F.Cu)
(pad 1 smd rect (at 0 0) (size 1 1) (layers F.Cu))
(fp_line
(start -2 1)
(end 2 1)
(stroke (width 0.4) (type solid))
(layer F.SilkS)
)
(fp_line
(start -1 -1)
(end 1 1)
(stroke (width 0.3) (type solid))
(layer F.SilkS)
)
(fp_circle
(center 0 -2)
(end 1 -2)
(stroke (width 0.2) (type solid))
(layer F.SilkS)
)
)
`

test("silkscreen pill and circle are parsed", async () => {
const circuitJson = await parseKicadModToCircuitJson(SILKSCREEN_FIXTURE)
const pills = circuitJson.filter(
(elm) => elm.type === "pcb_silkscreen_pill",
) as any[]
expect(pills.length).toBe(2)

const horizontal = pills.find((pill) => pill.width > pill.height)
expect(horizontal).toBeDefined()
expect(horizontal.layer).toBe("top")
expect(horizontal.center.x).toBeCloseTo(0)
expect(horizontal.center.y).toBeCloseTo(-1)
expect(horizontal.width).toBeCloseTo(4.4)
expect(horizontal.height).toBeCloseTo(0.4)
expect(horizontal.rotation).toBeCloseTo(0)

const diagonal = pills.find((pill) => pill.rotation !== 0)
expect(diagonal).toBeDefined()
expect(diagonal.layer).toBe("top")
expect(diagonal.center.x).toBeCloseTo(0)
expect(diagonal.center.y).toBeCloseTo(0)
expect(diagonal.width).toBeCloseTo(Math.sqrt(8) + 0.3)
expect(diagonal.height).toBeCloseTo(0.3)
expect(diagonal.rotation).toBeCloseTo(315)

const circle = circuitJson.find(
(elm) => elm.type === "pcb_silkscreen_circle",
) as any
expect(circle).toBeDefined()
expect(circle.layer).toBe("top")
expect(circle.center.x).toBeCloseTo(0)
expect(circle.center.y).toBeCloseTo(2)
expect(circle.radius).toBeCloseTo(1)

const paths = circuitJson.filter(
(elm) => elm.type === "pcb_silkscreen_path",
)
expect(paths.length).toBe(0)
})
Loading