Skip to content

Add PCB teardrop controls and saved path width interpolation - #869

Merged
seveibar merged 5 commits into
mainfrom
feat/pcb-path-wire-tapers
Sep 26, 2026
Merged

seveibar merged 5 commits into
mainfrom
feat/pcb-path-wire-tapers

Conversation

@seveibar

@seveibar seveibar commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

Adds trace-level teardrop controls and explicit width interpolation for saved pcbTracePaths.

Trace controls

Optional booleans pcbTeardrops, pcbTeardropStart, and pcbTeardropEnd request automatic teardrops. Explicit endpoint values override the trace-wide setting. Omitted endpoint values inherit it; omitted pcbTeardrops is disabled. No defaults are inserted by parsing and no curve shape is prescribed by these controls.

<trace from=".U1 > .pin1" to=".J1 > .pin1" pcbTeardrops pcbTeardropEnd={false} />

Saved route geometry

Wire points in pcbTracePaths accept width_interpolation_mode: "linear" | "quadratic". Each point retains its existing width; the mode controls interpolation to the next point's width. This is shared by fanout/breakout and autorouting-phase saved paths.

pcbTracePaths={[
  {
    connection: "U1.1",
    route: [
      { route_type: "wire", x: 0, y: 0, width: 0.8, layer: "top", width_interpolation_mode: "quadratic" },
      { route_type: "wire", x: 1, y: 0, width: 0.2, layer: "top" },
      { route_type: "wire", x: 3, y: 0, width: 0.2, layer: "top" },
    ],
  },
]}

Interpolation requires a distinct next wire point on the same layer. To terminate at a via, put the endpoint wire at the via coordinates. Terminal, coincident, direct-to-via, and cross-layer interpolation are rejected. Omitting the mode retains existing constant-width segment behavior, including paths with differing point widths. Distances still accept units and normalize to millimeters. No aliases or migration are required; pcbPath is unchanged.

Core implementation: tscircuit/core#4162 includes saved-route interpolation and automatic trace teardrops in a post-routing render phase.

Validation: 567 tests, TypeScript, and build pass. All four required generation scripts ran. README unchanged.

Comment thread tests/pcb-path-taper.test.ts Outdated
Comment on lines +13 to +71
test("trace paths preserve taper fields and convert widths to millimeters", () => {
const input: TraceProps = {
from: "U1.1",
to: "J1.1",
pcbPath: [taper, "J1.1"],
pcbPaths: [[taper, { x: 1, y: 0 }]],
}
const parsed = traceProps.parse(input)
const expected = { ...taper, startWidth: 0.6, endWidth: 0.2 }
expect(parsed.pcbPath).toEqual([expected, "J1.1"])
expect(parsed.pcbPaths).toEqual([[expected, { x: 1, y: 0 }]])
})

test("both profiles allow narrowing, widening and constant width", () => {
for (const widthInterpolationMode of ["linear", "quadratic"] as const) {
for (const [startWidth, endWidth] of [
[0.6, 0.2],
[0.2, 0.6],
[0.2, 0.2],
]) {
const point = { x: 0, y: 0, startWidth, endWidth, widthInterpolationMode }
expect(pcbPathPoint.parse(point)).toEqual(point)
}
}
})

test("ordinary points and vias keep their existing output without taper defaults", () => {
for (const point of [
{ x: 0, y: 0 },
{ x: 1, y: 0, via: true, toLayer: "bottom" as const },
]) {
expect(pcbPathPoint.parse(point)).toEqual(point)
}
})

test("taper fields must be supplied together and cannot belong to a via", () => {
for (const field of ["startWidth", "endWidth", "widthInterpolationMode"]) {
expect(
pcbPathPoint.safeParse({ ...taper, [field]: undefined }).success,
).toBe(false)
}
expect(
pcbPathPoint.safeParse({ ...taper, via: true, toLayer: "bottom" }).success,
).toBe(false)
})

test("widths must be positive and finite and profiles must be supported", () => {
for (const field of ["startWidth", "endWidth"]) {
for (const value of [0, -1, Infinity, NaN, "-1mm"]) {
expect(pcbPathPoint.safeParse({ ...taper, [field]: value }).success).toBe(
false,
)
}
}
expect(
pcbPathPoint.safeParse({ ...taper, widthInterpolationMode: "unsupported" })
.success,
).toBe(false)
})

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 file contains 5 test(...) calls (lines 13, 26, 39, 48, 59), but the style guide states that a *.test.ts file may have AT MOST one test(...). The remaining tests should be split into multiple numbered files, e.g., pcb-path-taper1.test.ts, pcb-path-taper2.test.ts, pcb-path-taper3.test.ts, pcb-path-taper4.test.ts, and pcb-path-taper5.test.ts, each containing exactly one test(...) call.

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

Fix in Graphite


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

