diff --git a/lib/dsn-pcb/dsn-json-to-circuit-json/dsn-component-converters/convert-dsn-pcb-components-to-source-components-and-ports.ts b/lib/dsn-pcb/dsn-json-to-circuit-json/dsn-component-converters/convert-dsn-pcb-components-to-source-components-and-ports.ts index 14b7ce0c..3df70861 100644 --- a/lib/dsn-pcb/dsn-json-to-circuit-json/dsn-component-converters/convert-dsn-pcb-components-to-source-components-and-ports.ts +++ b/lib/dsn-pcb/dsn-json-to-circuit-json/dsn-component-converters/convert-dsn-pcb-components-to-source-components-and-ports.ts @@ -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}`, diff --git a/lib/dsn-pcb/dsn-json-to-circuit-json/dsn-component-converters/convert-padstacks-to-smtpads.ts b/lib/dsn-pcb/dsn-json-to-circuit-json/dsn-component-converters/convert-padstacks-to-smtpads.ts index 7da192cf..86201ee4 100644 --- a/lib/dsn-pcb/dsn-json-to-circuit-json/dsn-component-converters/convert-padstacks-to-smtpads.ts +++ b/lib/dsn-pcb/dsn-json-to-circuit-json/dsn-component-converters/convert-padstacks-to-smtpads.ts @@ -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, @@ -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 }) @@ -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, }, ) @@ -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 ────────────────────────────────────────── @@ -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, { @@ -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, @@ -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, diff --git a/lib/dsn-pcb/dsn-json-to-circuit-json/parse-dsn-to-dsn-json.ts b/lib/dsn-pcb/dsn-json-to-circuit-json/parse-dsn-to-dsn-json.ts index 6ca0c7b2..6805b9f8 100644 --- a/lib/dsn-pcb/dsn-json-to-circuit-json/parse-dsn-to-dsn-json.ts +++ b/lib/dsn-pcb/dsn-json-to-circuit-json/parse-dsn-to-dsn-json.ts @@ -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 (rotate ) ) + 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] diff --git a/tests/fixtures/get-test-debug-utils.ts b/tests/fixtures/get-test-debug-utils.ts index 15a556ea..66f37810 100644 --- a/tests/fixtures/get-test-debug-utils.ts +++ b/tests/fixtures/get-test-debug-utils.ts @@ -1,4 +1,4 @@ -import { mkdirSync } from "node:fs" +import { mkdirSync, writeFileSync } from "node:fs" import Debug from "debug" /** @@ -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}` diff --git a/tests/repros/__snapshots__/repro7-smoothie-board.snap.svg b/tests/repros/__snapshots__/repro7-smoothie-board.snap.svg index 15f7e5a4..129674cb 100644 --- a/tests/repros/__snapshots__/repro7-smoothie-board.snap.svg +++ b/tests/repros/__snapshots__/repro7-smoothie-board.snap.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/tests/repros/place-rotation-applied.test.ts b/tests/repros/place-rotation-applied.test.ts new file mode 100644 index 00000000..bf98efdb --- /dev/null +++ b/tests/repros/place-rotation-applied.test.ts @@ -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) +}) diff --git a/tests/repros/string-pin-numbers.test.ts b/tests/repros/string-pin-numbers.test.ts new file mode 100644 index 00000000..e2c8f364 --- /dev/null +++ b/tests/repros/string-pin-numbers.test.ts @@ -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() +})