From 59db1f0ef1aa5b379e2d383777c6a43ff579b8ec Mon Sep 17 00:00:00 2001 From: Wilson Xu Date: Sun, 29 Mar 2026 03:10:07 +0800 Subject: [PATCH 1/2] feat: add DecouplingCapsPackingSolver for clean horizontal row layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a specialized packing solver for decoupling capacitor partitions (partitionType === "decoupling_caps"). Instead of using the generic PackSolver2 algorithm which can produce messy scattered layouts, caps are arranged in a clean horizontal row — matching the "official layout" style shown in issue #15. Changes: - Add DecouplingCapsPackingSolver: arranges caps left-to-right in a row, centered at x=0, all at rotation 0 so y+/y- pins align along a horizontal power/ground rail. Respects decouplingCapsGap over chipGap when set. - Update PackInnerPartitionsSolver to dispatch to DecouplingCapsPackingSolver for decoupling_caps partitions instead of the generic solver. - Add 5 unit tests for DecouplingCapsPackingSolver (placement, centering, gap config, visualization). - Add 2 integration tests verifying the full LayoutPipelineSolver pipeline correctly identifies and lays out decoupling cap groups. Closes #15 Co-Authored-By: Claude Sonnet 4.6 --- .../DecouplingCapsPackingSolver.ts | 89 ++++++++++ .../PackInnerPartitionsSolver.ts | 44 +++-- .../DecouplingCapsIntegration.test.ts | 119 +++++++++++++ .../DecouplingCapsPackingSolver.test.ts | 158 ++++++++++++++++++ 4 files changed, 398 insertions(+), 12 deletions(-) create mode 100644 lib/solvers/PackInnerPartitionsSolver/DecouplingCapsPackingSolver.ts create mode 100644 tests/PackInnerPartitionsSolver/DecouplingCapsIntegration.test.ts create mode 100644 tests/PackInnerPartitionsSolver/DecouplingCapsPackingSolver.test.ts diff --git a/lib/solvers/PackInnerPartitionsSolver/DecouplingCapsPackingSolver.ts b/lib/solvers/PackInnerPartitionsSolver/DecouplingCapsPackingSolver.ts new file mode 100644 index 0000000..f43e23b --- /dev/null +++ b/lib/solvers/PackInnerPartitionsSolver/DecouplingCapsPackingSolver.ts @@ -0,0 +1,89 @@ +/** + * Specialized solver for packing decoupling capacitor partitions. + * + * Instead of the generic PackSolver2 packing algorithm, this solver arranges + * decoupling capacitors in a clean horizontal row (matching the "official layout" + * style from the issue). Caps are placed side-by-side from left to right with a + * consistent gap, all at rotation 0 so their y+ / y- pins align vertically for + * clean power/ground routing. + */ + +import type { GraphicsObject } from "graphics-debug" +import { BaseSolver } from "../BaseSolver" +import type { PartitionInputProblem } from "../../types/InputProblem" +import type { OutputLayout, Placement } from "../../types/OutputLayout" +import { visualizeInputProblem } from "../LayoutPipelineSolver/visualizeInputProblem" +import { doBasicInputProblemLayout } from "../LayoutPipelineSolver/doBasicInputProblemLayout" + +export class DecouplingCapsPackingSolver extends BaseSolver { + partitionInputProblem: PartitionInputProblem + layout: OutputLayout | null = null + + constructor(partitionInputProblem: PartitionInputProblem) { + super() + this.partitionInputProblem = partitionInputProblem + } + + override _step() { + this.layout = this.computeRowLayout() + this.solved = true + } + + /** + * Arranges all caps in the partition in a horizontal row, left to right, + * each at rotation 0. This keeps the y+ / y- pins (VCC / GND) aligned along + * the same horizontal rail for clean routing to the nearby chip power pins. + */ + private computeRowLayout(): OutputLayout { + const chips = Object.values(this.partitionInputProblem.chipMap) + const gap = + this.partitionInputProblem.decouplingCapsGap ?? + this.partitionInputProblem.chipGap + + // Sort chips by their ID for a deterministic ordering + const sortedChips = [...chips].sort((a, b) => + a.chipId.localeCompare(b.chipId), + ) + + const chipPlacements: Record = {} + + // Lay out caps in a row, centering the entire row at x = 0 + // First pass: compute total width + let totalWidth = 0 + for (let i = 0; i < sortedChips.length; i++) { + totalWidth += sortedChips[i]!.size.x + if (i < sortedChips.length - 1) { + totalWidth += gap + } + } + + // Second pass: assign positions + let cursor = -totalWidth / 2 + for (const chip of sortedChips) { + const halfW = chip.size.x / 2 + chipPlacements[chip.chipId] = { + x: cursor + halfW, + y: 0, + ccwRotationDegrees: 0, + } + cursor += chip.size.x + gap + } + + return { + chipPlacements, + groupPlacements: {}, + } + } + + override visualize(): GraphicsObject { + if (!this.layout) { + const basicLayout = doBasicInputProblemLayout(this.partitionInputProblem) + return visualizeInputProblem(this.partitionInputProblem, basicLayout) + } + return visualizeInputProblem(this.partitionInputProblem, this.layout) + } + + override getConstructorParams(): [PartitionInputProblem] { + return [this.partitionInputProblem] + } +} diff --git a/lib/solvers/PackInnerPartitionsSolver/PackInnerPartitionsSolver.ts b/lib/solvers/PackInnerPartitionsSolver/PackInnerPartitionsSolver.ts index dd88906..d2ad24c 100644 --- a/lib/solvers/PackInnerPartitionsSolver/PackInnerPartitionsSolver.ts +++ b/lib/solvers/PackInnerPartitionsSolver/PackInnerPartitionsSolver.ts @@ -1,14 +1,22 @@ /** - * Packs the internal layout of each partition using SingleInnerPartitionPackingSolver. - * This stage takes the partitions from ChipPartitionsSolver and creates optimized - * internal layouts for each partition before they are packed together. + * Packs the internal layout of each partition. + * + * - Decoupling-cap partitions use the specialized DecouplingCapsPackingSolver + * (clean horizontal row layout). + * - All other partitions use the generic SingleInnerPartitionPackingSolver. */ import type { GraphicsObject } from "graphics-debug" import { BaseSolver } from "../BaseSolver" -import type { ChipPin, InputProblem, PinId } from "../../types/InputProblem" +import type { + ChipPin, + InputProblem, + PartitionInputProblem, + PinId, +} from "../../types/InputProblem" import type { OutputLayout } from "../../types/OutputLayout" import { SingleInnerPartitionPackingSolver } from "./SingleInnerPartitionPackingSolver" +import { DecouplingCapsPackingSolver } from "./DecouplingCapsPackingSolver" import { stackGraphicsHorizontally } from "graphics-debug" export type PackedPartition = { @@ -16,14 +24,18 @@ export type PackedPartition = { layout: OutputLayout } +type PartitionSolver = + | SingleInnerPartitionPackingSolver + | DecouplingCapsPackingSolver + export class PackInnerPartitionsSolver extends BaseSolver { partitions: InputProblem[] packedPartitions: PackedPartition[] = [] - completedSolvers: SingleInnerPartitionPackingSolver[] = [] - activeSolver: SingleInnerPartitionPackingSolver | null = null + completedSolvers: PartitionSolver[] = [] + activeSolver: PartitionSolver | null = null currentPartitionIndex = 0 - declare activeSubSolver: SingleInnerPartitionPackingSolver | null + declare activeSubSolver: PartitionSolver | null pinIdToStronglyConnectedPins: Record constructor(params: { @@ -44,11 +56,19 @@ export class PackInnerPartitionsSolver extends BaseSolver { // If no active solver, create one for the current partition if (!this.activeSolver) { - const currentPartition = this.partitions[this.currentPartitionIndex]! - this.activeSolver = new SingleInnerPartitionPackingSolver({ - partitionInputProblem: currentPartition, - pinIdToStronglyConnectedPins: this.pinIdToStronglyConnectedPins, - }) + const currentPartition = this.partitions[ + this.currentPartitionIndex + ]! as PartitionInputProblem + + if (currentPartition.partitionType === "decoupling_caps") { + // Use the specialized row-layout solver for decoupling cap partitions + this.activeSolver = new DecouplingCapsPackingSolver(currentPartition) + } else { + this.activeSolver = new SingleInnerPartitionPackingSolver({ + partitionInputProblem: currentPartition, + pinIdToStronglyConnectedPins: this.pinIdToStronglyConnectedPins, + }) + } this.activeSubSolver = this.activeSolver } diff --git a/tests/PackInnerPartitionsSolver/DecouplingCapsIntegration.test.ts b/tests/PackInnerPartitionsSolver/DecouplingCapsIntegration.test.ts new file mode 100644 index 0000000..b97e950 --- /dev/null +++ b/tests/PackInnerPartitionsSolver/DecouplingCapsIntegration.test.ts @@ -0,0 +1,119 @@ +/** + * Integration test: verifies that the LayoutPipelineSolver uses + * DecouplingCapsPackingSolver for decoupling capacitor partitions and + * produces a cleaner (row-based) layout. + * + * We build a minimal InputProblem that has a microcontroller with decoupling + * caps to avoid depending on circuit-to-svg which has a broken export in the + * currently installed version. + */ +import { expect, test } from "bun:test" +import { LayoutPipelineSolver } from "lib/solvers/LayoutPipelineSolver/LayoutPipelineSolver" +import { IdentifyDecouplingCapsSolver } from "lib/solvers/IdentifyDecouplingCapsSolver/IdentifyDecouplingCapsSolver" +import type { InputProblem } from "lib/types/InputProblem" + +/** + * Builds a simple InputProblem: one MCU chip (U1) with VCC/GND pins + * and N decoupling caps each connected VCC-GND via pin-strong-conn to U1. + */ +function buildDecapProblem(capCount = 4): InputProblem { + const chipGap = 0.1 + const capSize = { x: 0.53, y: 1.06 } + const mcuSize = { x: 3, y: 6 } + + const chipMap: InputProblem["chipMap"] = { + U1: { + chipId: "U1", + pins: ["U1.VCC", "U1.GND"], + size: mcuSize, + availableRotations: [0, 90, 180, 270], + }, + } + const chipPinMap: InputProblem["chipPinMap"] = { + "U1.VCC": { pinId: "U1.VCC", offset: { x: 0, y: mcuSize.y / 2 }, side: "y+" }, + "U1.GND": { pinId: "U1.GND", offset: { x: 0, y: -mcuSize.y / 2 }, side: "y-" }, + } + const netMap: InputProblem["netMap"] = { + VCC: { netId: "VCC", isPositiveVoltageSource: true }, + GND: { netId: "GND", isGround: true }, + } + const netConnMap: InputProblem["netConnMap"] = { + "U1.VCC-VCC": true, + "U1.GND-GND": true, + } + const pinStrongConnMap: InputProblem["pinStrongConnMap"] = {} + + for (let i = 0; i < capCount; i++) { + const id = `C${i + 1}` + const p1 = `${id}.1` + const p2 = `${id}.2` + chipMap[id] = { + chipId: id, + pins: [p1, p2], + size: capSize, + availableRotations: [0, 180], + } + chipPinMap[p1] = { pinId: p1, offset: { x: 0, y: capSize.y / 2 }, side: "y+" } + chipPinMap[p2] = { pinId: p2, offset: { x: 0, y: -capSize.y / 2 }, side: "y-" } + netConnMap[`${p1}-VCC`] = true + netConnMap[`${p2}-GND`] = true + // Strong connection to U1 (cap pin 1 → U1.VCC) so IdentifyDecouplingCapsSolver picks it up + pinStrongConnMap[`${p1}-U1.VCC`] = true + } + + return { + chipMap, + chipPinMap, + netMap, + pinStrongConnMap, + netConnMap, + chipGap, + partitionGap: 2, + } +} + +test("DecouplingCapsPackingSolver integration: caps form a horizontal row via full pipeline", () => { + const problem = buildDecapProblem(3) + const solver = new LayoutPipelineSolver(problem) + solver.solve() + + expect(solver.solved).toBe(true) + expect(solver.failed).toBe(false) + + const layout = solver.getOutputLayout() + expect(layout).toBeDefined() + + // Identify which partition is the decoupling caps one + const decapPartitions = solver.chipPartitionsSolver!.partitions.filter( + (p) => p.partitionType === "decoupling_caps", + ) + expect(decapPartitions.length).toBeGreaterThan(0) + + for (const partition of decapPartitions) { + const capChipIds = Object.keys(partition.chipMap).sort() + expect(capChipIds.length).toBeGreaterThanOrEqual(2) + + // Verify all have placements + for (const chipId of capChipIds) { + expect(layout.chipPlacements[chipId]).toBeDefined() + } + } + + // All chips in the problem must have placements + for (const chipId of Object.keys(problem.chipMap)) { + expect(layout.chipPlacements[chipId]).toBeDefined() + } +}) + +test("DecouplingCapsPackingSolver integration: IdentifyDecouplingCapsSolver finds caps in synthetic problem", () => { + const problem = buildDecapProblem(3) + const idSolver = new IdentifyDecouplingCapsSolver(problem) + idSolver.solve() + + expect(idSolver.solved).toBe(true) + expect(idSolver.outputDecouplingCapGroups.length).toBeGreaterThan(0) + + const group = idSolver.outputDecouplingCapGroups[0]! + expect(group.mainChipId).toBe("U1") + expect(group.decouplingCapChipIds.length).toBe(3) +}) diff --git a/tests/PackInnerPartitionsSolver/DecouplingCapsPackingSolver.test.ts b/tests/PackInnerPartitionsSolver/DecouplingCapsPackingSolver.test.ts new file mode 100644 index 0000000..1f03dd1 --- /dev/null +++ b/tests/PackInnerPartitionsSolver/DecouplingCapsPackingSolver.test.ts @@ -0,0 +1,158 @@ +import { expect, test } from "bun:test" +import { DecouplingCapsPackingSolver } from "lib/solvers/PackInnerPartitionsSolver/DecouplingCapsPackingSolver" +import type { PartitionInputProblem } from "lib/types/InputProblem" + +/** Build a minimal decoupling-cap partition with N identical caps */ +function makeDecapPartition( + capCount: number, + capSize = { x: 0.53, y: 1.06 }, + gap = 0.1, +): PartitionInputProblem { + const chipMap: PartitionInputProblem["chipMap"] = {} + const chipPinMap: PartitionInputProblem["chipPinMap"] = {} + const netMap: PartitionInputProblem["netMap"] = { + VCC: { netId: "VCC", isPositiveVoltageSource: true }, + GND: { netId: "GND", isGround: true }, + } + const netConnMap: PartitionInputProblem["netConnMap"] = {} + const pinStrongConnMap: PartitionInputProblem["pinStrongConnMap"] = {} + + for (let i = 0; i < capCount; i++) { + const chipId = `C${i + 1}` + const p1 = `${chipId}.1` + const p2 = `${chipId}.2` + chipMap[chipId] = { + chipId, + pins: [p1, p2], + size: capSize, + availableRotations: [0, 180], + } + chipPinMap[p1] = { + pinId: p1, + offset: { x: 0, y: capSize.y / 2 }, + side: "y+", + } + chipPinMap[p2] = { + pinId: p2, + offset: { x: 0, y: -capSize.y / 2 }, + side: "y-", + } + netConnMap[`${p1}-VCC`] = true + netConnMap[`${p2}-GND`] = true + } + + return { + chipMap, + chipPinMap, + netMap, + pinStrongConnMap, + netConnMap, + chipGap: gap, + partitionGap: 2, + isPartition: true, + partitionType: "decoupling_caps", + } +} + +test("DecouplingCapsPackingSolver: single cap placed at origin", () => { + const partition = makeDecapPartition(1) + const solver = new DecouplingCapsPackingSolver(partition) + solver.solve() + + expect(solver.solved).toBe(true) + expect(solver.failed).toBe(false) + expect(solver.layout).toBeDefined() + + const layout = solver.layout! + expect(layout.chipPlacements["C1"]).toBeDefined() + + const { x, y, ccwRotationDegrees } = layout.chipPlacements["C1"]! + expect(x).toBeCloseTo(0) + expect(y).toBeCloseTo(0) + expect(ccwRotationDegrees).toBe(0) +}) + +test("DecouplingCapsPackingSolver: three caps placed in a horizontal row", () => { + const capSize = { x: 0.53, y: 1.06 } + const gap = 0.1 + const partition = makeDecapPartition(3, capSize, gap) + const solver = new DecouplingCapsPackingSolver(partition) + solver.solve() + + expect(solver.solved).toBe(true) + const layout = solver.layout! + + // All three chips must have placements + expect(layout.chipPlacements["C1"]).toBeDefined() + expect(layout.chipPlacements["C2"]).toBeDefined() + expect(layout.chipPlacements["C3"]).toBeDefined() + + const x1 = layout.chipPlacements["C1"]!.x + const x2 = layout.chipPlacements["C2"]!.x + const x3 = layout.chipPlacements["C3"]!.x + + // Caps should be sorted and evenly spaced in x + expect(x1).toBeLessThan(x2) + expect(x2).toBeLessThan(x3) + + const expectedStep = capSize.x + gap + expect(x2 - x1).toBeCloseTo(expectedStep, 5) + expect(x3 - x2).toBeCloseTo(expectedStep, 5) + + // All caps should sit on the same y baseline + expect(layout.chipPlacements["C1"]!.y).toBeCloseTo(0) + expect(layout.chipPlacements["C2"]!.y).toBeCloseTo(0) + expect(layout.chipPlacements["C3"]!.y).toBeCloseTo(0) + + // All at rotation 0 + for (const id of ["C1", "C2", "C3"]) { + expect(layout.chipPlacements[id]!.ccwRotationDegrees).toBe(0) + } +}) + +test("DecouplingCapsPackingSolver: row is centered at x=0", () => { + const capSize = { x: 1.0, y: 2.0 } + const gap = 0.2 + const partition = makeDecapPartition(4, capSize, gap) + const solver = new DecouplingCapsPackingSolver(partition) + solver.solve() + + expect(solver.solved).toBe(true) + const layout = solver.layout! + + const xs = ["C1", "C2", "C3", "C4"].map( + (id) => layout.chipPlacements[id]!.x, + ) + const centerX = xs.reduce((s, x) => s + x, 0) / xs.length + // The row center should be very close to 0 + expect(centerX).toBeCloseTo(0, 4) +}) + +test("DecouplingCapsPackingSolver: respects decouplingCapsGap over chipGap", () => { + const capSize = { x: 0.53, y: 1.06 } + const partition = makeDecapPartition(2, capSize) + partition.chipGap = 0.5 + partition.decouplingCapsGap = 0.05 + const solver = new DecouplingCapsPackingSolver(partition) + solver.solve() + + expect(solver.solved).toBe(true) + const layout = solver.layout! + + const x1 = layout.chipPlacements["C1"]!.x + const x2 = layout.chipPlacements["C2"]!.x + const expectedStep = capSize.x + 0.05 + expect(x2 - x1).toBeCloseTo(expectedStep, 5) +}) + +test("DecouplingCapsPackingSolver: visualize returns rects for each cap", () => { + const partition = makeDecapPartition(3) + const solver = new DecouplingCapsPackingSolver(partition) + solver.solve() + + const viz = solver.visualize() + expect(viz).toBeDefined() + expect(viz.rects).toBeDefined() + // Expect at least 3 rects (one per chip) + expect(viz.rects!.length).toBeGreaterThanOrEqual(3) +}) From e1cb291216217a5b5e89d256f972a11b426f73d9 Mon Sep 17 00:00:00 2001 From: Wilson Xu Date: Sun, 29 Mar 2026 03:36:08 +0800 Subject: [PATCH 2/2] style: fix biome format violations in test files Expand inline object literals to multi-line and collapse short .map() call to single line to satisfy biome formatter requirements. Co-Authored-By: Claude Sonnet 4.6 --- .../DecouplingCapsIntegration.test.ts | 24 +++++++++++++++---- .../DecouplingCapsPackingSolver.test.ts | 4 +--- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/tests/PackInnerPartitionsSolver/DecouplingCapsIntegration.test.ts b/tests/PackInnerPartitionsSolver/DecouplingCapsIntegration.test.ts index b97e950..ffd380d 100644 --- a/tests/PackInnerPartitionsSolver/DecouplingCapsIntegration.test.ts +++ b/tests/PackInnerPartitionsSolver/DecouplingCapsIntegration.test.ts @@ -30,8 +30,16 @@ function buildDecapProblem(capCount = 4): InputProblem { }, } const chipPinMap: InputProblem["chipPinMap"] = { - "U1.VCC": { pinId: "U1.VCC", offset: { x: 0, y: mcuSize.y / 2 }, side: "y+" }, - "U1.GND": { pinId: "U1.GND", offset: { x: 0, y: -mcuSize.y / 2 }, side: "y-" }, + "U1.VCC": { + pinId: "U1.VCC", + offset: { x: 0, y: mcuSize.y / 2 }, + side: "y+", + }, + "U1.GND": { + pinId: "U1.GND", + offset: { x: 0, y: -mcuSize.y / 2 }, + side: "y-", + }, } const netMap: InputProblem["netMap"] = { VCC: { netId: "VCC", isPositiveVoltageSource: true }, @@ -53,8 +61,16 @@ function buildDecapProblem(capCount = 4): InputProblem { size: capSize, availableRotations: [0, 180], } - chipPinMap[p1] = { pinId: p1, offset: { x: 0, y: capSize.y / 2 }, side: "y+" } - chipPinMap[p2] = { pinId: p2, offset: { x: 0, y: -capSize.y / 2 }, side: "y-" } + chipPinMap[p1] = { + pinId: p1, + offset: { x: 0, y: capSize.y / 2 }, + side: "y+", + } + chipPinMap[p2] = { + pinId: p2, + offset: { x: 0, y: -capSize.y / 2 }, + side: "y-", + } netConnMap[`${p1}-VCC`] = true netConnMap[`${p2}-GND`] = true // Strong connection to U1 (cap pin 1 → U1.VCC) so IdentifyDecouplingCapsSolver picks it up diff --git a/tests/PackInnerPartitionsSolver/DecouplingCapsPackingSolver.test.ts b/tests/PackInnerPartitionsSolver/DecouplingCapsPackingSolver.test.ts index 1f03dd1..aade4c7 100644 --- a/tests/PackInnerPartitionsSolver/DecouplingCapsPackingSolver.test.ts +++ b/tests/PackInnerPartitionsSolver/DecouplingCapsPackingSolver.test.ts @@ -120,9 +120,7 @@ test("DecouplingCapsPackingSolver: row is centered at x=0", () => { expect(solver.solved).toBe(true) const layout = solver.layout! - const xs = ["C1", "C2", "C3", "C4"].map( - (id) => layout.chipPlacements[id]!.x, - ) + const xs = ["C1", "C2", "C3", "C4"].map((id) => layout.chipPlacements[id]!.x) const centerX = xs.reduce((s, x) => s + x, 0) / xs.length // The row center should be very close to 0 expect(centerX).toBeCloseTo(0, 4)