From 1d9666607bbfbd05d9ed1f0c9a218607c19947d5 Mon Sep 17 00:00:00 2001 From: seveibar Date: Sat, 26 Sep 2026 11:13:30 -0700 Subject: [PATCH 1/7] Preserve saved PCB path width interpolation through routing --- .../Breakout/get-saved-fanout-traces.ts | 29 +++-- .../get-saved-autorouting-phase-traces.ts | 62 ++++++---- .../resolve-saved-trace-route-widths.ts | 54 +++++++++ lib/utils/reverse-pcb-trace-route.ts | 112 +++++++++++------- package.json | 2 +- ...ved-trace-width-interpolation-pcb.snap.svg | 1 + .../utils/reverse-tapered-trace-route.test.ts | 49 ++++++++ tests/utils/saved-trace-route-widths.test.ts | 41 +++++++ .../saved-trace-via-contact-widths.test.ts | 28 +++++ .../saved-trace-width-interpolation.test.tsx | 61 ++++++++++ 10 files changed, 357 insertions(+), 82 deletions(-) create mode 100644 lib/utils/autorouting/resolve-saved-trace-route-widths.ts create mode 100644 tests/utils/__snapshots__/saved-trace-width-interpolation-pcb.snap.svg create mode 100644 tests/utils/reverse-tapered-trace-route.test.ts create mode 100644 tests/utils/saved-trace-route-widths.test.ts create mode 100644 tests/utils/saved-trace-via-contact-widths.test.ts create mode 100644 tests/utils/saved-trace-width-interpolation.test.tsx diff --git a/lib/components/primitive-components/Breakout/get-saved-fanout-traces.ts b/lib/components/primitive-components/Breakout/get-saved-fanout-traces.ts index 2804e81e46..bcee114d01 100644 --- a/lib/components/primitive-components/Breakout/get-saved-fanout-traces.ts +++ b/lib/components/primitive-components/Breakout/get-saved-fanout-traces.ts @@ -1,3 +1,7 @@ +import { + resolveSavedTraceRouteWidths, + getSavedTraceViaContactWidths, +} from "lib/utils/autorouting/resolve-saved-trace-route-widths" import { getViaBoardLayers } from "lib/utils/getViaSpanLayers" import { applyToPoint, compose, translate } from "transformation-matrix" import type { @@ -57,7 +61,7 @@ export function getSavedFanoutTraces( translate(-localExit.x, -localExit.y), ) const originalExit = applyToPoint(transform, localExit) - const route = path.route.map((point) => { + const route = resolveSavedTraceRouteWidths(path.route).map((point) => { const position = applyToPoint(transform, point) return { ...point, @@ -103,23 +107,30 @@ export function getSavedFanoutTraces( ) // Explicit wire contacts keep Circuit JSON connectivity checks aware of // the pad/exit layer without changing the saved copper or via position. - const width = - route.find((point) => point.route_type === "wire")?.width ?? - input.minTraceWidth + const startContactWidths = getSavedTraceViaContactWidths( + route, + 0, + input.minTraceWidth, + ) + const endContactWidths = getSavedTraceViaContactWidths( + route, + route.length - 1, + input.minTraceWidth, + ) if (first.route_type === "via") { route.splice(1, 0, { route_type: "wire", x: first.x, y: first.y, layer: first.to_layer, - width, + width: startContactWidths.toWidth, }) route.unshift({ route_type: "wire", x: first.x, y: first.y, layer: first.from_layer, - width, + width: startContactWidths.fromWidth, }) } if (last.route_type === "via") { @@ -128,14 +139,14 @@ export function getSavedFanoutTraces( x: last.x, y: last.y, layer: last.from_layer, - width, + width: endContactWidths.fromWidth, }) route.push({ route_type: "wire", x: last.x, y: last.y, layer: last.to_layer, - width, + width: endContactWidths.toWidth, }) } if (last.route_type === "via") { @@ -146,7 +157,7 @@ export function getSavedFanoutTraces( x: last.x, y: last.y, layer: last.to_layer, - width, + width: endContactWidths.toWidth, }) } coveredConnections.add(connection) diff --git a/lib/components/primitive-components/Group/get-saved-autorouting-phase-traces.ts b/lib/components/primitive-components/Group/get-saved-autorouting-phase-traces.ts index 9b8ec4e6c1..7418c37e81 100644 --- a/lib/components/primitive-components/Group/get-saved-autorouting-phase-traces.ts +++ b/lib/components/primitive-components/Group/get-saved-autorouting-phase-traces.ts @@ -1,3 +1,7 @@ +import { + resolveSavedTraceRouteWidths, + getSavedTraceViaContactWidths, +} from "lib/utils/autorouting/resolve-saved-trace-route-widths" import { applyToPoint, compose, translate } from "transformation-matrix" import { getViaBoardLayers } from "lib/utils/getViaSpanLayers" import type { @@ -90,7 +94,7 @@ export function getSavedAutoroutingPhaseTraces( ), transformBeforeLayout, ) - const route = path.route.map((point) => ({ + const route = resolveSavedTraceRouteWidths(path.route).map((point) => ({ ...point, ...applyToPoint(transform, point), })) @@ -152,37 +156,43 @@ export function getSavedAutoroutingPhaseTraces( } // Explicit wire contacts expose via connectivity to Circuit JSON consumers // without moving any saved copper. Interior vias need contacts too. - const width = - route.find((point) => point.route_type === "wire")?.width ?? - input.minTraceWidth - const routeWithViaContacts = route.flatMap((point): typeof route => - point.route_type === "via" - ? [ - { - route_type: "wire", - x: point.x, - y: point.y, - layer: point.from_layer, - width, - }, - point, - { - route_type: "wire", - x: point.x, - y: point.y, - layer: point.to_layer, - width, - }, - ] - : [point], - ) + const routeWithViaContacts = route.flatMap((point, index): typeof route => { + if (point.route_type !== "via") return [point] + const { fromWidth, toWidth } = getSavedTraceViaContactWidths( + route, + index, + input.minTraceWidth, + ) + return [ + { + route_type: "wire", + x: point.x, + y: point.y, + layer: point.from_layer, + width: fromWidth, + }, + point, + { + route_type: "wire", + x: point.x, + y: point.y, + layer: point.to_layer, + width: toWidth, + }, + ] + }) if (last.route_type === "via") { + const { toWidth } = getSavedTraceViaContactWidths( + route, + route.length - 1, + input.minTraceWidth, + ) routeWithViaContacts.push({ route_type: "wire", x: last.x, y: last.y, layer: last.to_layer, - width, + width: toWidth, }) } coveredConnections.add(connection) diff --git a/lib/utils/autorouting/resolve-saved-trace-route-widths.ts b/lib/utils/autorouting/resolve-saved-trace-route-widths.ts new file mode 100644 index 0000000000..0a8be1103d --- /dev/null +++ b/lib/utils/autorouting/resolve-saved-trace-route-widths.ts @@ -0,0 +1,54 @@ +import type { PcbTraceRoutePoint } from "circuit-json" + +/** + * Resolve saved path point widths into outgoing Circuit JSON wire tapers. + * Points are in the caller's PCB frame, mm (+X right, +Y up, +Z above, + * right-handed). Positions and directions are unchanged; only widths are added. + */ +export function resolveSavedTraceRouteWidths( + route: readonly T[], +): T[] { + return route.map((point, index) => { + if (point.route_type !== "wire" || !point.width_interpolation_mode) + return { ...point } + const next = route[index + 1] + if ( + next?.route_type !== "wire" || + next.layer !== point.layer || + (next.x === point.x && next.y === point.y) + ) { + throw new Error( + "Width interpolation requires a distinct next wire point on the same layer", + ) + } + return { ...point, start_width: point.width, end_width: next.width } + }) +} + +/** Match each via contact to its adjacent saved wire width, in mm. */ +export function getSavedTraceViaContactWidths( + route: readonly PcbTraceRoutePoint[], + viaIndex: number, + fallbackWidth: number, +): { fromWidth: number; toWidth: number } { + let before: number | undefined + let after: number | undefined + for (let index = viaIndex - 1; index >= 0; index--) { + const point = route[index] + if (point.route_type === "wire") { + before = point.width + break + } + } + for (let index = viaIndex + 1; index < route.length; index++) { + const point = route[index] + if (point.route_type === "wire") { + after = point.width + break + } + } + return { + fromWidth: before ?? after ?? fallbackWidth, + toWidth: after ?? before ?? fallbackWidth, + } +} diff --git a/lib/utils/reverse-pcb-trace-route.ts b/lib/utils/reverse-pcb-trace-route.ts index 089cf9a17f..7a9eb56d67 100644 --- a/lib/utils/reverse-pcb-trace-route.ts +++ b/lib/utils/reverse-pcb-trace-route.ts @@ -15,6 +15,11 @@ export const reversePcbTraceRoute = ( .slice() .reverse() .map((point) => { + if (point.route_type === "wire") { + const { start_width, end_width, width_interpolation_mode, ...wire } = + point + return wire + } if (point.route_type !== "through_pad") return { ...point } return { @@ -26,64 +31,79 @@ export const reversePcbTraceRoute = ( } }) - if (routeThicknessMode === "interpolated") return reversedRoute - - for ( - let reversedIndex = 0; - reversedIndex < reversedRoute.length - 1; - reversedIndex++ - ) { - const reversedStart = reversedRoute[reversedIndex] - const reversedEnd = reversedRoute[reversedIndex + 1] - if ( - reversedStart?.route_type !== "wire" || - reversedEnd?.route_type !== "wire" || - reversedStart.layer !== reversedEnd.layer + if (routeThicknessMode === "constant") { + for ( + let reversedIndex = 0; + reversedIndex < reversedRoute.length - 1; + reversedIndex++ ) { - continue - } + const reversedStart = reversedRoute[reversedIndex] + const reversedEnd = reversedRoute[reversedIndex + 1] + if ( + reversedStart?.route_type !== "wire" || + reversedEnd?.route_type !== "wire" || + reversedStart.layer !== reversedEnd.layer + ) { + continue + } - const originalSegmentStart = route[route.length - reversedIndex - 2] - if (originalSegmentStart?.route_type === "wire") { - reversedStart.width = originalSegmentStart.width + const originalSegmentStart = route[route.length - reversedIndex - 2] + if (originalSegmentStart?.route_type === "wire") { + reversedStart.width = originalSegmentStart.width + } } - } - for ( - let reversedIndex = 0; - reversedIndex < reversedRoute.length; - reversedIndex++ - ) { - const reversedPoint = reversedRoute[reversedIndex] - if (reversedPoint?.route_type !== "wire") continue - - const nextReversedPoint = reversedRoute[reversedIndex + 1] - if ( - nextReversedPoint?.route_type === "wire" && - nextReversedPoint.layer === reversedPoint.layer + for ( + let reversedIndex = 0; + reversedIndex < reversedRoute.length; + reversedIndex++ ) { - continue - } + const reversedPoint = reversedRoute[reversedIndex] + if (reversedPoint?.route_type !== "wire") continue - let originalRunEndIndex = route.length - reversedIndex - 1 - while (originalRunEndIndex < route.length - 1) { - const originalRunPoint = route[originalRunEndIndex] - const nextOriginalRunPoint = route[originalRunEndIndex + 1] + const nextReversedPoint = reversedRoute[reversedIndex + 1] if ( - originalRunPoint?.route_type !== "wire" || - nextOriginalRunPoint?.route_type !== "wire" || - originalRunPoint.layer !== nextOriginalRunPoint.layer + nextReversedPoint?.route_type === "wire" && + nextReversedPoint.layer === reversedPoint.layer ) { - break + continue + } + + let originalRunEndIndex = route.length - reversedIndex - 1 + while (originalRunEndIndex < route.length - 1) { + const originalRunPoint = route[originalRunEndIndex] + const nextOriginalRunPoint = route[originalRunEndIndex + 1] + if ( + originalRunPoint?.route_type !== "wire" || + nextOriginalRunPoint?.route_type !== "wire" || + originalRunPoint.layer !== nextOriginalRunPoint.layer + ) { + break + } + originalRunEndIndex++ } - originalRunEndIndex++ - } - const originalRunEnd = route[originalRunEndIndex] - if (originalRunEnd?.route_type === "wire") { - reversedPoint.width = originalRunEnd.width + const originalRunEnd = route[originalRunEndIndex] + if (originalRunEnd?.route_type === "wire") { + reversedPoint.width = originalRunEnd.width + } } } + // Taper metadata belongs to the outgoing segment, not its original point. + for (let index = 0; index < route.length - 1; index++) { + const start = route[index] + const end = route[index + 1] + if (start.route_type !== "wire" || !start.width_interpolation_mode) continue + if (end.route_type !== "wire" || end.layer !== start.layer) continue + const reversedStart = reversedRoute[route.length - index - 2] + if (reversedStart.route_type !== "wire") continue + Object.assign(reversedStart, { + width: start.end_width, + start_width: start.end_width, + end_width: start.start_width, + width_interpolation_mode: start.width_interpolation_mode, + }) + } return reversedRoute } diff --git a/package.json b/package.json index 97dce121c9..62744bebc2 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "circuit-json-to-connectivity-map": "^0.0.30", "circuit-json-to-gltf": "^0.0.133", "circuit-json-to-spice": "^0.0.45", - "circuit-to-svg": "^0.0.430", + "circuit-to-svg": "^0.0.433", "concurrently": "^9.1.2", "connectivity-map": "^1.0.0", "debug": "^4.3.6", diff --git a/tests/utils/__snapshots__/saved-trace-width-interpolation-pcb.snap.svg b/tests/utils/__snapshots__/saved-trace-width-interpolation-pcb.snap.svg new file mode 100644 index 0000000000..76d4d8ea72 --- /dev/null +++ b/tests/utils/__snapshots__/saved-trace-width-interpolation-pcb.snap.svg @@ -0,0 +1 @@ +Quadratic / constant / linearReversed: same copper \ No newline at end of file diff --git a/tests/utils/reverse-tapered-trace-route.test.ts b/tests/utils/reverse-tapered-trace-route.test.ts new file mode 100644 index 0000000000..0f6012b02c --- /dev/null +++ b/tests/utils/reverse-tapered-trace-route.test.ts @@ -0,0 +1,49 @@ +import { expect, test } from "bun:test" +import type { PcbTraceRoutePoint } from "circuit-json" +import { reversePcbTraceRoute } from "lib/utils/reverse-pcb-trace-route" +import { resolveSavedTraceRouteWidths } from "lib/utils/autorouting/resolve-saved-trace-route-widths" + +test("reversing mixed saved wire segments preserves taper ownership and widths", () => { + const route: PcbTraceRoutePoint[] = [ + { + 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", + width_interpolation_mode: "linear", + }, + { route_type: "wire", x: 4, y: 0, width: 0.6, layer: "top" }, + ] + const resolved = resolveSavedTraceRouteWidths(route) + const original = structuredClone(resolved) + for (const mode of ["constant", "interpolated"] as const) { + const reversed = reversePcbTraceRoute(resolved, mode) + expect(reversed[0]).toMatchObject({ + x: 4, + width: 0.6, + start_width: 0.6, + end_width: 0.2, + width_interpolation_mode: "linear", + }) + expect(reversed[1]).not.toHaveProperty("width_interpolation_mode") + expect(reversed[2]).toMatchObject({ + x: 1, + width: 0.2, + start_width: 0.2, + end_width: 0.8, + width_interpolation_mode: "quadratic", + }) + expect(reversed[3]).not.toHaveProperty("width_interpolation_mode") + expect(resolved).toEqual(original) + } +}) diff --git a/tests/utils/saved-trace-route-widths.test.ts b/tests/utils/saved-trace-route-widths.test.ts new file mode 100644 index 0000000000..bcaaa3446f --- /dev/null +++ b/tests/utils/saved-trace-route-widths.test.ts @@ -0,0 +1,41 @@ +import { expect, test } from "bun:test" +import type { PcbTraceRoutePoint } from "circuit-json" +import { resolveSavedTraceRouteWidths } from "lib/utils/autorouting/resolve-saved-trace-route-widths" +import { getCircuitJsonPcbTraceRoute } from "lib/utils/autorouting/get-circuit-json-pcb-trace-route" + +test("saved widths resolve only explicitly interpolated outgoing segments", () => { + const 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.4, + layer: "top", + width_interpolation_mode: "linear", + }, + { route_type: "wire", x: 4, y: 0, width: 0.8, layer: "top" }, + { route_type: "via", x: 4, y: 0, from_layer: "top", to_layer: "bottom" }, + ] as const satisfies readonly PcbTraceRoutePoint[] + const original = structuredClone(route) + const resolved = getCircuitJsonPcbTraceRoute( + resolveSavedTraceRouteWidths(route), + ) + expect(resolved).toEqual([ + { ...route[0], start_width: 0.8, end_width: 0.2 }, + route[1], + { ...route[2], start_width: 0.4, end_width: 0.8 }, + route[3], + route[4], + ]) + expect(route).toEqual(original) + expect(resolved[0]).not.toBe(route[0]) +}) diff --git a/tests/utils/saved-trace-via-contact-widths.test.ts b/tests/utils/saved-trace-via-contact-widths.test.ts new file mode 100644 index 0000000000..73dcb78d70 --- /dev/null +++ b/tests/utils/saved-trace-via-contact-widths.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from "bun:test" +import type { PcbTraceRoutePoint } from "circuit-json" +import { getSavedTraceViaContactWidths } from "lib/utils/autorouting/resolve-saved-trace-route-widths" + +test("via contacts use adjacent widths rather than the first taper width", () => { + const route: PcbTraceRoutePoint[] = [ + { route_type: "wire", x: 0, y: 0, width: 0.8, layer: "top" }, + { route_type: "wire", x: 1, y: 0, width: 0.2, layer: "top" }, + { route_type: "via", x: 1, y: 0, from_layer: "top", to_layer: "bottom" }, + { route_type: "wire", x: 2, y: 0, width: 0.3, layer: "bottom" }, + ] + expect(getSavedTraceViaContactWidths(route, 2, 0.1)).toEqual({ + fromWidth: 0.2, + toWidth: 0.3, + }) + expect(getSavedTraceViaContactWidths(route.slice(2), 0, 0.1)).toEqual({ + fromWidth: 0.3, + toWidth: 0.3, + }) + expect(getSavedTraceViaContactWidths(route.slice(0, 3), 2, 0.1)).toEqual({ + fromWidth: 0.2, + toWidth: 0.2, + }) + expect(getSavedTraceViaContactWidths([route[2]], 0, 0.1)).toEqual({ + fromWidth: 0.1, + toWidth: 0.1, + }) +}) diff --git a/tests/utils/saved-trace-width-interpolation.test.tsx b/tests/utils/saved-trace-width-interpolation.test.tsx new file mode 100644 index 0000000000..a9f28b4018 --- /dev/null +++ b/tests/utils/saved-trace-width-interpolation.test.tsx @@ -0,0 +1,61 @@ +import { expect, test } from "bun:test" +import type { PcbTraceRoutePoint } from "circuit-json" +import { resolveSavedTraceRouteWidths } from "lib/utils/autorouting/resolve-saved-trace-route-widths" +import { reversePcbTraceRoute } from "lib/utils/reverse-pcb-trace-route" +import { getTestFixture } from "tests/fixtures/get-test-fixture" + +test("saved point widths render matching tapers in either route direction", async () => { + const { circuit } = getTestFixture() + const route = resolveSavedTraceRouteWidths([ + { + route_type: "wire", + x: -3, + y: 0, + width: 1, + layer: "top", + width_interpolation_mode: "quadratic", + }, + { route_type: "wire", x: -1, y: 0, width: 0.2, layer: "top" }, + { + route_type: "wire", + x: 1, + y: 0, + width: 0.2, + layer: "top", + width_interpolation_mode: "linear", + }, + { route_type: "wire", x: 3, y: 0, width: 0.8, layer: "top" }, + ] satisfies PcbTraceRoutePoint[]) + const reversed = reversePcbTraceRoute(route) + .filter((point) => point.route_type === "wire") + .map((point) => ({ ...point, y: -2 })) + circuit.add( + + + + + + , + ) + await circuit.renderUntilSettled() + expect( + circuit.db.pcb_trace + .list() + .flatMap((trace) => trace.route) + .filter( + (point) => + point.route_type === "wire" && point.width_interpolation_mode, + ), + ).toHaveLength(4) + await expect(circuit).toMatchPcbSnapshot(import.meta.path) +}) From aaa43f3a8bf30345ff0891fd457051e48bd703d6 Mon Sep 17 00:00:00 2001 From: seveibar Date: Sat, 26 Sep 2026 11:43:06 -0700 Subject: [PATCH 2/7] Generate automatic trace teardrops in a post-routing render phase --- lib/components/base-components/Renderable.ts | 3 + lib/components/normal-components/Board.ts | 4 +- ...CopperPour_doInitialPcbCopperPourRender.ts | 3 +- ...StandaloneSubcircuitPcbDesignRuleChecks.ts | 3 +- .../primitive-components/Trace/Trace.ts | 9 + .../Trace_doInitialPcbTraceTeardropRender.ts | 209 ++++++++++++++++++ lib/utils/autorouting/add-trace-teardrops.ts | 89 ++++++++ lib/utils/get-taper-clearance-envelope.ts | 44 ++++ .../trace-teardrop-phase-pcb.snap.svg | 1 + .../trace-teardrop-via-phase-pcb.snap.svg | 1 + .../trace-teardrop-phase.test.tsx | 93 ++++++++ .../trace-teardrop-via-phase.test.tsx | 56 +++++ tests/utils/automatic-trace-teardrops.test.ts | 42 ++++ tests/utils/taper-clearance-envelope.test.ts | 40 ++++ 14 files changed, 594 insertions(+), 3 deletions(-) create mode 100644 lib/components/primitive-components/Trace/Trace_doInitialPcbTraceTeardropRender.ts create mode 100644 lib/utils/autorouting/add-trace-teardrops.ts create mode 100644 lib/utils/get-taper-clearance-envelope.ts create mode 100644 tests/components/primitive-components/__snapshots__/trace-teardrop-phase-pcb.snap.svg create mode 100644 tests/components/primitive-components/__snapshots__/trace-teardrop-via-phase-pcb.snap.svg create mode 100644 tests/components/primitive-components/trace-teardrop-phase.test.tsx create mode 100644 tests/components/primitive-components/trace-teardrop-via-phase.test.tsx create mode 100644 tests/utils/automatic-trace-teardrops.test.ts create mode 100644 tests/utils/taper-clearance-envelope.test.ts diff --git a/lib/components/base-components/Renderable.ts b/lib/components/base-components/Renderable.ts index b6027fb505..4d7eac761d 100644 --- a/lib/components/base-components/Renderable.ts +++ b/lib/components/base-components/Renderable.ts @@ -69,6 +69,7 @@ export const orderedRenderPhases = [ "PcbPlacementDesignRuleChecks", "PcbTraceRender", "PcbRouteNetIslands", + "PcbTraceTeardropRender", "PcbCopperPourRender", "PcbViaStitchRender", "PcbCopperPourCleanup", @@ -125,12 +126,14 @@ const asyncPhaseDependencies: Partial> = { PcbBoardAutoSize: ["PcbFootprintStringRender", "FetchPartFootprint"], PcbTraceHintRender: ["PcbFootprintStringRender", "FetchPartFootprint"], PcbManualTraceRender: ["PcbFootprintStringRender", "FetchPartFootprint"], + PcbTraceTeardropRender: ["PcbTraceRender", "PcbRouteNetIslands"], PcbCopperPourRender: [ "PcbFootprintStringRender", "FetchPartFootprint", "PcbPlacementDesignRuleChecks", "PcbTraceRender", "PcbRouteNetIslands", + "PcbTraceTeardropRender", ], PcbViaStitchRender: [ "PcbFootprintStringRender", diff --git a/lib/components/normal-components/Board.ts b/lib/components/normal-components/Board.ts index 021a73f8d1..e7bffacb3e 100644 --- a/lib/components/normal-components/Board.ts +++ b/lib/components/normal-components/Board.ts @@ -1,3 +1,4 @@ +import { getTaperClearanceEnvelope } from "lib/utils/get-taper-clearance-envelope" import { createPcbFold, type PcbFold } from "@tscircuit/flex-utils" import { dedupePcbDrcErrors, @@ -731,7 +732,8 @@ export class Board // Only run once after all configured checks are complete. if (this._drcChecksComplete || this._drcChecksInProgress) return - const runDrcChecks = async (circuitJson: AnyCircuitElement[]) => { + const runDrcChecks = async (inputCircuitJson: AnyCircuitElement[]) => { + const circuitJson = getTaperClearanceEnvelope(inputCircuitJson) const checksToRun: Promise[] = [] if ( diff --git a/lib/components/primitive-components/CopperPour/CopperPour_doInitialPcbCopperPourRender.ts b/lib/components/primitive-components/CopperPour/CopperPour_doInitialPcbCopperPourRender.ts index ed656a2686..f18fd66132 100644 --- a/lib/components/primitive-components/CopperPour/CopperPour_doInitialPcbCopperPourRender.ts +++ b/lib/components/primitive-components/CopperPour/CopperPour_doInitialPcbCopperPourRender.ts @@ -1,3 +1,4 @@ +import { getTaperClearanceEnvelope } from "lib/utils/get-taper-clearance-envelope" import { CopperPourPipelineSolver, convertCircuitJsonToInputProblem, @@ -40,7 +41,7 @@ const renderAllCopperPoursForSubcircuit = async (subcircuit: ISubcircuit) => { if (resolvedCopperPours.length === 0) return - const circuitJson = db.toArray() + const circuitJson = getTaperClearanceEnvelope(db.toArray()) const boardComponent = subcircuit._getBoard() const pcbBoard = boardComponent?.pcb_board_id ? db.pcb_board.get(boardComponent.pcb_board_id) diff --git a/lib/components/primitive-components/Group/Group_doInitialStandaloneSubcircuitPcbDesignRuleChecks.ts b/lib/components/primitive-components/Group/Group_doInitialStandaloneSubcircuitPcbDesignRuleChecks.ts index 542041719b..70224fc1c0 100644 --- a/lib/components/primitive-components/Group/Group_doInitialStandaloneSubcircuitPcbDesignRuleChecks.ts +++ b/lib/components/primitive-components/Group/Group_doInitialStandaloneSubcircuitPcbDesignRuleChecks.ts @@ -1,3 +1,4 @@ +import { getTaperClearanceEnvelope } from "lib/utils/get-taper-clearance-envelope" import { dedupePcbDrcErrors, runAllRoutingChecks } from "@tscircuit/checks" import type { AnyCircuitElement } from "circuit-json" import type { Group } from "./Group" @@ -49,7 +50,7 @@ export const Group_doInitialStandaloneSubcircuitPcbDesignRuleChecks = ( async () => { try { const results = (await runAllRoutingChecks( - subcircuitCircuitJson, + getTaperClearanceEnvelope(subcircuitCircuitJson), )) as AnyCircuitElement[] db.insertAll(dedupePcbDrcErrors(results)) group._standaloneSubcircuitDrcChecksComplete = true diff --git a/lib/components/primitive-components/Trace/Trace.ts b/lib/components/primitive-components/Trace/Trace.ts index 8ed6822942..b22f90afdb 100644 --- a/lib/components/primitive-components/Trace/Trace.ts +++ b/lib/components/primitive-components/Trace/Trace.ts @@ -42,6 +42,7 @@ import type { TraceI } from "./TraceI" import { Trace__doInitialSchematicTraceRenderWithDisplayLabel } from "./Trace__doInitialSchematicTraceRenderWithDisplayLabel" import { Trace__findConnectedPorts } from "./Trace__findConnectedPorts" import { Trace_doInitialPcbManualTraceRender } from "./Trace_doInitialPcbManualTraceRender" +import { Trace_doInitialPcbTraceTeardropRender } from "./Trace_doInitialPcbTraceTeardropRender" import { Trace_doInitialPcbTraceRender } from "./Trace_doInitialPcbTraceRender" import { Trace_doInitialSchematicTraceRender } from "./Trace_doInitialSchematicTraceRender" import { getMaxLengthFromConnectedComponents } from "./trace-utils/get-max-length-from-connected-components" @@ -368,6 +369,14 @@ export class Trace Trace_doInitialPcbManualTraceRender(this) } + doInitialPcbTraceTeardropRender(): void { + Trace_doInitialPcbTraceTeardropRender(this) + } + + updatePcbTraceTeardropRender(): void { + Trace_doInitialPcbTraceTeardropRender(this) + } + doInitialPcbTraceRender(): void { Trace_doInitialPcbTraceRender(this) } diff --git a/lib/components/primitive-components/Trace/Trace_doInitialPcbTraceTeardropRender.ts b/lib/components/primitive-components/Trace/Trace_doInitialPcbTraceTeardropRender.ts new file mode 100644 index 0000000000..99ff78f6f5 --- /dev/null +++ b/lib/components/primitive-components/Trace/Trace_doInitialPcbTraceTeardropRender.ts @@ -0,0 +1,209 @@ +import type { PcbTrace, PcbSmtPad } from "circuit-json" +import { + addTraceTeardrops, + type TraceTeardropContact, +} from "lib/utils/autorouting/add-trace-teardrops" +import type { Trace } from "./Trace" +import { getSavedTraceViaContactWidths } from "lib/utils/autorouting/resolve-saved-trace-route-widths" + +// Pending release of @tscircuit/props PR #869. Read parsed input only. +interface TeardropOptions { + pcbTeardrops?: boolean + pcbTeardropStart?: boolean + pcbTeardropEnd?: boolean +} + +type TeardropRenderContext = Pick< + Trace, + | "root" + | "source_trace_id" + | "_findConnectedPorts" + | "_getTracePortOrNetSelectorListFromProps" +> & { + _parsedProps: Trace["_parsedProps"] & TeardropOptions +} + +type PcbTraceId = PcbTrace["pcb_trace_id"] +const generatedRoutes = new WeakMap< + TeardropRenderContext, + Map< + PcbTraceId, + { + original: PcbTrace["route"] + generated: PcbTrace["route"] + } + > +>() + +function hasSameGeneratedGeometry( + current: PcbTrace["route"], + generated: PcbTrace["route"], +): boolean { + if (current === generated) return true + if (current.length !== generated.length) return false + // Copper-pour annotation can clone points after this phase without changing + // copper. Ignore only those annotations when recognizing our prior output. + const geometry = (point: PcbTrace["route"][number]) => { + const { is_inside_copper_pour, copper_pour_id, ...rest } = + point as typeof point & { + is_inside_copper_pour?: boolean + copper_pour_id?: string + } + return JSON.stringify(rest) + } + return current.every( + (point, index) => geometry(point) === geometry(generated[index]), + ) +} + +function getPadDiameter(pad: PcbSmtPad): number { + if (pad.shape === "polygon") return 0 + if (pad.shape === "circle") return pad.radius * 2 + return Math.min(pad.width, pad.height) +} + +export function Trace_doInitialPcbTraceTeardropRender( + trace: TeardropRenderContext, +): void { + if (!trace.root || trace.root.pcbDisabled || !trace.source_trace_id) return + const { db } = trace.root + const props = trace._parsedProps + const previous = generatedRoutes.get(trace) + // Restore our own output before reapplying changed options. Newly routed + // geometry replaces the saved baseline, rather than restoring stale copper. + for (const [id, routes] of previous ?? []) { + const current = db.pcb_trace.get(id) + if (current && hasSameGeneratedGeometry(current.route, routes.generated)) + db.pcb_trace.update(id, { route: routes.original }) + } + generatedRoutes.delete(trace) + if (!props.pcbTeardrops && !props.pcbTeardropStart && !props.pcbTeardropEnd) + return + const connected = trace._findConnectedPorts() + if (!connected.allPortsFound) return + const selectors = trace._getTracePortOrNetSelectorListFromProps() + const enabledPorts = connected.portsWithSelectors.map( + ({ port, selector }) => ({ + port, + enabled: + selector === selectors[0] + ? (props.pcbTeardropStart ?? props.pcbTeardrops ?? false) + : selector === selectors.at(-1) + ? (props.pcbTeardropEnd ?? props.pcbTeardrops ?? false) + : (props.pcbTeardrops ?? false), + }), + ) + const padContacts: TraceTeardropContact[] = [] + for (const { port, enabled } of enabledPorts) { + if (!enabled || !port.pcb_port_id) continue + for (const pad of db.pcb_smtpad.list({ pcb_port_id: port.pcb_port_id })) { + if (pad.shape === "polygon") continue + padContacts.push({ + x: pad.x, + y: pad.y, + layer: pad.layer, + diameter: getPadDiameter(pad), + }) + } + for (const hole of db.pcb_plated_hole.list({ + pcb_port_id: port.pcb_port_id, + })) { + if (hole.shape !== "circle") continue + for (const layer of hole.layers) + padContacts.push({ + x: hole.x, + y: hole.y, + layer, + diameter: hole.outer_diameter, + }) + } + } + const outputs = new Map< + PcbTraceId, + { original: PcbTrace["route"]; generated: PcbTrace["route"] } + >() + for (const pcbTrace of db.pcb_trace.list({ + source_trace_id: trace.source_trace_id, + })) { + const contacts = [...padContacts] + for (const via of db.pcb_via.list({ + pcb_trace_id: pcbTrace.pcb_trace_id, + })) { + const endpoint = enabledPorts.find(({ port }) => { + const position = port._getGlobalPcbPositionAfterLayout() + return Math.hypot(position.x - via.x, position.y - via.y) < 1e-6 + }) + if (!(endpoint ? endpoint.enabled : props.pcbTeardrops)) continue + for (const layer of via.layers) + contacts.push({ + x: via.x, + y: via.y, + layer, + diameter: via.outer_diameter, + }) + } + // Via contacts make both incident wire segments explicit without moving + // the via, so the same taper operation handles both copper layers. + const wireContacts = pcbTrace.route.flatMap( + (point, index): PcbTrace["route"] => { + if ( + point.route_type !== "via" || + !contacts.some( + (contact) => + Math.hypot(contact.x - point.x, contact.y - point.y) < 1e-6, + ) + ) + return [point] + const { fromWidth, toWidth } = getSavedTraceViaContactWidths( + pcbTrace.route, + index, + 0, + ) + if (!fromWidth || !toWidth) return [point] + const before = pcbTrace.route[index - 1] + const after = pcbTrace.route[index + 1] + const result: PcbTrace["route"] = [] + if ( + !( + before?.route_type === "wire" && + before.x === point.x && + before.y === point.y && + before.layer === point.from_layer + ) + ) + result.push({ + route_type: "wire", + x: point.x, + y: point.y, + width: fromWidth, + layer: point.from_layer, + }) + result.push(point) + if ( + !( + after?.route_type === "wire" && + after.x === point.x && + after.y === point.y && + after.layer === point.to_layer + ) + ) + result.push({ + route_type: "wire", + x: point.x, + y: point.y, + width: toWidth, + layer: point.to_layer, + }) + return result + }, + ) + const route = addTraceTeardrops(wireContacts, contacts) + if (route.length === pcbTrace.route.length) continue + outputs.set(pcbTrace.pcb_trace_id, { + original: pcbTrace.route, + generated: route, + }) + db.pcb_trace.update(pcbTrace.pcb_trace_id, { route }) + } + generatedRoutes.set(trace, outputs) +} diff --git a/lib/utils/autorouting/add-trace-teardrops.ts b/lib/utils/autorouting/add-trace-teardrops.ts new file mode 100644 index 0000000000..6c9c6444ea --- /dev/null +++ b/lib/utils/autorouting/add-trace-teardrops.ts @@ -0,0 +1,89 @@ +import type { LayerRef, PcbTrace, PcbTraceRoutePointWire } from "circuit-json" + +/** Copper contact in board-world mm: +X right, +Y up, +Z above (right-handed). + * The center is a point; diameter describes an inscribed copper circle. + */ +export interface TraceTeardropContact { + x: number + y: number + layer: LayerRef + diameter: number +} + +/** Post-process completed wire segments without moving their centerline. + * Positions use the same board-world frame as TraceTeardropContact. Existing + * explicit tapers are preserved. New tapers stop before the next bend and + * leave a straight section between two contacts on the same segment. + */ +export function addTraceTeardrops( + route: PcbTrace["route"], + contacts: readonly TraceTeardropContact[], +): PcbTrace["route"] { + const result: PcbTrace["route"] = [] + const contactDiameter = (point: PcbTraceRoutePointWire) => { + let diameter = 0 + for (const contact of contacts) { + if ( + contact.layer === point.layer && + Math.hypot(contact.x - point.x, contact.y - point.y) < 1e-6 + ) + diameter = Math.max(diameter, contact.diameter) + } + return diameter + } + for (let index = 0; index < route.length; index++) { + const start = route[index] + const end = route[index + 1] + if ( + start.route_type !== "wire" || + end?.route_type !== "wire" || + start.layer !== end.layer || + start.width_interpolation_mode + ) { + result.push(start) + continue + } + const length = Math.hypot(end.x - start.x, end.y - start.y) + const startDiameter = contactDiameter(start) + const endDiameter = contactDiameter(end) + const size = (diameter: number) => { + const width = diameter * 0.8 + const taperLength = Math.min(diameter * 1.5, length * 0.45) + return width > start.width && taperLength > diameter / 2 + ? { width, length: taperLength } + : undefined + } + const startTaper = size(startDiameter) + const endTaper = size(endDiameter) + if (!startTaper && !endTaper) { + result.push(start) + continue + } + const atDistance = (distance: number): PcbTraceRoutePointWire => ({ + route_type: "wire", + x: start.x + ((end.x - start.x) * distance) / length, + y: start.y + ((end.y - start.y) * distance) / length, + width: start.width, + layer: start.layer, + }) + if (startTaper) { + result.push({ + ...start, + width: startTaper.width, + start_width: startTaper.width, + end_width: start.width, + width_interpolation_mode: "quadratic", + }) + result.push(atDistance(startTaper.length)) + } else result.push(start) + if (endTaper) { + result.push({ + ...atDistance(length - endTaper.length), + start_width: start.width, + end_width: endTaper.width, + width_interpolation_mode: "quadratic", + }) + } + } + return result +} diff --git a/lib/utils/get-taper-clearance-envelope.ts b/lib/utils/get-taper-clearance-envelope.ts new file mode 100644 index 0000000000..ce69c0e958 --- /dev/null +++ b/lib/utils/get-taper-clearance-envelope.ts @@ -0,0 +1,44 @@ +import type { AnyCircuitElement } from "circuit-json" + +/** Conservative analysis-only copper envelope for consumers that read width + * but not taper fields. Board-world positions (mm, +X right, +Y up, +Z above) + * are unchanged. Widen both endpoints to cover the widest part of each taper; + * never mutate the displayed/exported Circuit JSON. This can overestimate + * clearance violations near a taper until consumers support its exact shape. + */ +export function getTaperClearanceEnvelope( + circuitJson: AnyCircuitElement[], +): AnyCircuitElement[] { + return circuitJson.map((element) => { + if ( + element.type !== "pcb_trace" || + !element.route.some( + (point) => + point.route_type === "wire" && point.width_interpolation_mode, + ) + ) + return element + const route = element.route.map((point) => ({ ...point })) + for (let index = 0; index < element.route.length - 1; index++) { + const point = element.route[index] + if ( + point.route_type !== "wire" || + point.start_width === undefined || + point.end_width === undefined + ) + continue + const width = Math.max(point.start_width, point.end_width) + for (const endpoint of [route[index], route[index + 1]]) { + if (endpoint.route_type !== "wire") continue + endpoint.width = Math.max(endpoint.width, width) + } + } + for (const point of route) { + if (point.route_type !== "wire") continue + delete point.start_width + delete point.end_width + delete point.width_interpolation_mode + } + return { ...element, route } + }) +} diff --git a/tests/components/primitive-components/__snapshots__/trace-teardrop-phase-pcb.snap.svg b/tests/components/primitive-components/__snapshots__/trace-teardrop-phase-pcb.snap.svg new file mode 100644 index 0000000000..8f8a9fecf9 --- /dev/null +++ b/tests/components/primitive-components/__snapshots__/trace-teardrop-phase-pcb.snap.svg @@ -0,0 +1 @@ +FROM only; stored route reversed \ No newline at end of file diff --git a/tests/components/primitive-components/__snapshots__/trace-teardrop-via-phase-pcb.snap.svg b/tests/components/primitive-components/__snapshots__/trace-teardrop-via-phase-pcb.snap.svg new file mode 100644 index 0000000000..15c48aee27 --- /dev/null +++ b/tests/components/primitive-components/__snapshots__/trace-teardrop-via-phase-pcb.snap.svg @@ -0,0 +1 @@ +R2R1Via teardrops on both layers; pads disabled \ No newline at end of file diff --git a/tests/components/primitive-components/trace-teardrop-phase.test.tsx b/tests/components/primitive-components/trace-teardrop-phase.test.tsx new file mode 100644 index 0000000000..9c86784755 --- /dev/null +++ b/tests/components/primitive-components/trace-teardrop-phase.test.tsx @@ -0,0 +1,93 @@ +import { expect, test } from "bun:test" +import { getTestFixture } from "tests/fixtures/get-test-fixture" +import type { Trace } from "lib/components/primitive-components/Trace/Trace" +import { Trace_doInitialPcbTraceTeardropRender } from "lib/components/primitive-components/Trace/Trace_doInitialPcbTraceTeardropRender" +import { reversePcbTraceRoute } from "lib/utils/reverse-pcb-trace-route" + +test("teardrop phase honors logical endpoints, preserves its baseline, and is idempotent", async () => { + const { circuit } = getTestFixture({ platform: { drcChecksDisabled: true } }) + circuit.add( + + + + + } + /> + + + + } + /> + + + , + ) + await circuit.renderUntilSettled() + const trace = circuit.selectOne("trace") as Trace + const pcbTrace = circuit.db.pcb_trace.list()[0] + const original = reversePcbTraceRoute(pcbTrace.route) + circuit.db.pcb_trace.update(pcbTrace.pcb_trace_id, { route: original }) + // Supply the pending parsed prop shape without changing the component input. + const context = { + root: trace.root, + source_trace_id: trace.source_trace_id, + _parsedProps: { + ...trace._parsedProps, + pcbTeardrops: true, + pcbTeardropStart: true, + pcbTeardropEnd: false, + }, + _findConnectedPorts: () => trace._findConnectedPorts(), + _getTracePortOrNetSelectorListFromProps: () => + trace._getTracePortOrNetSelectorListFromProps(), + } + Trace_doInitialPcbTraceTeardropRender(context) + const route = circuit.db.pcb_trace.get(pcbTrace.pcb_trace_id)!.route + const tapers = route.filter( + (p) => p.route_type === "wire" && p.width_interpolation_mode, + ) + expect(tapers).toHaveLength(1) + expect(tapers[0]).toMatchObject({ start_width: 0.2, end_width: 0.8 }) + await expect(circuit).toMatchPcbSnapshot(import.meta.path) + Trace_doInitialPcbTraceTeardropRender(context) + expect(circuit.db.pcb_trace.get(pcbTrace.pcb_trace_id)!.route).toEqual(route) + // Pour marking may clone route points after the phase. + circuit.db.pcb_trace.update(pcbTrace.pcb_trace_id, { + route: circuit.db.pcb_trace + .get(pcbTrace.pcb_trace_id)! + .route.map((point) => ({ + ...point, + is_inside_copper_pour: true, + copper_pour_id: "pour", + })), + }) + context._parsedProps = { + ...context._parsedProps, + pcbTeardrops: false, + pcbTeardropStart: false, + } + Trace_doInitialPcbTraceTeardropRender(context) + expect(circuit.db.pcb_trace.get(pcbTrace.pcb_trace_id)!.route).toEqual( + original, + ) +}) diff --git a/tests/components/primitive-components/trace-teardrop-via-phase.test.tsx b/tests/components/primitive-components/trace-teardrop-via-phase.test.tsx new file mode 100644 index 0000000000..cf2bb13494 --- /dev/null +++ b/tests/components/primitive-components/trace-teardrop-via-phase.test.tsx @@ -0,0 +1,56 @@ +import { expect, test } from "bun:test" +import { getTestFixture } from "tests/fixtures/get-test-fixture" +import type { Trace } from "lib/components/primitive-components/Trace/Trace" +import { Trace_doInitialPcbTraceTeardropRender } from "lib/components/primitive-components/Trace/Trace_doInitialPcbTraceTeardropRender" + +test("post-routing phase adds teardrops on both sides of a via with endpoints disabled", async () => { + const { circuit } = getTestFixture({ platform: { drcChecksDisabled: true } }) + circuit.add( + + + + + + , + ) + await circuit.renderUntilSettled() + const trace = circuit.selectOne("trace") as Trace + Trace_doInitialPcbTraceTeardropRender({ + root: trace.root, + source_trace_id: trace.source_trace_id, + _parsedProps: { + ...trace._parsedProps, + pcbTeardrops: true, + pcbTeardropStart: false, + pcbTeardropEnd: false, + }, + _findConnectedPorts: () => trace._findConnectedPorts(), + _getTracePortOrNetSelectorListFromProps: () => + trace._getTracePortOrNetSelectorListFromProps(), + }) + const tapers = circuit.db.pcb_trace + .list() + .flatMap((t) => t.route) + .filter((p) => p.route_type === "wire" && p.width_interpolation_mode) + expect(tapers).toHaveLength(2) + expect(tapers.map((p) => p.route_type === "wire" && p.layer).sort()).toEqual([ + "bottom", + "top", + ]) + await expect(circuit).toMatchPcbSnapshot(import.meta.path) +}) diff --git a/tests/utils/automatic-trace-teardrops.test.ts b/tests/utils/automatic-trace-teardrops.test.ts new file mode 100644 index 0000000000..14a97711d2 --- /dev/null +++ b/tests/utils/automatic-trace-teardrops.test.ts @@ -0,0 +1,42 @@ +import { expect, test } from "bun:test" +import type { PcbTraceRoutePoint } from "circuit-json" +import { addTraceTeardrops } from "lib/utils/autorouting/add-trace-teardrops" + +test("automatic tapers preserve explicit copper, bends, and input across repeated runs", () => { + const route = [ + { route_type: "wire", x: -3, y: 0, width: 0.2, layer: "top" }, + { route_type: "wire", x: 3, y: 0, width: 0.2, layer: "top" }, + { route_type: "wire", x: 3, y: 1, width: 0.2, layer: "top" }, + ] satisfies PcbTraceRoutePoint[] + const original = structuredClone(route) + const contacts = [ + { x: -3, y: 0, layer: "top" as const, diameter: 1 }, + { x: 3, y: 1, layer: "top" as const, diameter: 0.6 }, + ] + const result = addTraceTeardrops(route, contacts) + expect( + result.filter((p) => p.route_type === "wire" && p.width_interpolation_mode), + ).toHaveLength(2) + expect(result).toContainEqual(route[1]) + expect(addTraceTeardrops(result, contacts)).toEqual(result) + expect(route).toEqual(original) + expect(addTraceTeardrops(route, [])).toEqual(route) + expect( + addTraceTeardrops( + route, + contacts.map((c) => ({ ...c, diameter: 0.1 })), + ), + ).toEqual(route) + const explicit = [ + { + ...route[0], + start_width: 0.2, + end_width: 0.2, + width_interpolation_mode: "linear" as const, + }, + route[1], + ] + expect(addTraceTeardrops(explicit, contacts)).toEqual(explicit) + const short = [route[0], { ...route[0], x: -2.9 }] + expect(addTraceTeardrops(short, contacts)).toEqual(short) +}) diff --git a/tests/utils/taper-clearance-envelope.test.ts b/tests/utils/taper-clearance-envelope.test.ts new file mode 100644 index 0000000000..5901df0302 --- /dev/null +++ b/tests/utils/taper-clearance-envelope.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from "bun:test" +import type { PcbTrace } from "circuit-json" +import { getTaperClearanceEnvelope } from "lib/utils/get-taper-clearance-envelope" + +test("pour and DRC envelopes cover widening and narrowing without changing output copper", () => { + const trace: PcbTrace = { + type: "pcb_trace", + pcb_trace_id: "t", + route: [ + { + route_type: "wire", + x: 0, + y: 0, + layer: "top", + width: 0.2, + start_width: 0.2, + end_width: 0.8, + width_interpolation_mode: "quadratic", + }, + { + route_type: "wire", + x: 1, + y: 0, + layer: "top", + width: 0.8, + start_width: 0.8, + end_width: 0.2, + width_interpolation_mode: "quadratic", + }, + { route_type: "wire", x: 2, y: 0, layer: "top", width: 0.2 }, + ], + } + const original = structuredClone(trace) + const [envelope] = getTaperClearanceEnvelope([trace]) as PcbTrace[] + expect( + envelope.route.map((p) => (p.route_type === "wire" ? p.width : 0)), + ).toEqual([0.8, 0.8, 0.8]) + expect(envelope.route[0]).not.toHaveProperty("width_interpolation_mode") + expect(trace).toEqual(original) +}) From 91b44b7e581888a05a5916b3f48715e0d96b888c Mon Sep 17 00:00:00 2001 From: seveibar Date: Sat, 26 Sep 2026 11:57:46 -0700 Subject: [PATCH 3/7] Use realistic populated circuits for teardrop visual snapshots --- .../trace-teardrop-phase-pcb.snap.svg | 2 +- .../trace-teardrop-via-phase-pcb.snap.svg | 2 +- .../trace-teardrop-phase.test.tsx | 103 +++++++++++++---- .../trace-teardrop-via-phase.test.tsx | 72 ++++++++++-- ...ved-trace-width-interpolation-pcb.snap.svg | 2 +- .../saved-trace-width-interpolation.test.tsx | 104 ++++++++++++------ 6 files changed, 215 insertions(+), 70 deletions(-) diff --git a/tests/components/primitive-components/__snapshots__/trace-teardrop-phase-pcb.snap.svg b/tests/components/primitive-components/__snapshots__/trace-teardrop-phase-pcb.snap.svg index 8f8a9fecf9..4fae93fc8e 100644 --- a/tests/components/primitive-components/__snapshots__/trace-teardrop-phase-pcb.snap.svg +++ b/tests/components/primitive-components/__snapshots__/trace-teardrop-phase-pcb.snap.svg @@ -1 +1 @@ -FROM only; stored route reversed \ No newline at end of file +R1C1pin1pin2J1pin1pin2J2RC input filter: R1 end only (reversed route) \ No newline at end of file diff --git a/tests/components/primitive-components/__snapshots__/trace-teardrop-via-phase-pcb.snap.svg b/tests/components/primitive-components/__snapshots__/trace-teardrop-via-phase-pcb.snap.svg index 15c48aee27..d9f89134b4 100644 --- a/tests/components/primitive-components/__snapshots__/trace-teardrop-via-phase-pcb.snap.svg +++ b/tests/components/primitive-components/__snapshots__/trace-teardrop-via-phase-pcb.snap.svg @@ -1 +1 @@ -R2R1Via teardrops on both layers; pads disabled \ No newline at end of file +R2pin1pin2J2C1R1pin1pin2J1Signal layer change: via only, pads disabled \ No newline at end of file diff --git a/tests/components/primitive-components/trace-teardrop-phase.test.tsx b/tests/components/primitive-components/trace-teardrop-phase.test.tsx index 9c86784755..246d1fd752 100644 --- a/tests/components/primitive-components/trace-teardrop-phase.test.tsx +++ b/tests/components/primitive-components/trace-teardrop-phase.test.tsx @@ -7,42 +7,94 @@ import { reversePcbTraceRoute } from "lib/utils/reverse-pcb-trace-route" test("teardrop phase honors logical endpoints, preserves its baseline, and is idempotent", async () => { const { circuit } = getTestFixture({ platform: { drcChecksDisabled: true } }) circuit.add( - - + - - - } + pcbY={1} /> - - - - } + pcbY={-1} /> + + + + + + , ) await circuit.renderUntilSettled() + // Supporting RC-filter routes in board-world mm (+X right, +Y up). + const waypoints = [ + [], + [{ x: -5.73, y: 1 }], + [ + { x: 2.15, y: -2.5 }, + { x: 6.77, y: -2.5 }, + ], + [ + { x: -6.27, y: 3 }, + { x: 4.5, y: 3 }, + { x: 4.5, y: -0.35 }, + ], + [{ x: 6.12, y: 1.27 }], + ] + for (const [index, pcbTrace] of circuit.db.pcb_trace.list().entries()) { + if (!index) continue + const first = pcbTrace.route[0] + const last = pcbTrace.route.at(-1)! + if (first.route_type !== "wire") throw new Error("Expected wire") + circuit.db.pcb_trace.update(pcbTrace.pcb_trace_id, { + route: [ + first, + ...waypoints[index].map((point) => ({ + ...point, + route_type: "wire" as const, + width: first.width, + layer: first.layer, + })), + last, + ], + }) + } const trace = circuit.selectOne("trace") as Trace const pcbTrace = circuit.db.pcb_trace.list()[0] const original = reversePcbTraceRoute(pcbTrace.route) @@ -67,7 +119,10 @@ test("teardrop phase honors logical endpoints, preserves its baseline, and is id (p) => p.route_type === "wire" && p.width_interpolation_mode, ) expect(tapers).toHaveLength(1) - expect(tapers[0]).toMatchObject({ start_width: 0.2, end_width: 0.8 }) + expect(tapers[0]).toMatchObject({ start_width: 0.2 }) + expect(tapers[0].route_type === "wire" && tapers[0].end_width).toBeCloseTo( + 0.64, + ) await expect(circuit).toMatchPcbSnapshot(import.meta.path) Trace_doInitialPcbTraceTeardropRender(context) expect(circuit.db.pcb_trace.get(pcbTrace.pcb_trace_id)!.route).toEqual(route) diff --git a/tests/components/primitive-components/trace-teardrop-via-phase.test.tsx b/tests/components/primitive-components/trace-teardrop-via-phase.test.tsx index cf2bb13494..94e26b5b3a 100644 --- a/tests/components/primitive-components/trace-teardrop-via-phase.test.tsx +++ b/tests/components/primitive-components/trace-teardrop-via-phase.test.tsx @@ -6,29 +6,87 @@ import { Trace_doInitialPcbTraceTeardropRender } from "lib/components/primitive- test("post-routing phase adds teardrops on both sides of a via with endpoints disabled", async () => { const { circuit } = getTestFixture({ platform: { drcChecksDisabled: true } }) circuit.add( - - + + + + + + + + + , ) await circuit.renderUntilSettled() + // Keep the bottom-layer ground return clear of the signal pads, using + // board-world mm (+X right, +Y up) rather than mirrored footprint offsets. + const ground = circuit.db.pcb_trace.list().at(-1)! + const first = ground.route[0] + if (first.route_type !== "wire") throw new Error("Expected wire") + circuit.db.pcb_trace.update(ground.pcb_trace_id, { + route: [ + first, + ...[ + { x: 2.15, y: -4.5 }, + { x: 6.5, y: -4.5 }, + { x: 8, y: -3 }, + ].map((point) => ({ + ...point, + route_type: "wire" as const, + layer: first.layer, + width: first.width, + })), + ground.route.at(-1)!, + ], + }) const trace = circuit.selectOne("trace") as Trace Trace_doInitialPcbTraceTeardropRender({ root: trace.root, diff --git a/tests/utils/__snapshots__/saved-trace-width-interpolation-pcb.snap.svg b/tests/utils/__snapshots__/saved-trace-width-interpolation-pcb.snap.svg index 76d4d8ea72..9eb6838ff0 100644 --- a/tests/utils/__snapshots__/saved-trace-width-interpolation-pcb.snap.svg +++ b/tests/utils/__snapshots__/saved-trace-width-interpolation-pcb.snap.svg @@ -1 +1 @@ -Quadratic / constant / linearReversed: same copper \ No newline at end of file +pin1pin2J1R1pin1pin2J2R2Header to 0603: quadratic escape / linear entryLower channel stored in reverse: same copper \ No newline at end of file diff --git a/tests/utils/saved-trace-width-interpolation.test.tsx b/tests/utils/saved-trace-width-interpolation.test.tsx index a9f28b4018..e46eb27c31 100644 --- a/tests/utils/saved-trace-width-interpolation.test.tsx +++ b/tests/utils/saved-trace-width-interpolation.test.tsx @@ -1,53 +1,85 @@ import { expect, test } from "bun:test" -import type { PcbTraceRoutePoint } from "circuit-json" import { resolveSavedTraceRouteWidths } from "lib/utils/autorouting/resolve-saved-trace-route-widths" import { reversePcbTraceRoute } from "lib/utils/reverse-pcb-trace-route" import { getTestFixture } from "tests/fixtures/get-test-fixture" test("saved point widths render matching tapers in either route direction", async () => { - const { circuit } = getTestFixture() - const route = resolveSavedTraceRouteWidths([ - { - route_type: "wire", - x: -3, - y: 0, - width: 1, - layer: "top", - width_interpolation_mode: "quadratic", - }, - { route_type: "wire", x: -1, y: 0, width: 0.2, layer: "top" }, - { - route_type: "wire", - x: 1, - y: 0, - width: 0.2, - layer: "top", - width_interpolation_mode: "linear", - }, - { route_type: "wire", x: 3, y: 0, width: 0.8, layer: "top" }, - ] satisfies PcbTraceRoutePoint[]) - const reversed = reversePcbTraceRoute(route) - .filter((point) => point.route_type === "wire") - .map((point) => ({ ...point, y: -2 })) + const { circuit } = getTestFixture({ platform: { drcChecksDisabled: true } }) circuit.add( - - - + + {[2, -2].flatMap((y, index) => [ + , + , + , + ])} , ) await circuit.renderUntilSettled() + for (const [index, trace] of circuit.db.pcb_trace.list().entries()) { + const start = trace.route[0] + const end = trace.route.at(-1)! + if (start.route_type !== "wire" || end.route_type !== "wire") + throw new Error("Expected header-to-resistor wire endpoints") + // Board-world mm, +X right / +Y up. Escape the header, make a 45-degree + // detour, then narrow into a real 0603 pad. The second channel is identical. + const route = resolveSavedTraceRouteWidths([ + { ...start, width: 1, width_interpolation_mode: "quadratic" }, + { + route_type: "wire", + x: start.x + 1.5, + y: start.y, + width: 0.2, + layer: "top", + }, + { + route_type: "wire", + x: start.x + 2.25, + y: start.y - 0.75, + width: 0.2, + layer: "top", + }, + { + route_type: "wire", + x: end.x - 2.25, + y: end.y - 0.75, + width: 0.2, + layer: "top", + }, + { + route_type: "wire", + x: end.x - 1.5, + y: end.y, + width: 0.2, + layer: "top", + width_interpolation_mode: "linear", + }, + { ...end, width: 0.64 }, + ]) + circuit.db.pcb_trace.update(trace.pcb_trace_id, { + route: index === 0 ? route : reversePcbTraceRoute(route), + }) + } expect( circuit.db.pcb_trace .list() From 3ccbc037c8d05d72a78aa361c452b375be12db9a Mon Sep 17 00:00:00 2001 From: seveibar Date: Sat, 26 Sep 2026 12:04:49 -0700 Subject: [PATCH 4/7] Annotate teardrop snapshots with PCB note arrows --- .../trace-teardrop-phase-pcb.snap.svg | 2 +- .../trace-teardrop-via-phase-pcb.snap.svg | 2 +- .../trace-teardrop-phase.test.tsx | 10 ++++- .../trace-teardrop-via-phase.test.tsx | 31 +++++++------ tests/fixtures/teardrop-note-arrow.tsx | 43 +++++++++++++++++++ ...ved-trace-width-interpolation-pcb.snap.svg | 2 +- .../saved-trace-width-interpolation.test.tsx | 25 +++++++++++ 7 files changed, 98 insertions(+), 17 deletions(-) create mode 100644 tests/fixtures/teardrop-note-arrow.tsx diff --git a/tests/components/primitive-components/__snapshots__/trace-teardrop-phase-pcb.snap.svg b/tests/components/primitive-components/__snapshots__/trace-teardrop-phase-pcb.snap.svg index 4fae93fc8e..61fb5a6ec1 100644 --- a/tests/components/primitive-components/__snapshots__/trace-teardrop-phase-pcb.snap.svg +++ b/tests/components/primitive-components/__snapshots__/trace-teardrop-phase-pcb.snap.svg @@ -1 +1 @@ -R1C1pin1pin2J1pin1pin2J2RC input filter: R1 end only (reversed route) \ No newline at end of file +R1C1pin1pin2J1pin1pin2J2R1 teardropRC input filter: R1 end only (reversed route) \ No newline at end of file diff --git a/tests/components/primitive-components/__snapshots__/trace-teardrop-via-phase-pcb.snap.svg b/tests/components/primitive-components/__snapshots__/trace-teardrop-via-phase-pcb.snap.svg index d9f89134b4..24047cd9ec 100644 --- a/tests/components/primitive-components/__snapshots__/trace-teardrop-via-phase-pcb.snap.svg +++ b/tests/components/primitive-components/__snapshots__/trace-teardrop-via-phase-pcb.snap.svg @@ -1 +1 @@ -R2pin1pin2J2C1R1pin1pin2J1Signal layer change: via only, pads disabled \ No newline at end of file +R2pin1pin2J2C1R1pin1pin2J1Top teardropBottom teardropSignal layer change: via only, pads disabled \ No newline at end of file diff --git a/tests/components/primitive-components/trace-teardrop-phase.test.tsx b/tests/components/primitive-components/trace-teardrop-phase.test.tsx index 246d1fd752..2fada488bd 100644 --- a/tests/components/primitive-components/trace-teardrop-phase.test.tsx +++ b/tests/components/primitive-components/trace-teardrop-phase.test.tsx @@ -1,3 +1,4 @@ +import { TeardropNoteArrow } from "tests/fixtures/teardrop-note-arrow" import { expect, test } from "bun:test" import { getTestFixture } from "tests/fixtures/get-test-fixture" import type { Trace } from "lib/components/primitive-components/Trace/Trace" @@ -30,8 +31,8 @@ test("teardrop phase honors logical endpoints, preserves its baseline, and is id to="C1.1" thickness={0.2} pcbPath={[ - { x: 2, y: 0 }, - { x: 4, y: -2 }, + { x: 3, y: 0 }, + { x: 5, y: -2 }, ]} /> @@ -54,6 +55,11 @@ test("teardrop phase honors logical endpoints, preserves its baseline, and is id ]} /> + + - + @@ -58,8 +61,12 @@ test("post-routing phase adds teardrops on both sides of a via with endpoints di { x: -5.85, y: -2 }, ]} /> + + + + @@ -75,8 +82,8 @@ test("post-routing phase adds teardrops on both sides of a via with endpoints di route: [ first, ...[ - { x: 2.15, y: -4.5 }, - { x: 6.5, y: -4.5 }, + { x: 2.15, y: -5.5 }, + { x: 6.5, y: -5.5 }, { x: 8, y: -3 }, ].map((point) => ({ ...point, diff --git a/tests/fixtures/teardrop-note-arrow.tsx b/tests/fixtures/teardrop-note-arrow.tsx new file mode 100644 index 0000000000..99d3203238 --- /dev/null +++ b/tests/fixtures/teardrop-note-arrow.tsx @@ -0,0 +1,43 @@ +/** Snapshot annotation in board-world mm: +X right, +Y up. Both ends are + * positions; the arrow belongs to the PCB note layer and adds no copper. + */ +export function TeardropNoteArrow({ + from, + to, +}: { + from: { x: number; y: number } + to: { x: number; y: number } +}) { + const length = Math.hypot(to.x - from.x, to.y - from.y) + const dx = (to.x - from.x) / length + const dy = (to.y - from.y) / length + const headLength = 0.3 + const headWidth = 0.14 + return ( + <> + + + + ) +} diff --git a/tests/utils/__snapshots__/saved-trace-width-interpolation-pcb.snap.svg b/tests/utils/__snapshots__/saved-trace-width-interpolation-pcb.snap.svg index 9eb6838ff0..30fa593dcf 100644 --- a/tests/utils/__snapshots__/saved-trace-width-interpolation-pcb.snap.svg +++ b/tests/utils/__snapshots__/saved-trace-width-interpolation-pcb.snap.svg @@ -1 +1 @@ -pin1pin2J1R1pin1pin2J2R2Header to 0603: quadratic escape / linear entryLower channel stored in reverse: same copper \ No newline at end of file +pin1pin2J1R1pin1pin2J2R2QuadraticLinearQuadraticLinearHeader to 0603: quadratic escape / linear entryLower channel stored in reverse: same copper \ No newline at end of file diff --git a/tests/utils/saved-trace-width-interpolation.test.tsx b/tests/utils/saved-trace-width-interpolation.test.tsx index e46eb27c31..3abbf9789c 100644 --- a/tests/utils/saved-trace-width-interpolation.test.tsx +++ b/tests/utils/saved-trace-width-interpolation.test.tsx @@ -1,3 +1,4 @@ +import { TeardropNoteArrow } from "tests/fixtures/teardrop-note-arrow" import { expect, test } from "bun:test" import { resolveSavedTraceRouteWidths } from "lib/utils/autorouting/resolve-saved-trace-route-widths" import { reversePcbTraceRoute } from "lib/utils/reverse-pcb-trace-route" @@ -23,6 +24,30 @@ test("saved point widths render matching tapers in either route direction", asyn pcbPath={[]} />, ])} + {[2, -2].map((y) => ( + <> + + + + + + ))} Date: Sat, 26 Sep 2026 12:40:40 -0700 Subject: [PATCH 5/7] Remove core-side taper clearance envelope workaround --- lib/components/normal-components/Board.ts | 4 +- ...CopperPour_doInitialPcbCopperPourRender.ts | 3 +- ...StandaloneSubcircuitPcbDesignRuleChecks.ts | 3 +- lib/utils/get-taper-clearance-envelope.ts | 44 ------------------- tests/utils/taper-clearance-envelope.test.ts | 40 ----------------- 5 files changed, 3 insertions(+), 91 deletions(-) delete mode 100644 lib/utils/get-taper-clearance-envelope.ts delete mode 100644 tests/utils/taper-clearance-envelope.test.ts diff --git a/lib/components/normal-components/Board.ts b/lib/components/normal-components/Board.ts index e7bffacb3e..021a73f8d1 100644 --- a/lib/components/normal-components/Board.ts +++ b/lib/components/normal-components/Board.ts @@ -1,4 +1,3 @@ -import { getTaperClearanceEnvelope } from "lib/utils/get-taper-clearance-envelope" import { createPcbFold, type PcbFold } from "@tscircuit/flex-utils" import { dedupePcbDrcErrors, @@ -732,8 +731,7 @@ export class Board // Only run once after all configured checks are complete. if (this._drcChecksComplete || this._drcChecksInProgress) return - const runDrcChecks = async (inputCircuitJson: AnyCircuitElement[]) => { - const circuitJson = getTaperClearanceEnvelope(inputCircuitJson) + const runDrcChecks = async (circuitJson: AnyCircuitElement[]) => { const checksToRun: Promise[] = [] if ( diff --git a/lib/components/primitive-components/CopperPour/CopperPour_doInitialPcbCopperPourRender.ts b/lib/components/primitive-components/CopperPour/CopperPour_doInitialPcbCopperPourRender.ts index f18fd66132..ed656a2686 100644 --- a/lib/components/primitive-components/CopperPour/CopperPour_doInitialPcbCopperPourRender.ts +++ b/lib/components/primitive-components/CopperPour/CopperPour_doInitialPcbCopperPourRender.ts @@ -1,4 +1,3 @@ -import { getTaperClearanceEnvelope } from "lib/utils/get-taper-clearance-envelope" import { CopperPourPipelineSolver, convertCircuitJsonToInputProblem, @@ -41,7 +40,7 @@ const renderAllCopperPoursForSubcircuit = async (subcircuit: ISubcircuit) => { if (resolvedCopperPours.length === 0) return - const circuitJson = getTaperClearanceEnvelope(db.toArray()) + const circuitJson = db.toArray() const boardComponent = subcircuit._getBoard() const pcbBoard = boardComponent?.pcb_board_id ? db.pcb_board.get(boardComponent.pcb_board_id) diff --git a/lib/components/primitive-components/Group/Group_doInitialStandaloneSubcircuitPcbDesignRuleChecks.ts b/lib/components/primitive-components/Group/Group_doInitialStandaloneSubcircuitPcbDesignRuleChecks.ts index 70224fc1c0..542041719b 100644 --- a/lib/components/primitive-components/Group/Group_doInitialStandaloneSubcircuitPcbDesignRuleChecks.ts +++ b/lib/components/primitive-components/Group/Group_doInitialStandaloneSubcircuitPcbDesignRuleChecks.ts @@ -1,4 +1,3 @@ -import { getTaperClearanceEnvelope } from "lib/utils/get-taper-clearance-envelope" import { dedupePcbDrcErrors, runAllRoutingChecks } from "@tscircuit/checks" import type { AnyCircuitElement } from "circuit-json" import type { Group } from "./Group" @@ -50,7 +49,7 @@ export const Group_doInitialStandaloneSubcircuitPcbDesignRuleChecks = ( async () => { try { const results = (await runAllRoutingChecks( - getTaperClearanceEnvelope(subcircuitCircuitJson), + subcircuitCircuitJson, )) as AnyCircuitElement[] db.insertAll(dedupePcbDrcErrors(results)) group._standaloneSubcircuitDrcChecksComplete = true diff --git a/lib/utils/get-taper-clearance-envelope.ts b/lib/utils/get-taper-clearance-envelope.ts deleted file mode 100644 index ce69c0e958..0000000000 --- a/lib/utils/get-taper-clearance-envelope.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { AnyCircuitElement } from "circuit-json" - -/** Conservative analysis-only copper envelope for consumers that read width - * but not taper fields. Board-world positions (mm, +X right, +Y up, +Z above) - * are unchanged. Widen both endpoints to cover the widest part of each taper; - * never mutate the displayed/exported Circuit JSON. This can overestimate - * clearance violations near a taper until consumers support its exact shape. - */ -export function getTaperClearanceEnvelope( - circuitJson: AnyCircuitElement[], -): AnyCircuitElement[] { - return circuitJson.map((element) => { - if ( - element.type !== "pcb_trace" || - !element.route.some( - (point) => - point.route_type === "wire" && point.width_interpolation_mode, - ) - ) - return element - const route = element.route.map((point) => ({ ...point })) - for (let index = 0; index < element.route.length - 1; index++) { - const point = element.route[index] - if ( - point.route_type !== "wire" || - point.start_width === undefined || - point.end_width === undefined - ) - continue - const width = Math.max(point.start_width, point.end_width) - for (const endpoint of [route[index], route[index + 1]]) { - if (endpoint.route_type !== "wire") continue - endpoint.width = Math.max(endpoint.width, width) - } - } - for (const point of route) { - if (point.route_type !== "wire") continue - delete point.start_width - delete point.end_width - delete point.width_interpolation_mode - } - return { ...element, route } - }) -} diff --git a/tests/utils/taper-clearance-envelope.test.ts b/tests/utils/taper-clearance-envelope.test.ts deleted file mode 100644 index 5901df0302..0000000000 --- a/tests/utils/taper-clearance-envelope.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { expect, test } from "bun:test" -import type { PcbTrace } from "circuit-json" -import { getTaperClearanceEnvelope } from "lib/utils/get-taper-clearance-envelope" - -test("pour and DRC envelopes cover widening and narrowing without changing output copper", () => { - const trace: PcbTrace = { - type: "pcb_trace", - pcb_trace_id: "t", - route: [ - { - route_type: "wire", - x: 0, - y: 0, - layer: "top", - width: 0.2, - start_width: 0.2, - end_width: 0.8, - width_interpolation_mode: "quadratic", - }, - { - route_type: "wire", - x: 1, - y: 0, - layer: "top", - width: 0.8, - start_width: 0.8, - end_width: 0.2, - width_interpolation_mode: "quadratic", - }, - { route_type: "wire", x: 2, y: 0, layer: "top", width: 0.2 }, - ], - } - const original = structuredClone(trace) - const [envelope] = getTaperClearanceEnvelope([trace]) as PcbTrace[] - expect( - envelope.route.map((p) => (p.route_type === "wire" ? p.width : 0)), - ).toEqual([0.8, 0.8, 0.8]) - expect(envelope.route[0]).not.toHaveProperty("width_interpolation_mode") - expect(trace).toEqual(original) -}) From c398f407d1941afab7f2ad4c17da72961835a370 Mon Sep 17 00:00:00 2001 From: seveibar Date: Mon, 28 Sep 2026 10:56:37 -0700 Subject: [PATCH 6/7] Use copper-pour-solver release with native taper support --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 62744bebc2..b5610b7541 100644 --- a/package.json +++ b/package.json @@ -40,7 +40,7 @@ "@tscircuit/checks": "^0.0.221", "@tscircuit/circuit-json-util": "^0.0.116", "@tscircuit/common": "^0.0.20", - "@tscircuit/copper-pour-solver": "^0.0.57", + "@tscircuit/copper-pour-solver": "^0.0.62", "@tscircuit/create-fdm-enclosure": "0.0.4", "@tscircuit/fanout-solver": "0.0.78", "@tscircuit/flex-utils": "https://jscdn.tscircuit.com/@tscircuit/flex-utils/0.0.5.tgz", From 2b5c779d8f3a29be054f9a082e658f114f39b539 Mon Sep 17 00:00:00 2001 From: seveibar Date: Mon, 28 Sep 2026 11:00:13 -0700 Subject: [PATCH 7/7] Rerun CI after copper-pour-solver release becomes available