From 21a43975768ff4203d97885208f9230c2af2c7d6 Mon Sep 17 00:00:00 2001 From: ShiboSoftwareDev Date: Fri, 31 Jul 2026 07:13:38 +0200 Subject: [PATCH] fix(topology): detect BGA cores in mixed packages --- .../RectBoundsComponentDetectionStage.ts | 14 +- .../detectors/bga/BgaComponentDetector.ts | 105 +++++++++- .../ComponentTopologyGeneratorSolver.ts | 28 ++- .../topologyPlanningShared.ts | 5 +- .../srj24-sample4-topology-fix.snap.svg | 44 +++++ .../srj24-sample4-topology-repro.snap.svg | 4 +- .../srj24-sample4-topology.fixture.ts | 47 +++-- .../srj24-sample4-topology-fix.test.ts | 187 ++++++++++++++++++ .../srj24-sample4-topology-repro.test.ts | 4 +- 9 files changed, 404 insertions(+), 34 deletions(-) create mode 100644 tests/features/topology/__snapshots__/srj24-sample4-topology-fix.snap.svg create mode 100644 tests/features/topology/srj24-sample4-topology-fix.test.ts diff --git a/lib/solvers/ComponentDetectionSolver/RectBoundsComponentDetectionStage.ts b/lib/solvers/ComponentDetectionSolver/RectBoundsComponentDetectionStage.ts index f10f43415..a9654a9f0 100644 --- a/lib/solvers/ComponentDetectionSolver/RectBoundsComponentDetectionStage.ts +++ b/lib/solvers/ComponentDetectionSolver/RectBoundsComponentDetectionStage.ts @@ -10,6 +10,7 @@ import type { ComponentDetectionSolverParams, DetectedComponent, } from "./ComponentDetectionSolver" +import { getBgaLikeObstacleSubset } from "./detectors/bga/BgaComponentDetector" import { detectComponentKind, type ComponentKind } from "./detectors" /** @@ -243,7 +244,7 @@ export class RectBoundsComponentDetectionStage extends BaseSolver { grouped[obstacle.componentId].push(obstacle) } - const detectedEntries = Object.entries(grouped).filter( + const detectedEntries = Object.entries(grouped).flatMap( ([componentId, memberObstacles]) => { const boardBounds = this.inputSrj.bounds const hasOutOfBoundsObstacle = memberObstacles.some((obstacle) => { @@ -257,17 +258,22 @@ export class RectBoundsComponentDetectionStage extends BaseSolver { }) if (hasOutOfBoundsObstacle) { - return false + return [] } const componentKind = detectComponentKind({ memberObstacles, inputSrj: this.inputSrj, }) - if (!componentKind) return false + if (!componentKind) return [] componentKinds[componentId] = componentKind - return true + const detectedObstacles = + componentKind === "bga" + ? getBgaLikeObstacleSubset(memberObstacles)! + : memberObstacles + + return [[componentId, detectedObstacles] as const] }, ) diff --git a/lib/solvers/ComponentDetectionSolver/detectors/bga/BgaComponentDetector.ts b/lib/solvers/ComponentDetectionSolver/detectors/bga/BgaComponentDetector.ts index a7f15bab0..5c6462404 100644 --- a/lib/solvers/ComponentDetectionSolver/detectors/bga/BgaComponentDetector.ts +++ b/lib/solvers/ComponentDetectionSolver/detectors/bga/BgaComponentDetector.ts @@ -8,7 +8,12 @@ const MAX_BGA_PAD_ASPECT_RATIO = 1.5 const MIN_INTERIOR_PAD_COUNT = 1 const AXIS_CLUSTER_EPSILON = 1e-3 -export function clusterAxisValues(values: number[]) { +type BgaGridRow = { + y: number + obstaclesByX: Map +} + +export function clusterAxisValues(values: number[]): number[] { const sortedValues = [...values].sort((a, b) => a - b) const clustered: number[] = [] @@ -25,7 +30,7 @@ export function clusterAxisValues(values: number[]) { return clustered } -function hasUniformDimensionWithinTolerance(values: number[]) { +function hasUniformDimensionWithinTolerance(values: number[]): boolean { const minValue = Math.min(...values) const maxValue = Math.max(...values) @@ -34,7 +39,7 @@ function hasUniformDimensionWithinTolerance(values: number[]) { return maxValue / minValue <= 1 + MAX_BGA_PAD_SIZE_VARIANCE } -function hasUniformPadDimensions(memberObstacles: Obstacle[]) { +function hasUniformPadDimensions(memberObstacles: Obstacle[]): boolean { return ( hasUniformDimensionWithinTolerance( memberObstacles.map((obstacle) => obstacle.width), @@ -58,7 +63,7 @@ function hasInteriorPads( memberObstacles: Obstacle[], rowAxisValues: number[], columnAxisValues: number[], -) { +): boolean { if (rowAxisValues.length < MIN_BGA_AXIS_COUNT) return false if (columnAxisValues.length < MIN_BGA_AXIS_COUNT) return false @@ -79,7 +84,7 @@ function hasInteriorPads( return interiorPadCount >= MIN_INTERIOR_PAD_COUNT } -export function isBgaLikeComponent(memberObstacles: Obstacle[]) { +function isDirectBgaLikeComponent(memberObstacles: Obstacle[]): boolean { if (!hasUniformPadDimensions(memberObstacles)) return false if (!hasBgaLikePadAspectRatio(memberObstacles)) return false @@ -109,6 +114,96 @@ export function isBgaLikeComponent(memberObstacles: Obstacle[]) { return true } +function getPadGeometryKey(obstacle: Obstacle): string { + return [ + Math.round(obstacle.width / AXIS_CLUSTER_EPSILON), + Math.round(obstacle.height / AXIS_CLUSTER_EPSILON), + obstacle.layers.join(","), + ].join(":") +} + +function hasUniformPitch(values: number[]): boolean { + if (values.length < MIN_BGA_AXIS_COUNT) return false + + const sortedValues = [...values].sort((a, b) => a - b) + const pitch = sortedValues[1]! - sortedValues[0]! + if (pitch <= AXIS_CLUSTER_EPSILON) return false + + return sortedValues.every( + (value, index) => + index === 0 || + Math.abs(value - sortedValues[index - 1]! - pitch) <= + AXIS_CLUSTER_EPSILON, + ) +} + +function getRowsForPadGeometry(obstacles: Obstacle[]): BgaGridRow[] { + const rowsByY = new Map() + + for (const obstacle of obstacles) { + const yKey = Math.round(obstacle.center.y / AXIS_CLUSTER_EPSILON) + const xKey = Math.round(obstacle.center.x / AXIS_CLUSTER_EPSILON) + const row = rowsByY.get(yKey) ?? { + y: obstacle.center.y, + obstaclesByX: new Map(), + } + row.obstaclesByX.set(xKey, obstacle) + rowsByY.set(yKey, row) + } + + return [...rowsByY.values()] +} + +function findLargestCompleteGrid(obstacles: Obstacle[]): Obstacle[] { + const rows = getRowsForPadGeometry(obstacles) + let largestGrid: Obstacle[] = [] + + for (const candidateRow of rows) { + const xKeys = [...candidateRow.obstaclesByX.keys()] + if ( + !hasUniformPitch( + [...candidateRow.obstaclesByX.values()].map( + (obstacle) => obstacle.center.x, + ), + ) + ) { + continue + } + + const matchingRows = rows.filter((row) => + xKeys.every((xKey) => row.obstaclesByX.has(xKey)), + ) + if (!hasUniformPitch(matchingRows.map((row) => row.y))) continue + + const grid = matchingRows.flatMap((row) => + xKeys.map((xKey) => row.obstaclesByX.get(xKey)!), + ) + if (grid.length > largestGrid.length) largestGrid = grid + } + + return largestGrid +} + +export function getBgaLikeObstacleSubset( + memberObstacles: Obstacle[], +): Obstacle[] | null { + if (isDirectBgaLikeComponent(memberObstacles)) return memberObstacles + + const geometryGroups = Map.groupBy(memberObstacles, getPadGeometryKey) + let largestGrid: Obstacle[] = [] + + for (const geometryGroup of geometryGroups.values()) { + const grid = findLargestCompleteGrid(geometryGroup) + if (grid.length > largestGrid.length) largestGrid = grid + } + + return largestGrid.length > 0 ? largestGrid : null +} + +export function isBgaLikeComponent(memberObstacles: Obstacle[]): boolean { + return getBgaLikeObstacleSubset(memberObstacles) !== null +} + export class BgaComponentDetector implements ComponentDetector { static readonly componentKind = "bga" readonly componentKind = BgaComponentDetector.componentKind diff --git a/lib/solvers/ComponentTopologyGeneratorSolver/ComponentTopologyGeneratorSolver.ts b/lib/solvers/ComponentTopologyGeneratorSolver/ComponentTopologyGeneratorSolver.ts index dadf7533d..a7db129f0 100644 --- a/lib/solvers/ComponentTopologyGeneratorSolver/ComponentTopologyGeneratorSolver.ts +++ b/lib/solvers/ComponentTopologyGeneratorSolver/ComponentTopologyGeneratorSolver.ts @@ -22,6 +22,21 @@ export interface ComponentTopologyGeneratorSolverParams { export type ComponentTopologyGeneratorSolverOutput = CapacityMeshNode[] +export function isObstacleInDetectedComponent( + obstacle: Obstacle, + detectedComponent: DetectedComponent, +): boolean { + if (obstacle.componentId !== detectedComponent.componentId) return false + + const { bounds } = detectedComponent + return ( + obstacle.center.x >= bounds.minX && + obstacle.center.x <= bounds.maxX && + obstacle.center.y >= bounds.minY && + obstacle.center.y <= bounds.maxY + ) +} + export function createReplacementObstacleForComponent({ detectedComponent, inputSrj, @@ -29,8 +44,8 @@ export function createReplacementObstacleForComponent({ detectedComponent: DetectedComponent inputSrj: SimpleRouteJson }): Obstacle & { obstacleId: string } { - const memberObstacles = inputSrj.obstacles.filter( - (obstacle) => obstacle.componentId === detectedComponent.componentId, + const memberObstacles = inputSrj.obstacles.filter((obstacle) => + isObstacleInDetectedComponent(obstacle, detectedComponent), ) const zLayers = Array.from({ length: inputSrj.layerCount }, (_, z) => z) const layers = zLayers.map((z) => mapZToLayerName(z, inputSrj.layerCount)) @@ -62,17 +77,14 @@ export function createComponentObstacleSrj({ detectedComponents: DetectedComponent[] inputSrj: SimpleRouteJson }): SimpleRouteJson { - const detectedComponentIds = new Set( - detectedComponents.map((component) => component.componentId), - ) - return { ...structuredClone(inputSrj), obstacles: [ ...inputSrj.obstacles.filter( (obstacle) => - !obstacle.componentId || - !detectedComponentIds.has(obstacle.componentId), + !detectedComponents.some((detectedComponent) => + isObstacleInDetectedComponent(obstacle, detectedComponent), + ), ), ...detectedComponents.map((detectedComponent) => createReplacementObstacleForComponent({ diff --git a/lib/solvers/TopologyPlanningSolver/topologyPlanningShared.ts b/lib/solvers/TopologyPlanningSolver/topologyPlanningShared.ts index e7010d5ce..a071ac4a0 100644 --- a/lib/solvers/TopologyPlanningSolver/topologyPlanningShared.ts +++ b/lib/solvers/TopologyPlanningSolver/topologyPlanningShared.ts @@ -10,6 +10,7 @@ import type { ComponentKind } from "lib/solvers/ComponentDetectionSolver/detecto import { createComponentObstacleSrj, createReplacementObstacleForComponent, + isObstacleInDetectedComponent, } from "lib/solvers/ComponentTopologyGeneratorSolver/ComponentTopologyGeneratorSolver" import type { DetectedComponent } from "lib/solvers/ComponentDetectionSolver/ComponentDetectionSolver" import { @@ -158,8 +159,8 @@ function serializeDetectedComponents({ inputSrj: SimpleRouteJson }): SerializedTopologyComponentInput[] { return detectedComponents.map((detectedComponent) => { - const memberObstacles = inputSrj.obstacles.filter( - (obstacle) => obstacle.componentId === detectedComponent.componentId, + const memberObstacles = inputSrj.obstacles.filter((obstacle) => + isObstacleInDetectedComponent(obstacle, detectedComponent), ) return { diff --git a/tests/features/topology/__snapshots__/srj24-sample4-topology-fix.snap.svg b/tests/features/topology/__snapshots__/srj24-sample4-topology-fix.snap.svg new file mode 100644 index 000000000..14ebc8972 --- /dev/null +++ b/tests/features/topology/__snapshots__/srj24-sample4-topology-fix.snap.svg @@ -0,0 +1,44 @@ +Before: mixed pad packagestep:0 layer:allFix 1: regular BGA core detectedstep:6 layer:allz0z1z2z3z4z5target-toptarget-bottomfree-topfree-bottomBefore: terminal layers disconnectedstep:2249 layer:allz0z1z2z3z4z5route-0route-1route-2route-3route-4Fix 2: scoped BGA topology is reachablestep:43 layer:all \ No newline at end of file diff --git a/tests/features/topology/__snapshots__/srj24-sample4-topology-repro.snap.svg b/tests/features/topology/__snapshots__/srj24-sample4-topology-repro.snap.svg index 497443e52..524603d36 100644 --- a/tests/features/topology/__snapshots__/srj24-sample4-topology-repro.snap.svg +++ b/tests/features/topology/__snapshots__/srj24-sample4-topology-repro.snap.svg @@ -1,4 +1,4 @@ -Before: mixed pad packagestep:0 layer:allHidden regular BGA corestep:1 layer:allz0z1z2z3z4z5target-toptarget-bottomfree-topfree-bottomBefore: isolated terminal layersstep:2 layer:allz0z1z2z3z4z5target-toptarget-bottomfree-topfree-bottomBefore: no cross-layer edgestep:3 layer:all