Skip to content

Commit eb8df6e

Browse files
authored
Support arcs in fp_poly polygons (#162)
* Handle fp_poly arc segments * Render filled fp_poly copper polygons * Fix fp_poly arc sampling orientation * Remove unused point deduplication logic
1 parent bb820b6 commit eb8df6e

12 files changed

+339
-24
lines changed

src/convert-kicad-json-to-tscircuit-soup.ts

Lines changed: 80 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ const getAxisAlignedRectFromPoints = (
5151
}
5252
}
5353

54+
const fpPolyHasFill = (fill?: string) => {
55+
if (!fill) return false
56+
const normalized = fill.toLowerCase()
57+
return (
58+
normalized !== "no" && normalized !== "none" && normalized !== "outline"
59+
)
60+
}
61+
5462
const getRotationDeg = (at: number[] | undefined) => {
5563
if (!at) return 0
5664
if (Array.isArray(at) && at.length >= 3 && typeof at[2] === "number") {
@@ -577,9 +585,52 @@ export const convertKicadJsonToTsCircuitSoup = async (
577585

578586
if (fp_polys) {
579587
for (const fp_poly of fp_polys) {
580-
const route = fp_poly.pts.map((p) => ({ x: p[0], y: -p[1] }))
588+
const route: Array<{ x: number; y: number }> = []
589+
const pushRoutePoint = (point: { x: number; y: number }) => {
590+
if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) {
591+
return
592+
}
593+
route.push(point)
594+
}
595+
for (const segment of fp_poly.pts) {
596+
if (Array.isArray(segment)) {
597+
pushRoutePoint({ x: segment[0], y: -segment[1] })
598+
continue
599+
}
600+
if (segment && typeof segment === "object" && "kind" in segment) {
601+
if (segment.kind === "arc") {
602+
const start = makePoint(segment.start)
603+
const mid = makePoint(segment.mid)
604+
const end = makePoint(segment.end)
605+
const arcLength = getArcLength(start, mid, end)
606+
const numPoints = Math.max(8, Math.ceil(arcLength))
607+
const adjustedNumPoints = Math.max(2, Math.ceil(arcLength / 0.1))
608+
const arcPoints = generateArcPath(
609+
start,
610+
mid,
611+
end,
612+
adjustedNumPoints,
613+
).map((p) => ({
614+
x: p.x,
615+
y: -p.y,
616+
}))
617+
for (const point of arcPoints) {
618+
pushRoutePoint(point)
619+
}
620+
}
621+
continue
622+
}
623+
}
624+
const routePoints = route
625+
const isClosed =
626+
routePoints.length > 2 &&
627+
routePoints[0]!.x === routePoints[routePoints.length - 1]!.x &&
628+
routePoints[0]!.y === routePoints[routePoints.length - 1]!.y
629+
const polygonPoints = isClosed ? routePoints.slice(0, -1) : routePoints
630+
if (routePoints.length === 0) continue
631+
const strokeWidth = fp_poly.stroke?.width ?? 0
581632
if (fp_poly.layer.endsWith(".Cu")) {
582-
const rect = getAxisAlignedRectFromPoints(route)
633+
const rect = getAxisAlignedRectFromPoints(polygonPoints)
583634
if (rect) {
584635
circuitJson.push({
585636
type: "pcb_smtpad",
@@ -592,14 +643,34 @@ export const convertKicadJsonToTsCircuitSoup = async (
592643
layer: convertKicadLayerToTscircuitLayer(fp_poly.layer)!,
593644
pcb_component_id,
594645
} as any)
595-
} else {
646+
} else if (fpPolyHasFill(fp_poly.fill)) {
647+
if (polygonPoints.length >= 3) {
648+
circuitJson.push({
649+
type: "pcb_smtpad",
650+
pcb_smtpad_id: `pcb_smtpad_${smtpadId++}`,
651+
shape: "polygon",
652+
points: polygonPoints,
653+
layer: convertKicadLayerToTscircuitLayer(fp_poly.layer)!,
654+
pcb_component_id,
655+
} as any)
656+
} else if (polygonPoints.length >= 2) {
657+
circuitJson.push({
658+
type: "pcb_trace",
659+
pcb_trace_id: `pcb_trace_${traceId++}`,
660+
pcb_component_id,
661+
layer: convertKicadLayerToTscircuitLayer(fp_poly.layer)!,
662+
route: polygonPoints,
663+
thickness: strokeWidth,
664+
} as any)
665+
}
666+
} else if (polygonPoints.length >= 2) {
596667
circuitJson.push({
597668
type: "pcb_trace",
598669
pcb_trace_id: `pcb_trace_${traceId++}`,
599670
pcb_component_id,
600671
layer: convertKicadLayerToTscircuitLayer(fp_poly.layer)!,
601-
route,
602-
thickness: fp_poly.stroke.width,
672+
route: polygonPoints,
673+
thickness: strokeWidth,
603674
} as any)
604675
}
605676
} else if (fp_poly.layer.endsWith(".SilkS")) {
@@ -608,17 +679,17 @@ export const convertKicadJsonToTsCircuitSoup = async (
608679
pcb_silkscreen_path_id: `pcb_silkscreen_path_${silkPathId++}`,
609680
pcb_component_id,
610681
layer: convertKicadLayerToTscircuitLayer(fp_poly.layer)!,
611-
route,
612-
stroke_width: fp_poly.stroke.width,
682+
route: routePoints,
683+
stroke_width: strokeWidth,
613684
} as any)
614685
} else if (fp_poly.layer.endsWith(".Fab")) {
615686
circuitJson.push({
616687
type: "pcb_fabrication_note_path",
617688
fabrication_note_path_id: `fabrication_note_path_${fabPathId++}`,
618689
pcb_component_id,
619690
layer: convertKicadLayerToTscircuitLayer(fp_poly.layer)!,
620-
route,
621-
stroke_width: fp_poly.stroke.width,
691+
route: polygonPoints,
692+
stroke_width: strokeWidth,
622693
port_hints: [],
623694
} as any)
624695
} else {

src/get-attr.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,24 @@ export const formatAttr = (val: any, attrKey: string) => {
2424
return effects_def.parse(effectsObj)
2525
}
2626
if (attrKey === "pts") {
27-
// val is like [ [ 'xy', -1.25, -0.625 ], [ 'xy', 1.25, -0.625 ], ... ]
28-
return val.map((xy_pair: any[]) =>
29-
xy_pair.slice(1).map((n: any) => Number.parseFloat(n.valueOf())),
30-
)
27+
// val can include coordinate tuples as well as arc definitions.
28+
return val.map((segment: any[]) => {
29+
const segmentType = segment[0]?.valueOf?.() ?? segment[0]
30+
if (segmentType === "xy") {
31+
return segment.slice(1).map((n: any) => Number.parseFloat(n.valueOf()))
32+
}
33+
if (segmentType === "arc") {
34+
const arcObj: Record<string, any> = { kind: "arc" }
35+
for (const arcAttr of segment.slice(1)) {
36+
const key = arcAttr[0].valueOf()
37+
arcObj[key] = arcAttr
38+
.slice(1)
39+
.map((n: any) => Number.parseFloat(n.valueOf()))
40+
}
41+
return arcObj
42+
}
43+
return segment
44+
})
3145
}
3246
if (attrKey === "stroke") {
3347
const strokeObj: any = {}

src/kicad-zod.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@ export const point = z.union([point2, point3])
66

77
type MakeRequired<T, K extends keyof T> = Omit<T, K> & Required<Pick<T, K>>
88

9+
export const fp_poly_arc_segment_def = z.object({
10+
kind: z.literal("arc"),
11+
start: point2,
12+
mid: point2,
13+
end: point2,
14+
})
15+
16+
export const fp_poly_point_def = z.union([point2, fp_poly_arc_segment_def])
17+
918
export const attributes_def = z
1019
.object({
1120
at: point,
@@ -185,7 +194,7 @@ export const fp_circle_def = z.object({
185194

186195
export const fp_poly_def = z
187196
.object({
188-
pts: z.array(point2),
197+
pts: z.array(fp_poly_point_def),
189198
stroke: z
190199
.object({
191200
width: z.number(),
@@ -195,6 +204,7 @@ export const fp_poly_def = z
195204
width: z.number().optional(),
196205
layer: z.string(),
197206
uuid: z.string().optional(),
207+
fill: z.string().optional(),
198208
})
199209
// Old kicad versions don't have "stroke"
200210
.transform((data) => {

src/math/arc-utils.ts

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,22 @@ export interface Point {
33
y: number
44
}
55

6+
const TWO_PI = Math.PI * 2
7+
8+
const normalizeAngle = (angle: number) => {
9+
let result = angle % TWO_PI
10+
if (result < 0) result += TWO_PI
11+
return result
12+
}
13+
14+
const directedAngleCCW = (start: number, target: number) => {
15+
const startNorm = normalizeAngle(start)
16+
let targetNorm = normalizeAngle(target)
17+
let delta = targetNorm - startNorm
18+
if (delta < 0) delta += TWO_PI
19+
return delta
20+
}
21+
622
export function calculateCenter(start: Point, mid: Point, end: Point): Point {
723
const mid1 = { x: (start.x + mid.x) / 2, y: (start.y + mid.y) / 2 }
824
const mid2 = { x: (mid.x + end.x) / 2, y: (mid.y + end.y) / 2 }
@@ -30,14 +46,18 @@ export const getArcLength = (start: Point, mid: Point, end: Point) => {
3046
const radius = calculateRadius(center, start)
3147

3248
const angleStart = calculateAngle(center, start)
49+
const angleMid = calculateAngle(center, mid)
3350
const angleEnd = calculateAngle(center, end)
3451

35-
let angleDelta = angleEnd - angleStart
36-
if (angleDelta < 0) {
37-
angleDelta += 2 * Math.PI
52+
const ccwToMid = directedAngleCCW(angleStart, angleMid)
53+
const ccwToEnd = directedAngleCCW(angleStart, angleEnd)
54+
55+
let angleDelta = ccwToEnd
56+
if (ccwToMid > ccwToEnd) {
57+
angleDelta = ccwToEnd - TWO_PI
3858
}
3959

40-
return radius * angleDelta
60+
return Math.abs(radius * angleDelta)
4161
}
4262

4363
export function generateArcPath(
@@ -50,11 +70,15 @@ export function generateArcPath(
5070
const radius = calculateRadius(center, start)
5171

5272
const angleStart = calculateAngle(center, start)
73+
const angleMid = calculateAngle(center, mid)
5374
const angleEnd = calculateAngle(center, end)
5475

55-
let angleDelta = angleEnd - angleStart
56-
if (angleDelta < 0) {
57-
angleDelta += 2 * Math.PI
76+
const ccwToMid = directedAngleCCW(angleStart, angleMid)
77+
const ccwToEnd = directedAngleCCW(angleStart, angleEnd)
78+
79+
let angleDelta = ccwToEnd
80+
if (ccwToMid > ccwToEnd) {
81+
angleDelta = ccwToEnd - TWO_PI
5882
}
5983

6084
const path: Point[] = []

src/parse-kicad-mod-to-kicad-json.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -209,14 +209,27 @@ export const parseKicadModToKicadJson = (fileContent: string): KicadModJson => {
209209
for (const fp_poly_row of fp_polys_rows) {
210210
const pts = getAttr(fp_poly_row, "pts")
211211
const stroke = getAttr(fp_poly_row, "stroke")
212+
const width = getAttr(fp_poly_row, "width")
212213
const layer = getAttr(fp_poly_row, "layer")
213214
const uuid = getAttr(fp_poly_row, "uuid")
214-
215+
const fill = getAttr(fp_poly_row, "fill")
216+
let normalizedStroke = stroke
217+
if (!normalizedStroke && typeof width === "number") {
218+
normalizedStroke = { width, type: "solid" }
219+
} else if (
220+
normalizedStroke &&
221+
typeof normalizedStroke === "object" &&
222+
typeof width === "number" &&
223+
normalizedStroke.width === undefined
224+
) {
225+
normalizedStroke = { ...normalizedStroke, width }
226+
}
215227
fp_polys.push({
216228
pts,
217-
stroke,
229+
stroke: normalizedStroke,
218230
layer,
219231
uuid,
232+
fill,
220233
})
221234
}
222235

tests/__snapshots__/poly.snap.svg

Lines changed: 1 addition & 1 deletion
Loading
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
(footprint "viaGrid-pacman-1"
2+
(version 20241229)
3+
(generator "pcbnew")
4+
(generator_version "9.0")
5+
(layer "F.Cu")
6+
(property "Reference" "REF**"
7+
(at 0 -0.5 0)
8+
(unlocked yes)
9+
(layer "F.SilkS")
10+
(hide yes)
11+
(uuid "cbfffe61-1e92-4daa-bd46-ae32e0dbad92")
12+
(effects
13+
(font
14+
(size 0.5 0.5)
15+
(thickness 0.1)
16+
)
17+
)
18+
)
19+
(property "Value" "viaGrid-pacman-1"
20+
(at 0 1 0)
21+
(unlocked yes)
22+
(layer "F.Fab")
23+
(hide yes)
24+
(uuid "60d7fc8a-65be-4914-a046-de64475a184c")
25+
(effects
26+
(font
27+
(size 0.5 0.5)
28+
(thickness 0.15)
29+
)
30+
)
31+
)
32+
(property "Datasheet" ""
33+
(at 0 0 0)
34+
(unlocked yes)
35+
(layer "F.Fab")
36+
(hide yes)
37+
(uuid "1143702b-768c-463c-8287-7c8084dfab91")
38+
(effects
39+
(font
40+
(size 1 1)
41+
(thickness 0.15)
42+
)
43+
)
44+
)
45+
(property "Description" ""
46+
(at 0 0 0)
47+
(unlocked yes)
48+
(layer "F.Fab")
49+
(hide yes)
50+
(uuid "515226c9-0c16-4433-b704-16fa13d28a16")
51+
(effects
52+
(font
53+
(size 1 1)
54+
(thickness 0.15)
55+
)
56+
)
57+
)
58+
(attr smd)
59+
(fp_poly
60+
(pts
61+
(xy 2.5 0.2)
62+
(xy -0.2 0.2)
63+
(xy -0.199996 -2.5)
64+
(arc
65+
(start -0.199999 -2.5)
66+
(mid -1.773415 1.773415)
67+
(end 2.5 0.199999)
68+
)
69+
)
70+
(stroke
71+
(width 0)
72+
(type solid)
73+
)
74+
(fill yes)
75+
(layer "F.Cu")
76+
(uuid "f37eddc0-cfe3-474b-b8ab-9a4c0ef62518")
77+
)
78+
(embedded_fonts no)
79+
)

0 commit comments

Comments
 (0)