From 2f1b2c61d61eaaeaaf9f5e9dbf31d3ac5ee22267 Mon Sep 17 00:00:00 2001 From: seveibar Date: Tue, 28 Jul 2026 08:29:05 -0700 Subject: [PATCH] feat: add fanout autorouters --- .../primitive-components/Breakout/Breakout.ts | 7 + .../primitive-components/Group/Group.ts | 82 ++- .../Group/GroupRoutingPhasePlan.ts | 1 + .../Group/Group_getRoutingPhasePlans.ts | 1 + lib/utils/autorouting/FanoutAutorouter.ts | 303 ++++++++++ .../autorouting/GenericLocalAutorouter.ts | 6 + lib/utils/autorouting/SimpleRouteJson.ts | 4 +- .../autorouting/getBusesForSimpleRouteJson.ts | 3 +- .../autorouting/getPresetAutoroutingConfig.ts | 12 + .../autorouting/localAutorouterStrategies.ts | 67 ++- .../obstacles/getObstaclesFromCircuitJson.ts | 4 +- lib/utils/obstacles/types.ts | 4 +- package.json | 5 +- ...6-decoupling-caps-autorouting-srj.snap.svg | 561 ++++++++++++++++++ ...reakout-bga36-decoupling-caps-pcb.snap.svg | 1 + tests/breakout/breakout-arduino-uno.test.tsx | 2 +- .../breakout-bga36-decoupling-caps.test.tsx | 111 ++++ .../breakout-point-source-trace-ids.test.tsx | 2 +- ...ut-qfp16-with-header-and-passives.test.tsx | 2 +- .../breakout-rp2040-with-passives.test.tsx | 2 +- ...eakout-sot23-regulator-power-rail.test.tsx | 2 +- ...fanout-soic8-sensor-to-i2c-header.test.tsx | 2 +- .../breakout/fanoutpoint-chip-route.test.tsx | 2 +- .../bus/port-selector.test.tsx | 1 + .../bus/trace-name.test.tsx | 1 + ...autorouter-fanout-autorouting-srj.snap.svg | 435 ++++++++++++++ .../autorouter-fanout-pcb.snap.svg | 1 + ...ngle-layer-fanout-autorouting-srj.snap.svg | 388 ++++++++++++ ...utorouter-single-layer-fanout-pcb.snap.svg | 1 + tests/features/autorouter-fanout.test.tsx | 130 ++++ .../autorouter-single-layer-fanout.test.tsx | 120 ++++ .../getPresetAutoroutingConfig.test.ts | 13 + tsup.config.ts | 1 + 33 files changed, 2241 insertions(+), 36 deletions(-) create mode 100644 lib/utils/autorouting/FanoutAutorouter.ts create mode 100644 tests/breakout/__snapshots__/breakout-bga36-decoupling-caps-autorouting-srj.snap.svg create mode 100644 tests/breakout/__snapshots__/breakout-bga36-decoupling-caps-pcb.snap.svg create mode 100644 tests/breakout/breakout-bga36-decoupling-caps.test.tsx create mode 100644 tests/features/__snapshots__/autorouter-fanout-autorouting-srj.snap.svg create mode 100644 tests/features/__snapshots__/autorouter-fanout-pcb.snap.svg create mode 100644 tests/features/__snapshots__/autorouter-single-layer-fanout-autorouting-srj.snap.svg create mode 100644 tests/features/__snapshots__/autorouter-single-layer-fanout-pcb.snap.svg create mode 100644 tests/features/autorouter-fanout.test.tsx create mode 100644 tests/features/autorouter-single-layer-fanout.test.tsx diff --git a/lib/components/primitive-components/Breakout/Breakout.ts b/lib/components/primitive-components/Breakout/Breakout.ts index 60d99000f..6973e505b 100644 --- a/lib/components/primitive-components/Breakout/Breakout.ts +++ b/lib/components/primitive-components/Breakout/Breakout.ts @@ -9,6 +9,13 @@ import type { z } from "zod" import { createBreakoutPointSolverInput } from "./createBreakoutPointSolverInput" export class Breakout extends Group { + get config() { + return { + ...super.config, + zodProps: breakoutProps, + } + } + constructor(props: z.input) { super({ ...props, diff --git a/lib/components/primitive-components/Group/Group.ts b/lib/components/primitive-components/Group/Group.ts index dce84731b..ad2aacbc9 100644 --- a/lib/components/primitive-components/Group/Group.ts +++ b/lib/components/primitive-components/Group/Group.ts @@ -36,7 +36,7 @@ import { type NormalizedAutorouterConfig, getPresetAutoroutingConfig, } from "lib/utils/autorouting/getPresetAutoroutingConfig" -import { getLocalAutorouterStrategy } from "lib/utils/autorouting/localAutorouterStrategies" +import { getLocalAutoroutingStages } from "lib/utils/autorouting/localAutorouterStrategies" import { shouldSkipAutoroutingBecauseOfPlacementErrors } from "lib/utils/autorouting/should-skip-autorouting-because-of-placement-errors" import { getBoundsOfPcbComponents } from "lib/utils/get-bounds-of-pcb-components" import { getViaBoardLayers } from "lib/utils/getViaSpanLayers" @@ -931,6 +931,22 @@ export class Group = typeof groupProps> }) const routingPhasePlans = this._getRoutingPhasePlans() const hasPhasedAutorouting = Group_hasPhasedAutorouting(routingPhasePlans) + const routingStages = routingPhasePlans.flatMap((routingPhasePlan) => { + const phaseAutorouterConfig: NormalizedAutorouterConfig = + routingPhasePlan.autorouter + ? getPresetAutoroutingConfig( + routingPhasePlan.autorouter, + this.root?.platform, + ) + : autorouterConfig + return getLocalAutoroutingStages( + phaseAutorouterConfig, + this.root?.platform, + ).map((stage) => ({ + ...stage, + routingPhasePlan, + })) + }) const outputTraces: SimplifiedPcbTrace[] = [] const outputJumpers: Array<{ jumper_footprint: string @@ -978,15 +994,27 @@ export class Group = typeof groupProps> }) } - for (const routingPhasePlan of routingPhasePlans) { - const phaseAutorouterConfig: NormalizedAutorouterConfig = - routingPhasePlan.autorouter - ? getPresetAutoroutingConfig( - routingPhasePlan.autorouter, - this.root?.platform, - ) - : autorouterConfig - let simpleRouteJson = baseSimpleRouteJson + let previousStageOutputSimpleRouteJson: SimpleRouteJson | undefined + + for (const { + routingPhasePlan, + autorouterConfig: phaseAutorouterConfig, + strategy: localAutorouterStrategy, + usesPreviousStageOutput, + } of routingStages) { + if (!usesPreviousStageOutput) { + previousStageOutputSimpleRouteJson = undefined + } + if ( + usesPreviousStageOutput && + previousStageOutputSimpleRouteJson === undefined + ) { + throw new Error( + "Autorouting follow-up stage is missing the preceding stage output", + ) + } + let simpleRouteJson = + previousStageOutputSimpleRouteJson ?? baseSimpleRouteJson const isRegionReroutePhase = Boolean( routingPhasePlan.reroute && routingPhasePlan.region, ) @@ -1003,7 +1031,11 @@ export class Group = typeof groupProps> } : null - if (isRegionReroutePhase && rerouteOriginalSrj) { + if ( + !usesPreviousStageOutput && + isRegionReroutePhase && + rerouteOriginalSrj + ) { simpleRouteJson = getRerouteSimpleRouteJson( rerouteOriginalSrj as AutorouterSimpleRouteJson, { @@ -1011,7 +1043,7 @@ export class Group = typeof groupProps> ...routingPhasePlan.region, } as RerouteRectRegion, ) as SimpleRouteJson - } else if (isConnectionReroutePhase) { + } else if (!usesPreviousStageOutput && isConnectionReroutePhase) { simpleRouteJson = Group_filterSimpleRouteJsonForPhase( baseSimpleRouteJson, routingPhasePlan, @@ -1025,7 +1057,7 @@ export class Group = typeof groupProps> baseSimpleRouteJson.layerCount, ), ] - } else if (hasPhasedAutorouting) { + } else if (!usesPreviousStageOutput && hasPhasedAutorouting) { simpleRouteJson = Group_filterSimpleRouteJsonForPhase( baseSimpleRouteJson, routingPhasePlan, @@ -1089,9 +1121,6 @@ export class Group = typeof groupProps> simpleRouteJson, }) - const localAutorouterStrategy = getLocalAutorouterStrategy( - phaseAutorouterConfig.preset, - ) const cacheEngine = phaseAutorouterConfig.algorithmFn || !localAutorouterStrategy.cacheable ? undefined @@ -1139,6 +1168,7 @@ export class Group = typeof groupProps> autorouter = localAutorouterStrategy.create({ simpleRouteJson, commonAutorouterOptions, + busFanoutDirections: routingPhasePlan.busFanoutDirections, }) } @@ -1174,16 +1204,30 @@ export class Group = typeof groupProps> traces = await routingPromise } + const transformedSimpleRouteJson = + autorouter?.getOutputSimpleRouteJson?.() + let stageOutputTraces = traces + if (transformedSimpleRouteJson?.traces) { + stageOutputTraces = transformedSimpleRouteJson.traces + } else if (usesPreviousStageOutput) { + stageOutputTraces = [...(simpleRouteJson.traces ?? []), ...traces] + } const outputSimpleRouteJson = { - ...simpleRouteJson, - traces, + ...(transformedSimpleRouteJson ?? simpleRouteJson), + traces: stageOutputTraces, } + previousStageOutputSimpleRouteJson = transformedSimpleRouteJson + ? outputSimpleRouteJson + : undefined if (!cachedResult && cacheKey) { await cacheLocalAutoroutingPhaseResult({ cacheEngine, cacheKey, - result: outputSimpleRouteJson, + result: { + ...simpleRouteJson, + traces, + }, }) } diff --git a/lib/components/primitive-components/Group/GroupRoutingPhasePlan.ts b/lib/components/primitive-components/Group/GroupRoutingPhasePlan.ts index 56ba4526c..6e9fc1c25 100644 --- a/lib/components/primitive-components/Group/GroupRoutingPhasePlan.ts +++ b/lib/components/primitive-components/Group/GroupRoutingPhasePlan.ts @@ -20,6 +20,7 @@ export interface RoutingPhasePlan { reroute?: boolean region?: AutoroutingPhaseProps["region"] connectionSelectors?: string[] + busFanoutDirections?: AutoroutingPhaseProps["busFanoutDirections"] drcTolerances?: RoutingPhaseDrcTolerances nets: Net[] traces: Trace[] diff --git a/lib/components/primitive-components/Group/Group_getRoutingPhasePlans.ts b/lib/components/primitive-components/Group/Group_getRoutingPhasePlans.ts index 82e487653..f0cb47ae1 100644 --- a/lib/components/primitive-components/Group/Group_getRoutingPhasePlans.ts +++ b/lib/components/primitive-components/Group/Group_getRoutingPhasePlans.ts @@ -269,6 +269,7 @@ export function Group_getRoutingPhasePlans( plan.connectionSelectors = phaseProps ? getConnectionSelectorsFromAutoroutingPhaseProps(phaseProps) : undefined + plan.busFanoutDirections = phaseProps?.busFanoutDirections plan.drcTolerances = phaseProps ? getDrcTolerancesFromAutoroutingPhaseProps(phaseProps) : undefined diff --git a/lib/utils/autorouting/FanoutAutorouter.ts b/lib/utils/autorouting/FanoutAutorouter.ts new file mode 100644 index 000000000..f19343c53 --- /dev/null +++ b/lib/utils/autorouting/FanoutAutorouter.ts @@ -0,0 +1,303 @@ +import { + FanoutSolver, + type FanoutBorderTarget, + type FanoutSolverOptions, +} from "@tscircuit/fanout-solver" +import type { BusFanoutDirection, NinePointAnchor } from "@tscircuit/props" +import { getViaBoardLayers } from "../getViaSpanLayers" +import type { + AutorouterCompleteEvent, + AutorouterErrorEvent, + AutorouterEvent, + AutorouterProgressEvent, + GenericLocalAutorouter, +} from "./GenericLocalAutorouter" +import type { SimpleRouteJson, SimplifiedPcbTrace } from "./SimpleRouteJson" + +export type FanoutAutorouterMode = "single_layer_fanout" | "fanout" + +export interface FanoutAutorouterOptions { + mode: FanoutAutorouterMode + busFanoutDirections?: Readonly> +} + +const getNinePointAnchor = ( + fanoutDirection: BusFanoutDirection, +): NinePointAnchor => + typeof fanoutDirection === "string" + ? fanoutDirection + : fanoutDirection.direction + +const getFanoutBorderTarget = ( + anchor: NinePointAnchor, +): FanoutBorderTarget | undefined => { + switch (anchor) { + case "top_left": + return "top-left" + case "top_center": + return "top" + case "top_right": + return "top-right" + case "center_left": + return "left" + case "center": + return undefined + case "center_right": + return "right" + case "bottom_left": + return "bottom-left" + case "bottom_center": + return "bottom" + case "bottom_right": + return "bottom-right" + } +} + +const createDownstreamSimpleRouteJson = ({ + fanoutSimpleRouteJson, + sourceComponentIds, +}: { + fanoutSimpleRouteJson: SimpleRouteJson + sourceComponentIds: Set +}): SimpleRouteJson => { + const sourceObstaclesByComponentId = new Map< + string, + SimpleRouteJson["obstacles"] + >() + for (const obstacle of fanoutSimpleRouteJson.obstacles) { + if ( + !obstacle.componentId || + !sourceComponentIds.has(obstacle.componentId) + ) { + continue + } + const sourceObstacles = + sourceObstaclesByComponentId.get(obstacle.componentId) ?? [] + sourceObstacles.push(obstacle) + sourceObstaclesByComponentId.set(obstacle.componentId, sourceObstacles) + } + const sourceFootprintKeepouts: SimpleRouteJson["obstacles"] = [] + for (const [componentId, sourceObstacles] of sourceObstaclesByComponentId) { + const minX = Math.min( + ...sourceObstacles.map( + (obstacle) => obstacle.center.x - obstacle.width / 2, + ), + ) + const maxX = Math.max( + ...sourceObstacles.map( + (obstacle) => obstacle.center.x + obstacle.width / 2, + ), + ) + const minY = Math.min( + ...sourceObstacles.map( + (obstacle) => obstacle.center.y - obstacle.height / 2, + ), + ) + const maxY = Math.max( + ...sourceObstacles.map( + (obstacle) => obstacle.center.y + obstacle.height / 2, + ), + ) + sourceFootprintKeepouts.push({ + obstacleId: `fanout-source-keepout:${componentId}`, + type: "rect", + layers: getViaBoardLayers(fanoutSimpleRouteJson.layerCount), + center: { x: (minX + maxX) / 2, y: (minY + maxY) / 2 }, + width: maxX - minX, + height: maxY - minY, + connectedTo: [], + }) + } + return { + ...fanoutSimpleRouteJson, + obstacles: [ + ...fanoutSimpleRouteJson.obstacles.filter( + (obstacle) => + !obstacle.componentId || + !sourceComponentIds.has(obstacle.componentId), + ), + ...sourceFootprintKeepouts, + ], + } +} + +export class FanoutAutorouter implements GenericLocalAutorouter { + isRouting = false + private outputSimpleRouteJson?: SimpleRouteJson + private startTimeoutId?: number + private eventHandlers: { + complete: Array<(event: AutorouterCompleteEvent) => void> + error: Array<(event: AutorouterErrorEvent) => void> + progress: Array<(event: AutorouterProgressEvent) => void> + } = { + complete: [], + error: [], + progress: [], + } + + constructor( + public readonly input: SimpleRouteJson, + private readonly options: FanoutAutorouterOptions, + ) {} + + private getBusExitPreferences(): + | Readonly> + | undefined { + if (!this.options.busFanoutDirections) return undefined + const knownBusIds = new Set(this.input.buses?.map((bus) => bus.busId) ?? []) + const preferences: Record = {} + for (const [busId, fanoutDirection] of Object.entries( + this.options.busFanoutDirections, + )) { + if (!knownBusIds.has(busId)) { + throw new Error( + `Fanout direction references unknown bus "${busId}" in this autorouting phase`, + ) + } + const target = getFanoutBorderTarget(getNinePointAnchor(fanoutDirection)) + if (target) preferences[busId] = target + } + return Object.keys(preferences).length > 0 ? preferences : undefined + } + + private getFanoutSolverOptions(): FanoutSolverOptions { + const commonOptions: FanoutSolverOptions = { + borderDistribution: "even", + compactBusTracks: true, + busExitPreferences: this.getBusExitPreferences(), + } + if (this.options.mode === "single_layer_fanout") { + return { + ...commonOptions, + escapeLayers: ["top"], + singleLayerPushAndShove: true, + } + } + return commonOptions + } + + private solveFanout(): { + downstreamSimpleRouteJson: SimpleRouteJson + fanoutTraces: SimplifiedPcbTrace[] + debugGraphics: AutorouterProgressEvent["debugGraphics"] + } { + const fanoutSolver = new FanoutSolver( + this.input as unknown as ConstructorParameters[0], + this.getFanoutSolverOptions(), + ) + fanoutSolver.solve() + if (fanoutSolver.failed) { + throw new Error(fanoutSolver.error ?? "Fanout routing failed") + } + const output = fanoutSolver.getOutput() + const fanoutSimpleRouteJson = + output.simpleRouteJson as unknown as SimpleRouteJson + const fanoutTraces = output.fanoutTraces as unknown as SimplifiedPcbTrace[] + return { + downstreamSimpleRouteJson: createDownstreamSimpleRouteJson({ + fanoutSimpleRouteJson, + sourceComponentIds: new Set( + fanoutSolver.preparedBuses.map((bus) => bus.componentId), + ), + }), + fanoutTraces, + debugGraphics: fanoutSolver.visualize(), + } + } + + private startFanout(): void { + try { + const { downstreamSimpleRouteJson, fanoutTraces, debugGraphics } = + this.solveFanout() + if (!this.isRouting) return + this.outputSimpleRouteJson = downstreamSimpleRouteJson + + this.emitEvent({ + type: "progress", + steps: 1, + progress: 1, + phase: this.options.mode, + debugGraphics, + }) + this.isRouting = false + this.emitEvent({ + type: "complete", + traces: fanoutTraces, + }) + } catch (caughtError) { + this.isRouting = false + this.emitEvent({ + type: "error", + error: + caughtError instanceof Error + ? caughtError + : new Error(String(caughtError)), + }) + } + } + + start(): void { + if (this.isRouting) return + this.isRouting = true + this.startTimeoutId = setTimeout(() => { + this.startTimeoutId = undefined + this.startFanout() + }, 0) as unknown as number + } + + stop(): void { + if (this.startTimeoutId !== undefined) { + clearTimeout(this.startTimeoutId) + this.startTimeoutId = undefined + } + this.isRouting = false + } + + on( + event: "complete", + callback: (event: AutorouterCompleteEvent) => void, + ): void + on(event: "error", callback: (event: AutorouterErrorEvent) => void): void + on( + event: "progress", + callback: (event: AutorouterProgressEvent) => void, + ): void + on( + event: "complete" | "error" | "progress", + callback: (event: any) => void, + ): void { + if (event === "complete") { + this.eventHandlers.complete.push( + callback as (event: AutorouterCompleteEvent) => void, + ) + } else if (event === "error") { + this.eventHandlers.error.push( + callback as (event: AutorouterErrorEvent) => void, + ) + } else { + this.eventHandlers.progress.push( + callback as (event: AutorouterProgressEvent) => void, + ) + } + } + + private emitEvent(event: AutorouterEvent): void { + if (event.type === "complete") { + for (const handler of this.eventHandlers.complete) handler(event) + } else if (event.type === "error") { + for (const handler of this.eventHandlers.error) handler(event) + } else { + for (const handler of this.eventHandlers.progress) handler(event) + } + } + + solveSync(): SimplifiedPcbTrace[] { + const { downstreamSimpleRouteJson, fanoutTraces } = this.solveFanout() + this.outputSimpleRouteJson = downstreamSimpleRouteJson + return fanoutTraces + } + + getOutputSimpleRouteJson(): SimpleRouteJson | undefined { + return this.outputSimpleRouteJson + } +} diff --git a/lib/utils/autorouting/GenericLocalAutorouter.ts b/lib/utils/autorouting/GenericLocalAutorouter.ts index 82e8ef6df..f31d732d9 100644 --- a/lib/utils/autorouting/GenericLocalAutorouter.ts +++ b/lib/utils/autorouting/GenericLocalAutorouter.ts @@ -38,6 +38,12 @@ export interface GenericLocalAutorouter { solveSync(): SimplifiedPcbTrace[] + /** + * Return a transformed routing problem for a subsequent autorouting phase. + * Autorouters that only produce final traces can omit this method. + */ + getOutputSimpleRouteJson?(): SimpleRouteJson | undefined + /** * Get the mapping of obstacle IDs to root connection names that were * connected via off-board paths (e.g., interconnects). diff --git a/lib/utils/autorouting/SimpleRouteJson.ts b/lib/utils/autorouting/SimpleRouteJson.ts index f4a5f79fd..fb573159e 100644 --- a/lib/utils/autorouting/SimpleRouteJson.ts +++ b/lib/utils/autorouting/SimpleRouteJson.ts @@ -50,7 +50,8 @@ export type Obstacle = { obstacleId?: string componentId?: string // TODO include ovals - type: "rect" // NOTE: most datasets do not contain ovals + type: "rect" + shape?: "circle" layers: string[] zLayers?: number[] center: { x: number; y: number } @@ -100,6 +101,7 @@ export type SimpleRouteDifferentialPair = { /** A group of connections that an autorouter should keep together. */ export type SimpleRouteBus = { + busId: string name?: string connectionNames: SrjConnectionName[] } diff --git a/lib/utils/autorouting/getBusesForSimpleRouteJson.ts b/lib/utils/autorouting/getBusesForSimpleRouteJson.ts index 4a6fdef9d..52ddeb686 100644 --- a/lib/utils/autorouting/getBusesForSimpleRouteJson.ts +++ b/lib/utils/autorouting/getBusesForSimpleRouteJson.ts @@ -2,8 +2,8 @@ import type { SourcePort, SourceTrace } from "circuit-json" import type { Bus } from "lib/components/primitive-components/Bus" import type { Port } from "lib/components/primitive-components/Port/Port" import type { - SimpleRouteBus, SimpleRouteConnection, + SimpleRouteBus, SrjConnectionName, } from "./SimpleRouteJson" @@ -147,6 +147,7 @@ export const getBusesForSimpleRouteJson = ({ } srjBuses.push({ + busId: bus.name, name: bus.name, connectionNames, }) diff --git a/lib/utils/autorouting/getPresetAutoroutingConfig.ts b/lib/utils/autorouting/getPresetAutoroutingConfig.ts index bedc57406..dcca961c5 100644 --- a/lib/utils/autorouting/getPresetAutoroutingConfig.ts +++ b/lib/utils/autorouting/getPresetAutoroutingConfig.ts @@ -74,6 +74,18 @@ export function getPresetAutoroutingConfig( local: true, groupMode: "subcircuit", } + case "single-layer-fanout": + return { + local: true, + groupMode: "subcircuit", + preset: "single_layer_fanout", + } + case "fanout": + return { + local: true, + groupMode: "subcircuit", + preset: "fanout", + } case "auto-cloud": { const { preset: _preset, diff --git a/lib/utils/autorouting/localAutorouterStrategies.ts b/lib/utils/autorouting/localAutorouterStrategies.ts index 0b27bc5cf..05bd1046f 100644 --- a/lib/utils/autorouting/localAutorouterStrategies.ts +++ b/lib/utils/autorouting/localAutorouterStrategies.ts @@ -1,30 +1,93 @@ +import type { + AutorouterProp, + AutoroutingPhaseProps, + PlatformConfig, +} from "@tscircuit/props" import { type AutorouterOptions, TscircuitAutorouter, } from "./CapacityMeshAutorouter" +import { FanoutAutorouter, type FanoutAutorouterMode } from "./FanoutAutorouter" import type { GenericLocalAutorouter } from "./GenericLocalAutorouter" import type { SimpleRouteJson } from "./SimpleRouteJson" -import type { NormalizedAutorouterConfig } from "./getPresetAutoroutingConfig" +import { + type NormalizedAutorouterConfig, + getPresetAutoroutingConfig, +} from "./getPresetAutoroutingConfig" export interface LocalAutorouterStrategyContext { simpleRouteJson: SimpleRouteJson commonAutorouterOptions: AutorouterOptions + busFanoutDirections?: AutoroutingPhaseProps["busFanoutDirections"] } export interface LocalAutorouterStrategy { cacheable: boolean + followUpAutorouter?: AutorouterProp create: (context: LocalAutorouterStrategyContext) => GenericLocalAutorouter } +export interface LocalAutoroutingStage { + autorouterConfig: NormalizedAutorouterConfig + strategy: LocalAutorouterStrategy + usesPreviousStageOutput: boolean +} + const defaultLocalAutorouterStrategy: LocalAutorouterStrategy = { cacheable: true, create: ({ simpleRouteJson, commonAutorouterOptions }) => new TscircuitAutorouter(simpleRouteJson, commonAutorouterOptions), } -const localAutorouterStrategies = new Map() +const createFanoutAutorouterStrategy = ( + mode: FanoutAutorouterMode, +): LocalAutorouterStrategy => ({ + cacheable: false, + followUpAutorouter: "default", + create: ({ simpleRouteJson, busFanoutDirections }) => + new FanoutAutorouter(simpleRouteJson, { + mode, + busFanoutDirections, + }), +}) + +const localAutorouterStrategies = new Map([ + [ + "single_layer_fanout", + createFanoutAutorouterStrategy("single_layer_fanout"), + ], + ["fanout", createFanoutAutorouterStrategy("fanout")], +]) export const getLocalAutorouterStrategy = ( preset: NormalizedAutorouterConfig["preset"], ): LocalAutorouterStrategy => localAutorouterStrategies.get(preset ?? "") ?? defaultLocalAutorouterStrategy + +export const getLocalAutoroutingStages = ( + autorouterConfig: NormalizedAutorouterConfig, + platformConfig?: PlatformConfig, +): LocalAutoroutingStage[] => { + const strategy = getLocalAutorouterStrategy(autorouterConfig.preset) + const stages: LocalAutoroutingStage[] = [ + { + autorouterConfig, + strategy, + usesPreviousStageOutput: false, + }, + ] + + if (strategy.followUpAutorouter) { + const followUpAutorouterConfig = getPresetAutoroutingConfig( + strategy.followUpAutorouter, + platformConfig, + ) + stages.push({ + autorouterConfig: followUpAutorouterConfig, + strategy: getLocalAutorouterStrategy(followUpAutorouterConfig.preset), + usesPreviousStageOutput: true, + }) + } + + return stages +} diff --git a/lib/utils/obstacles/getObstaclesFromCircuitJson.ts b/lib/utils/obstacles/getObstaclesFromCircuitJson.ts index 2f5f54995..887141135 100644 --- a/lib/utils/obstacles/getObstaclesFromCircuitJson.ts +++ b/lib/utils/obstacles/getObstaclesFromCircuitJson.ts @@ -63,8 +63,8 @@ export const getObstaclesFromCircuitJson = ( if (element.shape === "circle") { obstacles.push({ componentId: pcbComponentId, - // @ts-ignore - type: "oval", + type: "rect", + shape: "circle", layers: [element.layer], center: { x: element.x, diff --git a/lib/utils/obstacles/types.ts b/lib/utils/obstacles/types.ts index c94290ee1..50bee615c 100644 --- a/lib/utils/obstacles/types.ts +++ b/lib/utils/obstacles/types.ts @@ -1,7 +1,7 @@ export type Obstacle = { componentId?: string - // TODO include ovals - type: "rect" // NOTE: most datasets do not contain ovals + shape?: "circle" + type: "rect" layers: string[] zLayers?: number[] center: { x: number; y: number } diff --git a/package.json b/package.json index 8e2639d6d..2bfe77729 100644 --- a/package.json +++ b/package.json @@ -35,12 +35,13 @@ "@tsci/tscircuit.ti": "github:tscircuit/ti#57e314be4b2b06bc546d4def7453e619604d1157", "@tscircuit/alphabet": "0.0.25", "@tscircuit/breakout-point-solver": "github:tscircuit/breakout-point-solver#bac9629", - "@tscircuit/capacity-autorouter": "^0.0.710", + "@tscircuit/capacity-autorouter": "^0.0.718", "@tscircuit/checks": "0.0.146", "@tscircuit/circuit-json-util": "^0.0.101", "@tscircuit/common": "^0.0.20", "@tscircuit/copper-pour-solver": "0.0.41", "@tscircuit/create-fdm-enclosure": "0.0.3", + "@tscircuit/fanout-solver": "github:tscircuit/fanout-solver#34e4664", "@tscircuit/footprinter": "^0.0.387", "@tscircuit/image-utils": "^0.0.8", "@tscircuit/infer-cable-insertion-point": "^0.0.2", @@ -52,7 +53,7 @@ "@tscircuit/math-utils": "^0.0.36", "@tscircuit/miniflex": "^0.0.4", "@tscircuit/ngspice-spice-engine": "^0.0.20", - "@tscircuit/props": "^0.0.593", + "@tscircuit/props": "^0.0.595", "@tscircuit/schematic-match-adapt": "^0.0.18", "@tscircuit/schematic-trace-solver": "^0.0.111", "@tscircuit/solver-utils": "^0.0.16", diff --git a/tests/breakout/__snapshots__/breakout-bga36-decoupling-caps-autorouting-srj.snap.svg b/tests/breakout/__snapshots__/breakout-bga36-decoupling-caps-autorouting-srj.snap.svg new file mode 100644 index 000000000..ee843e314 --- /dev/null +++ b/tests/breakout/__snapshots__/breakout-bga36-decoupling-caps-autorouting-srj.snap.svg @@ -0,0 +1,561 @@ + + + + + FULL ROUTED CIRCUIT: 0 CONNECTIONS, 16 TRACES + + + + + + + + + + AUTOROUTING PHASE 2 END: 8 CONNECTIONS, 16 TRACES + + + + + + + + + AUTOROUTING PHASE 2 START: 8 CONNECTIONS, 8 TRACES + + + + + + + + + AUTOROUTING PHASE 1 END: 8 CONNECTIONS, 8 TRACES + + + + + + + + + AUTOROUTING PHASE 1 START: 8 CONNECTIONS, 0 TRACES + + + + + + + \ No newline at end of file diff --git a/tests/breakout/__snapshots__/breakout-bga36-decoupling-caps-pcb.snap.svg b/tests/breakout/__snapshots__/breakout-bga36-decoupling-caps-pcb.snap.svg new file mode 100644 index 000000000..54fe7ebce --- /dev/null +++ b/tests/breakout/__snapshots__/breakout-bga36-decoupling-caps-pcb.snap.svg @@ -0,0 +1 @@ +U1C_TOPC_RIGHTC_BOTTOMC_LEFT<breakout> default fanout: bga36_grid6x6_p0.8mm_pad0.35mm_circularpads \ No newline at end of file diff --git a/tests/breakout/breakout-arduino-uno.test.tsx b/tests/breakout/breakout-arduino-uno.test.tsx index 73272d8aa..ac608a42d 100644 --- a/tests/breakout/breakout-arduino-uno.test.tsx +++ b/tests/breakout/breakout-arduino-uno.test.tsx @@ -61,7 +61,7 @@ test.skip("breakout routes Arduino Uno ATmega pins to board headers", async () = pcbRotation={90} /> - + ( + +) + +test("breakout defaults to fanout for a 6x6 BGA surrounded by four 0603 decoupling caps", async () => { + const { circuit } = getTestFixture() + const autoroutingPhaseIoStack = createAutoroutingPhaseIoStack(circuit) + + circuit.add( + + + + + + + + + + + + + + + + + + + + + + + + + default fanout: ${bgaFootprint}`} + /> + , + ) + + await circuit.renderUntilSettled() + + const breakoutSourceGroup = circuit.db.source_group.getWhere({ + name: "BGA_BREAKOUT", + }) + expect(breakoutSourceGroup).toBeDefined() + + const breakoutPhases = autoroutingPhaseIoStack.filter( + (phase) => phase.subcircuit_id === breakoutSourceGroup?.subcircuit_id, + ) + expect(breakoutPhases).toHaveLength(2) + expect(breakoutPhases[0]?.startSimpleRouteJson?.traces ?? []).toHaveLength(0) + expect(breakoutPhases[0]?.endSimpleRouteJson?.traces).toHaveLength(8) + expect(breakoutPhases[1]?.startSimpleRouteJson?.traces).toHaveLength(8) + expect(breakoutPhases[1]?.endSimpleRouteJson?.traces).toHaveLength(16) + expect( + breakoutPhases[0]?.endSimpleRouteJson?.traces + ?.flatMap((trace) => trace.route) + .filter((routePoint) => routePoint.route_type === "via"), + ).toHaveLength(8) + + expect(circuit.db.pcb_breakout_point.list()).toHaveLength(0) + expect(circuit.db.pcb_trace.list()).toHaveLength(16) + expect(circuit.db.pcb_via.list().length).toBeGreaterThanOrEqual(8) + expect(circuit.db.pcb_autorouting_error.list()).toEqual([]) + expect(circuit.db.pcb_trace_error.list()).toEqual([]) + expect(circuit.db.pcb_pad_trace_clearance_error.list()).toEqual([]) + expect(circuit.db.pcb_via_clearance_error.list()).toEqual([]) + + expect(circuit).toMatchPcbSnapshot(import.meta.path) + await expect(autoroutingPhaseIoStack).toMatchAutoroutingPhaseIoStackSnapshot( + import.meta.path, + "breakout-bga36-decoupling-caps-autorouting-srj", + circuit, + ) +}) diff --git a/tests/breakout/breakout-point-source-trace-ids.test.tsx b/tests/breakout/breakout-point-source-trace-ids.test.tsx index b4a4597d0..d1b3c3a9c 100644 --- a/tests/breakout/breakout-point-source-trace-ids.test.tsx +++ b/tests/breakout/breakout-point-source-trace-ids.test.tsx @@ -6,7 +6,7 @@ test("breakout points use cross-boundary source trace ids", async () => { circuit.add( - + - + - + - + - + { circuit.add( - + { expect(simpleRouteJson.buses).toEqual([ { + busId: "DATA", name: "DATA", connectionNames: sourceTraceIds, }, diff --git a/tests/components/primitive-components/bus/trace-name.test.tsx b/tests/components/primitive-components/bus/trace-name.test.tsx index 87ee4c698..43e736cfb 100644 --- a/tests/components/primitive-components/bus/trace-name.test.tsx +++ b/tests/components/primitive-components/bus/trace-name.test.tsx @@ -44,6 +44,7 @@ test("registers a bus routing constraint from trace names", (): void => { expect(simpleRouteJson.buses).toEqual([ { + busId: "DATA", name: "DATA", connectionNames: sourceTraceIds, }, diff --git a/tests/features/__snapshots__/autorouter-fanout-autorouting-srj.snap.svg b/tests/features/__snapshots__/autorouter-fanout-autorouting-srj.snap.svg new file mode 100644 index 000000000..eb905542d --- /dev/null +++ b/tests/features/__snapshots__/autorouter-fanout-autorouting-srj.snap.svg @@ -0,0 +1,435 @@ + + + + + FULL ROUTED CIRCUIT: 0 CONNECTIONS, 8 TRACES + + + + + + + + + + AUTOROUTING PHASE 2 END: 4 CONNECTIONS, 8 TRACES + + + + + + + + + AUTOROUTING PHASE 2 START: 4 CONNECTIONS, 4 TRACES + + + + + + + + + AUTOROUTING PHASE 1 END: 4 CONNECTIONS, 4 TRACES + + + + + + + + + AUTOROUTING PHASE 1 START: 4 CONNECTIONS, 0 TRACES + + + + + + + \ No newline at end of file diff --git a/tests/features/__snapshots__/autorouter-fanout-pcb.snap.svg b/tests/features/__snapshots__/autorouter-fanout-pcb.snap.svg new file mode 100644 index 000000000..9ef91c86a --- /dev/null +++ b/tests/features/__snapshots__/autorouter-fanout-pcb.snap.svg @@ -0,0 +1 @@ +R1R2R3R4autorouter="fanout": inner 4x4 BGA bus \ No newline at end of file diff --git a/tests/features/__snapshots__/autorouter-single-layer-fanout-autorouting-srj.snap.svg b/tests/features/__snapshots__/autorouter-single-layer-fanout-autorouting-srj.snap.svg new file mode 100644 index 000000000..6c6765645 --- /dev/null +++ b/tests/features/__snapshots__/autorouter-single-layer-fanout-autorouting-srj.snap.svg @@ -0,0 +1,388 @@ + + + + + FULL ROUTED CIRCUIT: 0 CONNECTIONS, 8 TRACES + + + + + + + + + + AUTOROUTING PHASE 2 END: 4 CONNECTIONS, 8 TRACES + + + + + + + + + AUTOROUTING PHASE 2 START: 4 CONNECTIONS, 4 TRACES + + + + + + + + + AUTOROUTING PHASE 1 END: 4 CONNECTIONS, 4 TRACES + + + + + + + + + AUTOROUTING PHASE 1 START: 4 CONNECTIONS, 0 TRACES + + + + + + + \ No newline at end of file diff --git a/tests/features/__snapshots__/autorouter-single-layer-fanout-pcb.snap.svg b/tests/features/__snapshots__/autorouter-single-layer-fanout-pcb.snap.svg new file mode 100644 index 000000000..041dae378 --- /dev/null +++ b/tests/features/__snapshots__/autorouter-single-layer-fanout-pcb.snap.svg @@ -0,0 +1 @@ +R1R2R3R4autorouter="single_layer_fanout": top-only bus \ No newline at end of file diff --git a/tests/features/autorouter-fanout.test.tsx b/tests/features/autorouter-fanout.test.tsx new file mode 100644 index 000000000..fa34b9c27 --- /dev/null +++ b/tests/features/autorouter-fanout.test.tsx @@ -0,0 +1,130 @@ +import { expect, test } from "bun:test" +import { createAutoroutingPhaseIoStack } from "tests/fixtures/create-autorouting-phase-io-stack" +import { getTestFixture } from "tests/fixtures/get-test-fixture" + +const innerPinNumbers = [6, 7, 10, 11] + +const TestPad = ({ + pinNumber, + pcbX, + pcbY, +}: { + pinNumber: number + pcbX: number + pcbY: number +}) => ( + +) + +const TestResistor = ({ + busIndex, + pcbY, +}: { + busIndex: number + pcbY: number +}) => ( + +) + +const TestTrace = ({ + pinNumber, + busIndex, +}: { + pinNumber: number + busIndex: number +}) => ( + .pin${pinNumber}`} + to={`.R${busIndex + 1} > .pin1`} + /> +) + +test('autorouter="fanout" escapes an inner BGA bus before board routing', async () => { + const { circuit } = getTestFixture() + const autoroutingPhaseIoStack = createAutoroutingPhaseIoStack(circuit) + const bgaPads = Array.from({ length: 16 }, (_, padIndex) => { + const pinNumber = padIndex + 1 + return ( + + ) + }) + + circuit.add( + + + {bgaPads}} /> + `D${busIndex}`)} + /> + {innerPinNumbers.map((pinNumber, busIndex) => ( + + ))} + {innerPinNumbers.map((pinNumber, busIndex) => ( + + ))} + + , + ) + + await circuit.renderUntilSettled() + + expect(circuit.db.pcb_autorouting_error.list()).toEqual([]) + expect(circuit.db.pcb_trace_error.list()).toEqual([]) + expect(circuit.db.pcb_pad_trace_clearance_error.list()).toEqual([]) + expect(circuit.db.pcb_via_clearance_error.list()).toEqual([]) + expect(circuit.db.pcb_trace.list().length).toBeGreaterThanOrEqual(8) + expect(circuit.db.pcb_via.list().length).toBeGreaterThan(0) + expect(autoroutingPhaseIoStack).toHaveLength(2) + expect(autoroutingPhaseIoStack[0]?.endSimpleRouteJson?.traces).toHaveLength(4) + expect(autoroutingPhaseIoStack[1]?.startSimpleRouteJson?.traces).toHaveLength( + 4, + ) + expect(autoroutingPhaseIoStack[1]?.endSimpleRouteJson?.traces).toHaveLength(8) + expect(circuit).toMatchPcbSnapshot(import.meta.path) + await expect(autoroutingPhaseIoStack).toMatchAutoroutingPhaseIoStackSnapshot( + import.meta.path, + "autorouter-fanout-autorouting-srj", + circuit, + ) +}) diff --git a/tests/features/autorouter-single-layer-fanout.test.tsx b/tests/features/autorouter-single-layer-fanout.test.tsx new file mode 100644 index 000000000..61f0ff864 --- /dev/null +++ b/tests/features/autorouter-single-layer-fanout.test.tsx @@ -0,0 +1,120 @@ +import { expect, test } from "bun:test" +import { createAutoroutingPhaseIoStack } from "tests/fixtures/create-autorouting-phase-io-stack" +import { getTestFixture } from "tests/fixtures/get-test-fixture" + +const TestPad = ({ + pinNumber, + pcbX, + pcbY, +}: { + pinNumber: number + pcbX: number + pcbY: number +}) => ( + +) + +const TestResistor = ({ busIndex }: { busIndex: number }) => ( + +) + +const TestTrace = ({ busIndex }: { busIndex: number }) => ( + .pin${busIndex + 5}`} + to={`.R${busIndex + 1} > .pin1`} + /> +) + +test('autorouter="single_layer_fanout" keeps a complete bus on top', async () => { + const { circuit } = getTestFixture() + const autoroutingPhaseIoStack = createAutoroutingPhaseIoStack(circuit) + const sourcePads = Array.from({ length: 8 }, (_, padIndex) => { + const pinNumber = padIndex + 1 + return ( + + ) + }) + + circuit.add( + + + {sourcePads}} + /> + + {Array.from({ length: 4 }, (_, busIndex) => ( + + ))} + {Array.from({ length: 4 }, (_, busIndex) => ( + + ))} + + , + ) + + await circuit.renderUntilSettled() + + expect(circuit.db.pcb_autorouting_error.list()).toEqual([]) + expect(circuit.db.pcb_trace_error.list()).toEqual([]) + expect(circuit.db.pcb_pad_trace_clearance_error.list()).toEqual([]) + expect(circuit.db.pcb_via_clearance_error.list()).toEqual([]) + expect(circuit.db.pcb_trace.list().length).toBeGreaterThanOrEqual(8) + expect(circuit.db.pcb_via.list()).toEqual([]) + expect( + circuit.db.pcb_trace + .list() + .flatMap((trace) => trace.route) + .filter((routePoint) => routePoint.route_type === "wire") + .every((routePoint) => routePoint.layer === "top"), + ).toBe(true) + expect(autoroutingPhaseIoStack).toHaveLength(2) + expect(autoroutingPhaseIoStack[0]?.endSimpleRouteJson?.traces).toHaveLength(4) + expect(autoroutingPhaseIoStack[1]?.startSimpleRouteJson?.traces).toHaveLength( + 4, + ) + expect(autoroutingPhaseIoStack[1]?.endSimpleRouteJson?.traces).toHaveLength(8) + expect(circuit).toMatchPcbSnapshot(import.meta.path) + await expect(autoroutingPhaseIoStack).toMatchAutoroutingPhaseIoStackSnapshot( + import.meta.path, + "autorouter-single-layer-fanout-autorouting-srj", + circuit, + ) +}) diff --git a/tests/utils/autorouting/getPresetAutoroutingConfig.test.ts b/tests/utils/autorouting/getPresetAutoroutingConfig.test.ts index 90d13101b..347290061 100644 --- a/tests/utils/autorouting/getPresetAutoroutingConfig.test.ts +++ b/tests/utils/autorouting/getPresetAutoroutingConfig.test.ts @@ -27,4 +27,17 @@ describe("getPresetAutoroutingConfig", () => { expect(alias).toEqual(expected) }) + + test("normalizes fanout presets to local subcircuit routing", () => { + expect(getPresetAutoroutingConfig("single_layer_fanout")).toMatchObject({ + local: true, + groupMode: "subcircuit", + preset: "single_layer_fanout", + }) + expect(getPresetAutoroutingConfig("fanout")).toMatchObject({ + local: true, + groupMode: "subcircuit", + preset: "fanout", + }) + }) }) diff --git a/tsup.config.ts b/tsup.config.ts index 659863ee0..810aa9d9c 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ dts: true, noExternal: [ "@tscircuit/breakout-point-solver", + "@tscircuit/fanout-solver", "@tscircuit/jlcpcb-manufacturing-specs", ], })