diff --git a/lib/components/primitive-components/Breakout/create-implicit-breakout-point-solver-input.ts b/lib/components/primitive-components/Breakout/create-implicit-breakout-point-solver-input.ts index 1b1c2daf3..4a4f0a13b 100644 --- a/lib/components/primitive-components/Breakout/create-implicit-breakout-point-solver-input.ts +++ b/lib/components/primitive-components/Breakout/create-implicit-breakout-point-solver-input.ts @@ -41,6 +41,14 @@ export interface ImplicitBreakoutPointSolverContext { PcbGroupId, ReadonlyMap > + singleRegionPreferredEdgeByConnectionId?: ReadonlyMap< + SourceTraceId, + BreakoutEdge + > + singleRegionExternalTargetByConnectionId?: ReadonlyMap< + SourceTraceId, + { x: number; y: number } + > } const getRoutingScopeOrThrow = (breakout: Breakout): Group => { @@ -264,26 +272,38 @@ const getFacingEdge = ({ return "bottom" } -const getSingleRegionPreferredEdge = ({ +const getSingleRegionEdgePreferences = ({ region, sourceTraces, }: { region: BreakoutRegionGeometry sourceTraces: readonly SourceTrace[] -}): BreakoutEdge | undefined => { +}): + | { + preferredEdge: BreakoutEdge + preferredEdgeByConnectionId: ReadonlyMap + externalTargetByConnectionId: ReadonlyMap< + SourceTraceId, + { x: number; y: number } + > + } + | undefined => { const root = region.breakout.root if (!root) return undefined const internalSourcePortIds = new Set( region.sourcePortIdBySourceTraceId.values(), ) - const externalPcbPorts = new Map() + const externalTargetsByConnectionId = new Map< + SourceTraceId, + Array<{ x: number; y: number }> + >() + const aggregateExternalTargetsByPcbPortId = new Map< + string, + { x: number; y: number } + >() for (const sourceTrace of sourceTraces) { - if ( - !sourceTrace.connected_source_port_ids.some((sourcePortId) => - internalSourcePortIds.has(sourcePortId), - ) - ) { + if (!region.endpointBySourceTraceId.has(sourceTrace.source_trace_id)) { continue } for (const sourcePortId of sourceTrace.connected_source_port_ids) { @@ -298,19 +318,27 @@ const getSingleRegionPreferredEdge = ({ ) { continue } - const isInsideRegion = + const isInsideRegionBounds = pcbPort.x >= region.bounds.minX && pcbPort.x <= region.bounds.maxX && pcbPort.y >= region.bounds.minY && pcbPort.y <= region.bounds.maxY - if (isInsideRegion) continue - externalPcbPorts.set(pcbPort.pcb_port_id, { + if (!isInsideRegionBounds) { + aggregateExternalTargetsByPcbPortId.set(pcbPort.pcb_port_id, { + x: pcbPort.x, + y: pcbPort.y, + }) + } + const targets = + externalTargetsByConnectionId.get(sourceTrace.source_trace_id) ?? [] + targets.push({ x: pcbPort.x, y: pcbPort.y, }) + externalTargetsByConnectionId.set(sourceTrace.source_trace_id, targets) } } - if (externalPcbPorts.size === 0) return undefined + if (externalTargetsByConnectionId.size === 0) return undefined const distanceToEdge = ( target: { x: number; y: number }, @@ -335,17 +363,44 @@ const getSingleRegionPreferredEdge = ({ return Math.hypot(target.x - edgePoint.x, target.y - edgePoint.y) } const edges: BreakoutEdge[] = ["right", "left", "top", "bottom"] - return edges.reduce((bestEdge, edge) => { - const bestCost = [...externalPcbPorts.values()].reduce( - (sum, target) => sum + distanceToEdge(target, bestEdge), - 0, - ) - const edgeCost = [...externalPcbPorts.values()].reduce( - (sum, target) => sum + distanceToEdge(target, edge), - 0, - ) - return edgeCost < bestCost ? edge : bestEdge - }, edges[0]!) + const getPreferredEdge = (targets: readonly { x: number; y: number }[]) => + edges.reduce((bestEdge, edge) => { + const bestCost = targets.reduce( + (sum, target) => sum + distanceToEdge(target, bestEdge), + 0, + ) + const edgeCost = targets.reduce( + (sum, target) => sum + distanceToEdge(target, edge), + 0, + ) + return edgeCost < bestCost ? edge : bestEdge + }, edges[0]!) + const allExternalTargets = [...externalTargetsByConnectionId.values()].flat() + const aggregateExternalTargets = [ + ...aggregateExternalTargetsByPcbPortId.values(), + ] + const preferredEdgeByConnectionId = new Map() + const externalTargetByConnectionId = new Map< + SourceTraceId, + { x: number; y: number } + >() + for (const [connectionId, targets] of externalTargetsByConnectionId) { + preferredEdgeByConnectionId.set(connectionId, getPreferredEdge(targets)) + externalTargetByConnectionId.set(connectionId, { + x: targets.reduce((sum, target) => sum + target.x, 0) / targets.length, + y: targets.reduce((sum, target) => sum + target.y, 0) / targets.length, + }) + } + + return { + preferredEdge: getPreferredEdge( + aggregateExternalTargets.length > 0 + ? aggregateExternalTargets + : allExternalTargets, + ), + preferredEdgeByConnectionId, + externalTargetByConnectionId, + } } const getSourceTraceForConnectionOrThrow = ({ @@ -667,9 +722,9 @@ export const createImplicitBreakoutPointSolverContext = ( PcbGroupId, ReadonlyMap >() - const singleRegionPreferredEdge = + const singleRegionEdgePreferences = regions.length === 1 - ? getSingleRegionPreferredEdge({ region: regions[0]!, sourceTraces }) + ? getSingleRegionEdgePreferences({ region: regions[0]!, sourceTraces }) : undefined const solverRegions = regions.map((region, regionIndex) => { sourcePortIdByConnectionIdByRegionId.set( @@ -684,7 +739,7 @@ export const createImplicitBreakoutPointSolverContext = ( regionIndex, regions, horizontalPlacement, - singleRegionPreferredEdge, + singleRegionPreferredEdge: singleRegionEdgePreferences?.preferredEdge, }), } }) @@ -697,5 +752,9 @@ export const createImplicitBreakoutPointSolverContext = ( boundaryPointSpacing: getBoundaryPointSpacing(breakout), }, sourcePortIdByConnectionIdByRegionId, + singleRegionPreferredEdgeByConnectionId: + singleRegionEdgePreferences?.preferredEdgeByConnectionId, + singleRegionExternalTargetByConnectionId: + singleRegionEdgePreferences?.externalTargetByConnectionId, } } diff --git a/lib/components/primitive-components/Breakout/default-implicit-breakout-point-solver.ts b/lib/components/primitive-components/Breakout/default-implicit-breakout-point-solver.ts index ac5ef73dd..f4caa567a 100644 --- a/lib/components/primitive-components/Breakout/default-implicit-breakout-point-solver.ts +++ b/lib/components/primitive-components/Breakout/default-implicit-breakout-point-solver.ts @@ -1,6 +1,9 @@ import type { ImplicitBreakoutConnection, + ImplicitBreakoutEdge, ImplicitBreakoutPointSolverFn, + ImplicitBreakoutPointSolverInput, + ImplicitBreakoutPointSolverOutput, } from "@tscircuit/props" import { WindingBreakoutSolver, @@ -15,35 +18,205 @@ const toWindingConnection = ( endpoints: connection.endpoints, }) -/** Adapt Core's canonical implicit-breakout contract to the winding solver. */ -export const defaultImplicitBreakoutPointSolverFn: ImplicitBreakoutPointSolverFn = - (input) => { - const windingInput = { - regions: input.regions.map((region) => ({ - id: region.regionId, - bounds: region.bounds, - edge: region.edge, - })), - connections: input.connections.map((connection) => - "type" in connection - ? { - type: "differential" as const, - connections: connection.connections.map(toWindingConnection) as [ - WindingConnectionInput, - WindingConnectionInput, - ], - } - : toWindingConnection(connection), +const getConnectionIds = ( + connection: ImplicitBreakoutPointSolverInput["connections"][number], +): string[] => + "type" in connection + ? connection.connections.map((member) => member.connectionId) + : [connection.connectionId] + +const solveWithWindingSolver = ( + input: ImplicitBreakoutPointSolverInput, +): ImplicitBreakoutPointSolverOutput => { + const windingInput = { + regions: input.regions.map((region) => ({ + id: region.regionId, + bounds: region.bounds, + edge: region.edge, + })), + connections: input.connections.map((connection) => + "type" in connection + ? { + type: "differential" as const, + connections: connection.connections.map(toWindingConnection) as [ + WindingConnectionInput, + WindingConnectionInput, + ], + } + : toWindingConnection(connection), + ), + buses: input.buses.map((bus) => ({ + id: bus.busId, + connectionIds: bus.connectionIds, + preferredLayers: bus.targetLayers, + })), + boundaryPointSpacing: input.boundaryPointSpacing, + } satisfies WindingBreakoutSolverInput + + const solver = new WindingBreakoutSolver(windingInput) + solver.solve() + return solver.getOutput() +} + +const getPreferredEdgesForStandaloneConnections = ({ + input, + preferredEdgeByConnectionId, +}: { + input: ImplicitBreakoutPointSolverInput + preferredEdgeByConnectionId: ReadonlyMap +}): Map => { + const fallbackEdge = input.regions[0]!.edge + const groupedConnectionIds = new Set( + input.buses.flatMap((bus) => bus.connectionIds), + ) + for (const connection of input.connections) { + if ("type" in connection) { + for (const connectionId of getConnectionIds(connection)) { + groupedConnectionIds.add(connectionId) + } + } + } + return new Map( + input.connections.flatMap((connection) => + getConnectionIds(connection).map( + (connectionId) => + [ + connectionId, + groupedConnectionIds.has(connectionId) + ? fallbackEdge + : (preferredEdgeByConnectionId.get(connectionId) ?? fallbackEdge), + ] as const, ), - buses: input.buses.map((bus) => ({ - id: bus.busId, - connectionIds: bus.connectionIds, - preferredLayers: bus.targetLayers, - })), - boundaryPointSpacing: input.boundaryPointSpacing, - } satisfies WindingBreakoutSolverInput - - const solver = new WindingBreakoutSolver(windingInput) - solver.solve() - return solver.getOutput() + ), + ) +} + +const shiftBreakoutPointsTowardExternalTargets = ({ + breakoutPoints, + edge, + bounds, + externalTargetByConnectionId, +}: { + breakoutPoints: ImplicitBreakoutPointSolverOutput["breakoutPoints"] + edge: ImplicitBreakoutEdge + bounds: ImplicitBreakoutPointSolverInput["regions"][number]["bounds"] + externalTargetByConnectionId: ReadonlyMap +}): ImplicitBreakoutPointSolverOutput["breakoutPoints"] => { + const vertical = edge === "left" || edge === "right" + const axisMinimum = vertical ? bounds.minY : bounds.minX + const axisMaximum = vertical ? bounds.maxY : bounds.maxX + const targetAxes = breakoutPoints.flatMap((point) => { + const target = externalTargetByConnectionId.get(point.connectionId) + return target ? [vertical ? target.y : target.x] : [] + }) + if (targetAxes.length === 0 || breakoutPoints.length === 0) { + return breakoutPoints + } + const currentAxes = breakoutPoints.map((point) => + vertical ? point.y : point.x, + ) + const desiredCenter = + targetAxes.reduce((sum, axis) => sum + axis, 0) / targetAxes.length + const currentCenter = + currentAxes.reduce((sum, axis) => sum + axis, 0) / currentAxes.length + const minimumShift = axisMinimum - Math.min(...currentAxes) + const maximumShift = axisMaximum - Math.max(...currentAxes) + const shift = Math.max( + minimumShift, + Math.min(maximumShift, desiredCenter - currentCenter), + ) + return breakoutPoints.map((point) => ({ + ...point, + ...(vertical ? { y: point.y + shift } : { x: point.x + shift }), + })) +} + +export const solveDefaultImplicitBreakoutPoints = ({ + input, + preferredEdgeByConnectionId, + externalTargetByConnectionId, +}: { + input: ImplicitBreakoutPointSolverInput + preferredEdgeByConnectionId?: ReadonlyMap + externalTargetByConnectionId?: ReadonlyMap +}): ImplicitBreakoutPointSolverOutput => { + const baselineOutput = solveWithWindingSolver(input) + if (input.regions.length !== 1 || !preferredEdgeByConnectionId?.size) { + return baselineOutput } + + const region = input.regions[0]! + const constrainedEdges = getPreferredEdgesForStandaloneConnections({ + input, + preferredEdgeByConnectionId, + }) + const connectionCountByEdge = new Map() + for (const edge of constrainedEdges.values()) { + connectionCountByEdge.set(edge, (connectionCountByEdge.get(edge) ?? 0) + 1) + } + // Keep dense fanouts on the winding solver's shared edge. A singleton + // outlier may use its nearer edge when at least two routes retain the + // original edge, which avoids long cross-region escapes without weakening + // the baseline feasibility check above. + const fallbackConnectionCount = connectionCountByEdge.get(region.edge) ?? 0 + if (fallbackConnectionCount < 2) { + for (const connectionId of constrainedEdges.keys()) { + constrainedEdges.set(connectionId, region.edge) + } + } else { + for (const [connectionId, edge] of constrainedEdges) { + if (edge !== region.edge && (connectionCountByEdge.get(edge) ?? 0) > 1) { + constrainedEdges.set(connectionId, region.edge) + } + } + } + const selectedEdges = new Set(constrainedEdges.values()) + if (selectedEdges.size === 1) return baselineOutput + const connectionGroupsByEdge = new Map< + ImplicitBreakoutEdge, + typeof input.connections + >() + for (const connection of input.connections) { + const edge = + constrainedEdges.get(getConnectionIds(connection)[0]!) ?? region.edge + const connections = connectionGroupsByEdge.get(edge) ?? [] + connectionGroupsByEdge.set(edge, [...connections, connection]) + } + + const breakoutPoints = [...connectionGroupsByEdge.entries()].flatMap( + ([edge, connections]) => { + const connectionIds = new Set(connections.flatMap(getConnectionIds)) + const output = solveWithWindingSolver({ + ...input, + regions: [{ ...region, edge }], + connections, + buses: input.buses.filter((bus) => + bus.connectionIds.every((connectionId) => + connectionIds.has(connectionId), + ), + ), + }) + const allConnectionsPreferSelectedEdge = [...connectionIds].every( + (connectionId) => + preferredEdgeByConnectionId.get(connectionId) === edge, + ) + if ( + !allConnectionsPreferSelectedEdge || + !externalTargetByConnectionId?.size + ) { + return output.breakoutPoints + } + return shiftBreakoutPointsTowardExternalTargets({ + breakoutPoints: output.breakoutPoints, + edge, + bounds: region.bounds, + externalTargetByConnectionId, + }) + }, + ) + return { breakoutPoints } +} + +/** Adapt Core's canonical implicit-breakout contract to the winding solver. */ +export const defaultImplicitBreakoutPointSolverFn: ImplicitBreakoutPointSolverFn = + (input) => solveDefaultImplicitBreakoutPoints({ input }) diff --git a/lib/components/primitive-components/Breakout/solve-implicit-breakout-points.ts b/lib/components/primitive-components/Breakout/solve-implicit-breakout-points.ts index 73c472579..1e9893214 100644 --- a/lib/components/primitive-components/Breakout/solve-implicit-breakout-points.ts +++ b/lib/components/primitive-components/Breakout/solve-implicit-breakout-points.ts @@ -7,6 +7,10 @@ import { type ImplicitBreakoutPointSolverContext, createImplicitBreakoutPointSolverContext, } from "./create-implicit-breakout-point-solver-input" +import { + defaultImplicitBreakoutPointSolverFn, + solveDefaultImplicitBreakoutPoints, +} from "./default-implicit-breakout-point-solver" type SourceTraceId = SourceTrace["source_trace_id"] type SourcePortId = NonNullable @@ -42,7 +46,16 @@ const solveScope = ( solverFn: ImplicitBreakoutPointSolverFn, ): CoordinatedScopeSolution => { const context = createImplicitBreakoutPointSolverContext(breakout) - const output = solverFn(context.input) + const output = + solverFn === defaultImplicitBreakoutPointSolverFn + ? solveDefaultImplicitBreakoutPoints({ + input: context.input, + preferredEdgeByConnectionId: + context.singleRegionPreferredEdgeByConnectionId, + externalTargetByConnectionId: + context.singleRegionExternalTargetByConnectionId, + }) + : solverFn(context.input) if (output instanceof Promise) { throw new Error( "Implicit breakout point solvers must return synchronously during PCB layout", diff --git a/tests/breakout/__snapshots__/breakout-qfp16-with-header-and-passives-autorouting-srj.snap.svg b/tests/breakout/__snapshots__/breakout-qfp16-with-header-and-passives-autorouting-srj.snap.svg index a326211c9..979c1e1f2 100644 --- a/tests/breakout/__snapshots__/breakout-qfp16-with-header-and-passives-autorouting-srj.snap.svg +++ b/tests/breakout/__snapshots__/breakout-qfp16-with-header-and-passives-autorouting-srj.snap.svg @@ -15,56 +15,56 @@