Skip to content

fix: strip parentheses from origin parameter in string parser#569

Open
claw-explorer wants to merge 1 commit intotscircuit:mainfrom
claw-explorer:fix/origin-string-parser-parentheses
Open

fix: strip parentheses from origin parameter in string parser#569
claw-explorer wants to merge 1 commit intotscircuit:mainfrom
claw-explorer:fix/origin-string-parser-parentheses

Conversation

@claw-explorer
Copy link
Copy Markdown

Problem

The origin parameter silently fails when used in footprinter strings. All origin modes are completely broken through the string parser:

// Builder API works correctly:
fp().dip(8).origin("pin1").circuitJson()  // ✅ Pin 1 at (0, 0)

// String parser is broken — origin has no effect:
fp.string("dip8_origin(pin1)").circuitJson()  // ❌ Pin 1 at (-3.81, 3.81)
fp.string("soic8_origin(center)").circuitJson()  // ❌ Not centered
fp.string("res0402_origin(bottomleft)").circuitJson()  // ❌ No shift

All 7 origin modes are affected: center, pin1, bottomleft, bottomcenter, topcenter, leftcenter, rightcenter.

Root Cause

The string parser regex captures function-call-style parameters with their parentheses intact. For example, origin(pin1) is stored as "(pin1)" in the params object.

Other function-call parameters like missing(A1,B2) handle this through their Zod function_call transform which strips parentheses. But origin is a simple string that goes directly to applyOrigin(), which expects bare values like "pin1", not "(pin1)".

Since "(pin1)" matches none of the switch cases in applyOrigin, dx and dy remain 0 and no transformation is applied.

Fix

Normalize parenthesized values in applyOrigin() before matching:

const normalizedOrigin = (
  typeof origin === "string" && origin.startsWith("(") && origin.endsWith(")")
    ? origin.slice(1, -1)
    : origin
) as OriginMode

This is consistent with how function_call.ts handles the same pattern for other parameters.

Tests

Added 9 tests in origin-string-parser.test.ts:

  • origin(pin1) for DIP, res0402
  • origin(center) for SOIC
  • origin(bottomleft) for res0603
  • origin(topcenter) and origin(rightcenter)
  • String parser ↔ builder API parity (DIP pin1, SOIC center)
  • Silkscreen elements are shifted correctly

All 393 tests pass (384 existing + 9 new).

The string parser passes origin values with parentheses (e.g., "(pin1)")
when using the fp.string("dip8_origin(pin1)") syntax. However,
applyOrigin() expects bare values like "pin1", "center", etc.

This caused all origin modes to silently fail when used in footprinter
strings - pads were not repositioned even though origin was specified.

The builder API (fp().dip(8).origin("pin1")) was unaffected because
it passes bare strings directly.

Fix: normalize parenthesized values in applyOrigin before matching.

Tests: 9 new tests covering all origin modes through the string parser,
parity with builder API, and silkscreen shift verification.
@claw-explorer claw-explorer requested a review from seveibar as a code owner April 2, 2026 04:22
Comment on lines +1 to +113
import { test, expect } from "bun:test"
import { fp } from "../src/footprinter"

test("origin(pin1) works in string parser for dip8", () => {
const circuit = fp.string("dip8_origin(pin1)").circuitJson()
const pad1 = circuit.find(
(el: any) =>
el.type === "pcb_plated_hole" &&
(el.port_hints?.[0] === "1" || el.port_hints?.[0] === 1),
) as any
expect(pad1).toBeDefined()
expect(pad1.x).toBeCloseTo(0)
expect(pad1.y).toBeCloseTo(0)
})

test("origin(center) works in string parser for soic8", () => {
const circuit = fp.string("soic8_origin(center)").circuitJson()
const pads = circuit.filter((el: any) => el.type === "pcb_smtpad") as any[]
expect(pads.length).toBe(8)
// With center origin, the average position should be near (0, 0)
const avgX = pads.reduce((s: number, p: any) => s + p.x, 0) / pads.length
const avgY = pads.reduce((s: number, p: any) => s + p.y, 0) / pads.length
expect(avgX).toBeCloseTo(0, 1)
expect(avgY).toBeCloseTo(0, 1)
})

