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
Original file line number Diff line number Diff line change
Expand Up @@ -33,20 +33,34 @@ export const convertDsnPcbComponentsToSourceComponentsAndPorts = ({
// Create ports for each pin in the image
if (image.pins) {
for (const pin of image.pins) {
// Non-numeric pin names (EPAD, GND, A…) must not become NaN;
// keep the raw token in name/port_hints instead.
const numericPinNumber = Number(pin.pin_number)
const port: SourcePort = {
type: "source_port",
source_port_id: `source_port_${component.name}-Pad${pin.pin_number}_${place.refdes}`,
source_component_id: sourceComponent.source_component_id,
name: `${place.refdes}-${pin.pin_number}`,
pin_number: Number(pin.pin_number),
port_hints: [],
...(Number.isNaN(numericPinNumber)
? {}
: { pin_number: numericPinNumber }),
port_hints: [String(pin.pin_number)],
}
// Handle case where place coordinates might be null/undefined
const placeX = place.x || 0
const placeY = place.y || 0

// place.rotation is CCW in DSN space (y-up); rotate the pin offset
// around the component center to match pad placement.
const rotationRad = ((place.rotation || 0) * Math.PI) / 180
const cos = Math.cos(rotationRad)
const sin = Math.sin(rotationRad)
const rotatedOffsetX = pin.x * cos - pin.y * sin
const rotatedOffsetY = pin.x * sin + pin.y * cos

const pcb_port_center = applyToPoint(transformDsnUnitToMm, {
x: placeX + pin.x,
y: placeY + pin.y,
x: placeX + rotatedOffsetX,
y: placeY + rotatedOffsetY,
})
const pcb_port: PcbPort = {
pcb_port_id: `pcb_port_${component.name}-Pad${pin.pin_number}_${place.refdes}`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,18 @@ function isApproximatelyEqual(a: number, b: number) {
return Math.abs(a - b) < 1e-6
}

/** Rotate a point counterclockwise by the given angle in radians */
function rotateOffset(
x: number,
y: number,
rad: number,
): { x: number; y: number } {
if (rad === 0) return { x, y }
const cos = Math.cos(rad)
const sin = Math.sin(rad)
return { x: x * cos - y * sin, y: x * sin + y * cos }
}

export function convertPadstacksToSmtPads(
pcb: DsnPcb,
dsnToCircuitJsonTransform: any,
Expand All @@ -110,6 +122,9 @@ export function convertPadstacksToSmtPads(
debug("processing place...", { place })
const { x: compX, y: compY, side } = place

// place.rotation is CCW in DSN space (y-up); convert to radians.
const rotationRad = ((place.rotation || 0) * Math.PI) / 180

image.pins.forEach((pin) => {
const padstack = padstacks.find((p) => p.name === pin.padstack_name)
debug("found padstack", { padstack })
Expand All @@ -119,11 +134,12 @@ export function convertPadstacksToSmtPads(
return
}

const rotatedOffset = rotateOffset(pin.x, pin.y, rotationRad)
const { x: circuitX, y: circuitY } = applyToPoint(
dsnToCircuitJsonTransform,
{
x: (compX || 0) + pin.x,
y: (compY || 0) + pin.y,
x: (compX || 0) + rotatedOffset.x,
y: (compY || 0) + rotatedOffset.y,
},
)

Expand All @@ -133,6 +149,12 @@ export function convertPadstacksToSmtPads(
port_hints: [pin.pin_number.toString()],
}
const pcbPlatedHoleId = `pcb_plated_hole_${componentId}_${place.refdes}_${pin.pin_number}`
// Numeric pins keep the historical 0-based index in the id; string
// pin names (EPAD, GND, A…) are sanitized into the id instead of
// producing "NaN".
const pinIdSuffix = Number.isNaN(Number(pin.pin_number))
? String(pin.pin_number).replace(/[^a-zA-Z0-9_-]/g, "_")
: String(Number(pin.pin_number) - 1)
const parsedPadstackName = parsePadstackName(padstack.name)

// ── Through-hole detection ──────────────────────────────────────────
Expand Down Expand Up @@ -286,7 +308,7 @@ export function convertPadstacksToSmtPads(
const layer = getLayerFromPadstack(padstack)
pcbPad = {
type: "pcb_smtpad",
pcb_smtpad_id: `pcb_smtpad_${componentId}_${place.refdes}_${Number(pin.pin_number) - 1}`,
pcb_smtpad_id: `pcb_smtpad_${componentId}_${place.refdes}_${pinIdSuffix}`,
...commonIds,
shape: "polygon",
points: getPolygonPoints(polygonShape.coordinates, {
Expand All @@ -299,7 +321,7 @@ export function convertPadstacksToSmtPads(
const layer = getLayerFromPadstack(padstack)
pcbPad = {
type: "pcb_smtpad",
pcb_smtpad_id: `pcb_smtpad_${componentId}_${place.refdes}_${Number(pin.pin_number) - 1}`,
pcb_smtpad_id: `pcb_smtpad_${componentId}_${place.refdes}_${pinIdSuffix}`,
...commonIds,
shape: "rect",
x: circuitX,
Expand All @@ -311,7 +333,7 @@ export function convertPadstacksToSmtPads(
} else {
pcbPad = {
type: "pcb_smtpad",
pcb_smtpad_id: `pcb_smtpad_${componentId}_${place.refdes}_${Number(pin.pin_number) - 1}`,
pcb_smtpad_id: `pcb_smtpad_${componentId}_${place.refdes}_${pinIdSuffix}`,
...commonIds,
shape: "circle",
x: circuitX,
Expand Down
47 changes: 44 additions & 3 deletions lib/dsn-pcb/dsn-json-to-circuit-json/parse-dsn-to-dsn-json.ts
Original file line number Diff line number Diff line change
Expand Up @@ -577,18 +577,59 @@ function processPin(nodes: ASTNode[]): Pin | null {
return null
}
pin.padstack_name = String(nodes[1].value)
// check if pin number is in a List structure
const pinNumber = getPinNum(nodes)

// Optional rotation: (pin <padstack> (rotate <deg>) <pin_number> <x> <y>)
let rotation: number | undefined
let pinNumNodeIndex = 2
if (
nodes[2]?.type === "List" &&
nodes[2].children?.[0]?.type === "Atom" &&
nodes[2].children[0].value === "rotate"
) {
const angle = nodes[2].children[1]?.value
if (typeof angle === "number") {
rotation = angle
}
pinNumNodeIndex = 3
}

// Pin number: direct Atom or single-child List, after any rotate sublist
const pinNumNode = nodes[pinNumNodeIndex]
let pinNumber: number | string | null
if (pinNumNode?.type === "Atom") {
if (typeof pinNumNode.value === "number") {
pinNumber = pinNumNode.value
} else {
const parsed = parseInt(String(pinNumNode.value), 10)
pinNumber = Number.isNaN(parsed) ? String(pinNumNode.value) : parsed
}
} else if (
pinNumNode?.type === "List" &&
pinNumNode.children?.length === 1
) {
const parsed = parseInt(String(pinNumNode.children[0].value), 10)
pinNumber = Number.isNaN(parsed) ? null : parsed
} else {
debug("Unsupported pin number format:", nodes)
pinNumber = null
}

if (pinNumber === null) return null

pin.pin_number = pinNumber
if (rotation !== undefined) {
;(pin as any).rotation = rotation
}

// Parse coordinates
let xValue: number | undefined
let yValue: number | undefined

for (let i = 3; i < nodes.length; i++) {
// With a rotate sublist, pin number is at index 3 and coords start at 4;
// otherwise pin number is at 2 and coords start at 3.
const coordStart = rotation !== undefined ? 4 : 3

for (let i = coordStart; i < nodes.length; i++) {
const node = nodes[i]
const nextNode = nodes[i + 1]

Expand Down
8 changes: 5 additions & 3 deletions tests/fixtures/get-test-debug-utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mkdirSync } from "node:fs"
import { mkdirSync, writeFileSync } from "node:fs"
import Debug from "debug"

/**
Expand All @@ -18,12 +18,14 @@ import Debug from "debug"
* )
*/
export const getTestDebugUtils = (testPath: string) => {
const testFileDir = testPath.split("/").pop()?.split(".")[0]
const testFileDir = testPath.split(/[\\/]/).pop()?.split(".")[0]
mkdirSync(`./debug-files/${testFileDir}`, { recursive: true })
return {
debug: Debug(`dsn-converter:${testFileDir}`),
writeDebugFile: (name: string, content: string) => {
Bun.write(`./debug-files/${testFileDir}/${name}`, content)
// Synchronous write: these files are read back (e.g. by looksSame)
// within the same test, and unawaited async writes race the read.
writeFileSync(`./debug-files/${testFileDir}/${name}`, content)
},
getDebugFilePath: (name: string) => {
return `./debug-files/${testFileDir}/${name}`
Expand Down
2 changes: 1 addition & 1 deletion tests/repros/__snapshots__/repro7-smoothie-board.snap.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
107 changes: 107 additions & 0 deletions tests/repros/place-rotation-applied.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { expect, test } from "bun:test"
import type { DsnPcb } from "lib"
import { convertPadstacksToSmtPads } from "lib/dsn-pcb/dsn-json-to-circuit-json/dsn-component-converters/convert-padstacks-to-smtpads"
import { scale } from "transformation-matrix"

const transform = scale(1 / 1000)

// A 3-pad footprint placed rotated 90°: without applying place.rotation,
// the pads stay at their unrotated offsets.
const pcb: DsnPcb = {
is_dsn_pcb: true,
filename: "rot-test.dsn",
parser: {
string_quote: '"',
host_version: "8.0",
space_in_quoted_tokens: "on",
host_cad: "KiCad's Pcbnew",
},
resolution: { unit: "um", value: 10 },
unit: "um",
structure: {
layers: [{ name: "F.Cu", type: "signal", property: { index: 0 } }],
boundary: {
path: {
layer: "F.Cu",
width: 0,
coordinates: [0, 0, 10000, 0, 10000, 10000, 0, 10000],
},
},
via: "Via[0-1]_600:400_um",
rule: { clearances: [{ value: 200 }], width: 200 },
},
placement: {
components: [
{
name: "test:footprint",
places: [
{ refdes: "R1", x: 5000, y: 5000, side: "front", rotation: 90 },
],
},
],
},
library: {
images: [
{
name: "test:footprint",
outlines: [],
pins: [
{
padstack_name: "Rect[T]Pad_1000x500_um",
pin_number: 1,
x: 1000,
y: 0,
},
{
padstack_name: "Rect[T]Pad_1000x500_um",
pin_number: 2,
x: -1000,
y: 0,
},
],
},
],
padstacks: [
{
name: "Rect[T]Pad_1000x500_um",
shapes: [
{
shapeType: "rect",
layer: "F.Cu",
width: 0,
coordinates: [-500, -250, 500, 250],
},
],
attach: "off",
},
],
},
network: { nets: [], classes: [] },
wiring: { wires: [] },
} as unknown as DsnPcb

test("place.rotation rotates pad offsets around the component center", () => {
const elements = convertPadstacksToSmtPads(pcb, transform)
const pads = elements.filter((e: any) => e.type === "pcb_smtpad") as any[]

expect(pads.length).toBe(2)

// Pad 1 at local (1000, 0) um rotated 90° CCW around component center
// in DSN space (y-up): offset becomes (0, 1000) → mm: (5, 6)
const pad1 = pads.find((p) => p.port_hints[0] === "1")
const pad2 = pads.find((p) => p.port_hints[0] === "2")

// DSN y-up → circuit y-down flip happens in the transform; verify offsets
// are perpendicular to the original axis (x-offset rotates onto y-axis)
const mm = (v: number) => v / 1000
const compX = mm(5000)
const compY = mm(5000)
const d1x = pad1.x - compX
const d1y = pad1.y - compY
const d2x = pad2.x - compX
const d2y = pad2.y - compY

// Rotation must move the ±1000um x-offsets onto the y-axis (±1mm)
expect(Math.abs(d1x) < 0.001 && Math.abs(d1y) - 1 < 0.01).toBe(true)
expect(Math.abs(d2x) < 0.001 && Math.abs(d2y) - 1 < 0.01).toBe(true)
})
22 changes: 22 additions & 0 deletions tests/repros/string-pin-numbers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { expect, test } from "bun:test"
import { convertDsnPcbToCircuitJson, parseDsnToDsnJson, type DsnPcb } from "lib"
// @ts-ignore
import dsn from "../assets/repro/smoothieboard-repro.dsn" with { type: "text" }

test("string pin numbers do not produce NaN source_port.pin_number", () => {
const dsnJson = parseDsnToDsnJson(dsn) as DsnPcb
const cj = convertDsnPcbToCircuitJson(dsnJson) as any[]

const ports = cj.filter((e) => e.type === "source_port")
const nanPorts = ports.filter(
(p) => p.pin_number !== undefined && Number.isNaN(p.pin_number),
)

// The smoothieboard has 78 non-numeric pin names (EPAD, GND, A, C, P$1…).
// They must simply omit pin_number rather than carry NaN.
expect(nanPorts.length).toBe(0)

// And string-named pins must still be reachable via port_hints/name
const epad = ports.find((p) => p.name.includes("EPAD"))
expect(epad).toBeDefined()
})
Loading