@seveibar seveibar changed the title Add wire taper props to pcbPath points Add pcbTeardrops trace prop for automatic teardrops Sep 26, 2026
@seveibar seveibar changed the title Add pcbTeardrops trace prop for automatic teardrops Add trace-level and endpoint PCB teardrop props Sep 26, 2026
Comment on lines +1 to +47
import { expect, test } from "bun:test"
import { traceProps, type TraceProps } from "lib/components/trace"

test("teardrop booleans preserve explicit endpoint overrides for all connection forms", () => {
for (const connection of [
{ from: "U1.1", to: "J1.1" },
{ path: ["U1.1", "J1.1"] },
]) {
for (const pcbTeardrops of [true, false, undefined]) {
for (const pcbTeardropStart of [true, false, undefined]) {
for (const pcbTeardropEnd of [true, false, undefined]) {
const input: TraceProps = {
...connection,
pcbTeardrops,
pcbTeardropStart,
pcbTeardropEnd,
pcbPath: [
{ x: 0, y: 0 },
{ x: 1, y: 0 },
],
}
const parsed = traceProps.parse(input)
expect(parsed.pcbTeardrops).toBe(pcbTeardrops)
expect(parsed.pcbTeardropStart).toBe(pcbTeardropStart)
expect(parsed.pcbTeardropEnd).toBe(pcbTeardropEnd)
}
}
}
}
})

test("teardrop props reject non-booleans and introduce no defaults", () => {
const connection = { from: "U1.1", to: "J1.1" }
const parsed = traceProps.parse(connection)
for (const prop of [
"pcbTeardrops",
"pcbTeardropStart",
"pcbTeardropEnd",
] as const) {
expect(parsed).not.toHaveProperty(prop)
for (const value of ["yes", 1, null]) {
expect(
traceProps.safeParse({ ...connection, [prop]: value }).success,
).toBe(false)
}
}
})

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 file contains two test(...) calls (one at line 4 and another at line 32), which violates the rule that a *.test.ts file may have AT MOST one test(...). Please split this into two separate numbered files, e.g. trace-teardrops1.test.ts (containing the 'teardrop booleans preserve explicit endpoint overrides for all connection forms' test) and trace-teardrops2.test.ts (containing the 'teardrop props reject non-booleans and introduce no defaults' test).

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

Fix in Graphite


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

@seveibar seveibar changed the title Add trace-level and endpoint PCB teardrop props Add PCB teardrop controls and saved path width interpolation Sep 26, 2026
Comment on lines +18 to +71
test("saved route interpolation retains modes and normalizes point widths", () => {
for (const schema of [breakoutProps, autoroutingPhaseProps]) {
for (const mode of ["linear", "quadratic"] as const) {
const path: FanoutTracePath = {
connection: "U1.1",
route: [
{ ...wire, width: "800um", width_interpolation_mode: mode },
{ ...end, width: "0.2mm", width_interpolation_mode: mode },
{ ...wire, x: 2 },
],
}
const parsed = schema.parse({ pcbTracePaths: [path] })
expect(parsed.pcbTracePaths?.[0]?.route).toEqual([
{ ...wire, width_interpolation_mode: mode },
{ ...end, width_interpolation_mode: mode },
{ ...wire, x: 2 },
])
}
}
})

test("ordinary differing widths remain unchanged and interpolation can end at a via contact", () => {
const route = [wire, end]
expect(fanoutTracePath.parse({ connection: "U1.1", route }).route).toEqual(
route,
)
const input = {
connection: "U1.1",
route: [
{ ...wire, width_interpolation_mode: "quadratic" },
end,
{ route_type: "via", x: 1, y: 0, from_layer: "top", to_layer: "bottom" },
],
} as const
expect(fanoutTracePath.safeParse(input).success).toBe(true)
})

test("interpolation rejects terminal, coincident, via and cross-layer endpoints", () => {
const start = { ...wire, width_interpolation_mode: "quadratic" }
for (const route of [
[wire, { ...end, width_interpolation_mode: "quadratic" }],
[start, { ...end, x: 0 }],
[start, { ...end, layer: "bottom" }],
[
start,
{ route_type: "via", x: 1, y: 0, from_layer: "top", to_layer: "bottom" },
],
[{ ...start, width_interpolation_mode: "unsupported" }, end],
]) {
expect(
fanoutTracePath.safeParse({ connection: "U1.1", route }).success,
).toBe(false)
}
})

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 file contains three test(...) calls (at lines 18, 39, and 55), but the rule states that a *.test.ts file may have AT MOST one test(...). Please split this into multiple numbered files, e.g. fanout-trace-path-interpolation1.test.ts, fanout-trace-path-interpolation2.test.ts, and fanout-trace-path-interpolation3.test.ts.

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

Fix in Graphite


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

@seveibar
seveibar merged commit 4c48e5f into main Sep 26, 2026
5 checks passed
@tscircuitbot

Copy link
Copy Markdown
Contributor

Thank you for your contribution! 🎉

PR Rating: ⭐⭐
Impact: Minor

Track your contributions and see the leaderboard at: tscircuit Contribution Tracker


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.

2 participants