test("origin(pin1) works in string parser for res0402", () => {
const circuit = fp.string("res0402_origin(pin1)").circuitJson()
const pad1 = circuit.find(
(el: any) => el.type === "pcb_smtpad" && el.port_hints?.[0] === "1",
) as any
expect(pad1).toBeDefined()
expect(pad1.x).toBeCloseTo(0)
expect(pad1.y).toBeCloseTo(0)
})

test("origin(bottomleft) works in string parser for res0603", () => {
const circuit = fp.string("res0603_origin(bottomleft)").circuitJson()
const pads = circuit.filter((el: any) => el.type === "pcb_smtpad") as any[]
// All pads should have non-negative x and y after bottomleft origin
for (const pad of pads) {
expect(pad.x).toBeGreaterThanOrEqual(-0.01)
expect(pad.y).toBeGreaterThanOrEqual(-0.01)
}
})

test("string parser origin matches builder API origin for pin1", () => {
const fromString = fp.string("dip8_origin(pin1)").circuitJson()
const fromBuilder = fp().dip(8).origin("pin1").circuitJson()

const stringPads = fromString.filter(
(el: any) => el.type === "pcb_plated_hole",
) as any[]
const builderPads = fromBuilder.filter(
(el: any) => el.type === "pcb_plated_hole",
) as any[]

expect(stringPads.length).toBe(builderPads.length)
for (let i = 0; i < stringPads.length; i++) {
expect(stringPads[i].x).toBeCloseTo(builderPads[i].x)
expect(stringPads[i].y).toBeCloseTo(builderPads[i].y)
}
})

test("string parser origin matches builder API origin for center", () => {
const fromString = fp.string("soic8_origin(center)").circuitJson()
const fromBuilder = fp().soic(8).origin("center").circuitJson()

const stringPads = fromString.filter(
(el: any) => el.type === "pcb_smtpad",
) as any[]
const builderPads = fromBuilder.filter(
(el: any) => el.type === "pcb_smtpad",
) as any[]

expect(stringPads.length).toBe(builderPads.length)
for (let i = 0; i < stringPads.length; i++) {
expect(stringPads[i].x).toBeCloseTo(builderPads[i].x)
expect(stringPads[i].y).toBeCloseTo(builderPads[i].y)
}
})

test("origin(topcenter) works in string parser", () => {
const circuit = fp.string("res0805_origin(topcenter)").circuitJson()
const pads = circuit.filter((el: any) => el.type === "pcb_smtpad") as any[]
expect(pads.length).toBe(2)
// Top center means highest pad edge should be at y=0
const maxY = Math.max(...pads.map((p: any) => p.y + p.height / 2))
expect(maxY).toBeCloseTo(0, 1)
})

test("origin(rightcenter) works in string parser", () => {
const circuit = fp.string("soic8_origin(rightcenter)").circuitJson()
const pads = circuit.filter((el: any) => el.type === "pcb_smtpad") as any[]
// Right center means rightmost pad edge should be at x=0
const maxX = Math.max(...pads.map((p: any) => p.x + p.width / 2))
expect(maxX).toBeCloseTo(0, 1)
})

test("origin shifts silkscreen elements in string parser", () => {
const withoutOrigin = fp.string("dip8").circuitJson()
const withOrigin = fp.string("dip8_origin(pin1)").circuitJson()

const silkWithout = withoutOrigin.find(
(el: any) => el.type === "pcb_silkscreen_path",
) as any
const silkWith = withOrigin.find(
(el: any) => el.type === "pcb_silkscreen_path",
) as any

// Silkscreen routes should be shifted
expect(silkWith.route[0].x).not.toBeCloseTo(silkWithout.route[0].x)
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test file violates the rule that a *.test.ts file may have AT MOST one test(...), after that the user should split into multiple, numbered files. The file contains 9 test cases but should be split into multiple numbered files like origin-string-parser1.test.ts, origin-string-parser2.test.ts, etc. Each file should contain only one test case.

Spotted by Graphite (based on custom rule: Custom rule)

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant