diff --git a/lib/solvers/DecouplingCapsHorizontalRowSolver/DecouplingCapsHorizontalRowSolver.ts b/lib/solvers/DecouplingCapsHorizontalRowSolver/DecouplingCapsHorizontalRowSolver.ts new file mode 100644 index 0000000..054bfcf --- /dev/null +++ b/lib/solvers/DecouplingCapsHorizontalRowSolver/DecouplingCapsHorizontalRowSolver.ts @@ -0,0 +1,150 @@ +import type { GraphicsObject } from "graphics-debug" +import { BaseSolver } from "../BaseSolver" +import type { ChipId, PartitionInputProblem } from "../../types/InputProblem" + +/** + * Shape returned to the parent SingleInnerPartitionPackingSolver — must + * be assignment-compatible with `PackSolver2["packedComponents"]` so the + * existing layout-extraction code at the call site keeps working. + */ +export type PackedComponent = { + componentId: string + center: { x: number; y: number } + ccwRotationOffset?: number + ccwRotationDegrees?: number +} + +/** + * Specialized packer for partitions of decoupling capacitors. + * + * Why this exists + * --------------- + * The general-purpose `PackSolver2` arranges decoupling caps using the + * same minimum-distance heuristics it uses for arbitrary chips. That + * tends to leave them scattered or stacked in a bounding box that's + * convenient for the packer but ugly for a human reader. The + * acceptable layout shown in tscircuit/matchpack#15 is a single tidy + * row: all caps share a y-axis, sit at consistent x intervals, and the + * row is later attached next to the main chip's power pins by the + * outer `PartitionPackingSolver`. + * + * Algorithm + * --------- + * 1. Pull the cap chips out of the partition. Sort by chipId so the row + * order is deterministic across runs (important for snapshot + * stability and for the outer packer's nearest-neighbor logic). + * 2. Stretch them along the x-axis with `gap` separation between + * adjacent cap edges, centering the whole row on the origin so the + * outer packer treats the partition as a single rectangular block. + * 3. Apply each cap's first available rotation (typically 0°) — the + * `IdentifyDecouplingCapsSolver` already restricts caps to + * rotations that keep the y+/y- pin pair upright, so we don't need + * to choose between options. + * + * The solver completes in a single `_step()` — there's nothing + * iterative to do — but it conforms to the BaseSolver / PackSolver2 + * shape so the caller can treat it interchangeably. + */ +export class DecouplingCapsHorizontalRowSolver extends BaseSolver { + partitionInputProblem: PartitionInputProblem + gap: number + packedComponents: PackedComponent[] = [] + + constructor(params: { + partitionInputProblem: PartitionInputProblem + /** + * Gap between adjacent cap edges. If unset we fall back to the + * partition's `decouplingCapsGap`, then `chipGap`. Mirrors the + * existing fallback chain in SingleInnerPartitionPackingSolver. + */ + gap?: number + }) { + super() + this.partitionInputProblem = params.partitionInputProblem + this.gap = + params.gap ?? + this.partitionInputProblem.decouplingCapsGap ?? + this.partitionInputProblem.chipGap + } + + override getConstructorParams(): [ + { partitionInputProblem: PartitionInputProblem; gap?: number }, + ] { + return [ + { partitionInputProblem: this.partitionInputProblem, gap: this.gap }, + ] + } + + override _step() { + const chips = Object.values(this.partitionInputProblem.chipMap) + + if (chips.length === 0) { + this.packedComponents = [] + this.solved = true + return + } + + // Sort by chipId for deterministic ordering. If there's exactly one + // cap (degenerate partition) we still go through the same path — + // the row "of one" lands centered on the origin. + const ordered = [...chips].sort((a, b) => a.chipId.localeCompare(b.chipId)) + + // Compute total row width: sum of cap widths + (n-1) gaps. Then + // walk from the left edge placing each cap so that the row is + // centered on x=0 / y=0. + const totalWidth = + ordered.reduce((sum, chip) => sum + chip.size.x, 0) + + Math.max(0, ordered.length - 1) * this.gap + + let cursor = -totalWidth / 2 + + const packed: PackedComponent[] = [] + for (const chip of ordered) { + const halfWidth = chip.size.x / 2 + const rotation = pickRotation(chip.availableRotations) + packed.push({ + componentId: chip.chipId, + center: { x: cursor + halfWidth, y: 0 }, + ccwRotationDegrees: rotation, + ccwRotationOffset: rotation, + }) + cursor += chip.size.x + this.gap + } + + this.packedComponents = packed + this.solved = true + } + + override visualize(): GraphicsObject { + return { + lines: [], + points: [], + circles: [], + rects: this.packedComponents.map((pc) => { + const chip = this.partitionInputProblem.chipMap[pc.componentId]! + return { + center: pc.center, + width: chip.size.x, + height: chip.size.y, + fill: "rgba(120,180,255,0.35)", + stroke: "rgba(40,90,180,0.8)", + label: chip.chipId, + } + }), + } + } +} + +/** + * Decoupling caps come in with `availableRotations: [0]` from + * `IdentifyDecouplingCapsSolver`, but the field is also optional — + * default to 0 so we never hand back undefined. + */ +function pickRotation( + available: ReadonlyArray<0 | 90 | 180 | 270> | undefined, +): 0 | 90 | 180 | 270 { + if (!available || available.length === 0) return 0 + return available[0]! +} + +export type { ChipId } diff --git a/lib/solvers/PackInnerPartitionsSolver/SingleInnerPartitionPackingSolver.ts b/lib/solvers/PackInnerPartitionsSolver/SingleInnerPartitionPackingSolver.ts index 88db103..9b0c8a9 100644 --- a/lib/solvers/PackInnerPartitionsSolver/SingleInnerPartitionPackingSolver.ts +++ b/lib/solvers/PackInnerPartitionsSolver/SingleInnerPartitionPackingSolver.ts @@ -19,13 +19,20 @@ import { visualizeInputProblem } from "../LayoutPipelineSolver/visualizeInputPro import { createFilteredNetworkMapping } from "../../utils/networkFiltering" import { getPadsBoundingBox } from "./getPadsBoundingBox" import { doBasicInputProblemLayout } from "../LayoutPipelineSolver/doBasicInputProblemLayout" +import { + DecouplingCapsHorizontalRowSolver, + type PackedComponent, +} from "../DecouplingCapsHorizontalRowSolver/DecouplingCapsHorizontalRowSolver" const PIN_SIZE = 0.1 export class SingleInnerPartitionPackingSolver extends BaseSolver { partitionInputProblem: PartitionInputProblem layout: OutputLayout | null = null - declare activeSubSolver: PackSolver2 | null + declare activeSubSolver: + | PackSolver2 + | DecouplingCapsHorizontalRowSolver + | null pinIdToStronglyConnectedPins: Record constructor(params: { @@ -38,19 +45,26 @@ export class SingleInnerPartitionPackingSolver extends BaseSolver { } override _step() { - // Initialize PackSolver2 if not already created + // Initialize the packing sub-solver. Decoupling-cap partitions get a + // specialized row layout (matchpack#15); everything else falls + // through to the general-purpose PackSolver2. if (!this.activeSubSolver) { - const packInput = this.createPackInput() - this.activeSubSolver = new PackSolver2(packInput) - this.activeSubSolver = this.activeSubSolver + if (this.partitionInputProblem.partitionType === "decoupling_caps") { + this.activeSubSolver = new DecouplingCapsHorizontalRowSolver({ + partitionInputProblem: this.partitionInputProblem, + }) + } else { + const packInput = this.createPackInput() + this.activeSubSolver = new PackSolver2(packInput) + } } - // Run one step of the PackSolver2 + // Run one step of the active sub-solver this.activeSubSolver.step() if (this.activeSubSolver.failed) { this.failed = true - this.error = `PackSolver2 failed: ${this.activeSubSolver.error}` + this.error = `${this.activeSubSolver.constructor.name} failed: ${this.activeSubSolver.error}` return } @@ -142,7 +156,7 @@ export class SingleInnerPartitionPackingSolver extends BaseSolver { } private createLayoutFromPackingResult( - packedComponents: PackSolver2["packedComponents"], + packedComponents: PackSolver2["packedComponents"] | PackedComponent[], ): OutputLayout { const chipPlacements: Record = {} diff --git a/tests/DecouplingCapsHorizontalRowSolver/DecouplingCapsHorizontalRowSolver.test.ts b/tests/DecouplingCapsHorizontalRowSolver/DecouplingCapsHorizontalRowSolver.test.ts new file mode 100644 index 0000000..9a562ac --- /dev/null +++ b/tests/DecouplingCapsHorizontalRowSolver/DecouplingCapsHorizontalRowSolver.test.ts @@ -0,0 +1,266 @@ +import { describe, expect, test } from "bun:test" +import { DecouplingCapsHorizontalRowSolver } from "../../lib/solvers/DecouplingCapsHorizontalRowSolver/DecouplingCapsHorizontalRowSolver" +import type { + Chip, + ChipPin, + PartitionInputProblem, +} from "../../lib/types/InputProblem" +import { normalizeSide } from "../../lib/types/Side" + +const makeCap = ( + chipId: string, + size: { x: number; y: number } = { x: 0.5, y: 1.0 }, +): Chip => ({ + chipId, + pins: [`${chipId}_top`, `${chipId}_bot`], + size, + isDecouplingCap: true, + availableRotations: [0], +}) + +const makeCapPin = (chipId: string, suffix: "top" | "bot"): ChipPin => ({ + pinId: `${chipId}_${suffix}`, + offset: { x: 0, y: suffix === "top" ? 0.5 : -0.5 }, + side: suffix === "top" ? normalizeSide("top") : normalizeSide("bottom"), +}) + +const makePartition = ( + chips: Chip[], + overrides: Partial = {}, +): PartitionInputProblem => { + const chipMap: Record = {} + const chipPinMap: Record = {} + for (const chip of chips) { + chipMap[chip.chipId] = chip + chipPinMap[`${chip.chipId}_top`] = makeCapPin(chip.chipId, "top") + chipPinMap[`${chip.chipId}_bot`] = makeCapPin(chip.chipId, "bot") + } + return { + chipMap, + chipPinMap, + netMap: { + VCC: { netId: "VCC", isPositiveVoltageSource: true }, + GND: { netId: "GND", isGround: true }, + }, + pinStrongConnMap: {}, + netConnMap: {}, + chipGap: 0.2, + partitionGap: 1.0, + decouplingCapsGap: 0.1, + isPartition: true, + partitionType: "decoupling_caps", + ...overrides, + } +} + +describe("DecouplingCapsHorizontalRowSolver", () => { + test("empty partition solves immediately with no packed components", () => { + const solver = new DecouplingCapsHorizontalRowSolver({ + partitionInputProblem: makePartition([]), + }) + solver.solve() + expect(solver.solved).toBe(true) + expect(solver.failed).toBe(false) + expect(solver.packedComponents).toEqual([]) + }) + + test("single cap is centered on the origin", () => { + const solver = new DecouplingCapsHorizontalRowSolver({ + partitionInputProblem: makePartition([makeCap("C1", { x: 0.5, y: 1.0 })]), + }) + solver.solve() + expect(solver.packedComponents).toHaveLength(1) + expect(solver.packedComponents[0]!.componentId).toBe("C1") + expect(solver.packedComponents[0]!.center.x).toBeCloseTo(0, 9) + expect(solver.packedComponents[0]!.center.y).toBe(0) + }) + + test("multiple caps form a horizontal row centered on the origin", () => { + const caps = [makeCap("C1"), makeCap("C2"), makeCap("C3"), makeCap("C4")] + const solver = new DecouplingCapsHorizontalRowSolver({ + partitionInputProblem: makePartition(caps, { decouplingCapsGap: 0.1 }), + }) + solver.solve() + const packed = solver.packedComponents + expect(packed).toHaveLength(4) + + // Caps in chipId order + expect(packed.map((p) => p.componentId)).toEqual(["C1", "C2", "C3", "C4"]) + + // All on y=0 + for (const p of packed) { + expect(p.center.y).toBe(0) + } + + // Equal x spacing between adjacent centers (cap width 0.5, gap 0.1 → 0.6) + const spacings: number[] = [] + for (let i = 1; i < packed.length; i++) { + spacings.push(packed[i]!.center.x - packed[i - 1]!.center.x) + } + for (const s of spacings) { + expect(s).toBeCloseTo(0.6, 9) + } + + // Row is centered: leftmost and rightmost centers should mirror around 0 + expect(packed[0]!.center.x).toBeCloseTo( + -packed[packed.length - 1]!.center.x, + 9, + ) + }) + + test("custom gap parameter overrides decouplingCapsGap and chipGap fallbacks", () => { + const partition = makePartition([makeCap("C1"), makeCap("C2")], { + decouplingCapsGap: 5, + chipGap: 10, + }) + const solver = new DecouplingCapsHorizontalRowSolver({ + partitionInputProblem: partition, + gap: 0.5, + }) + solver.solve() + // With cap width 0.5 and gap 0.5, adjacent centers are 1.0 apart. + expect( + solver.packedComponents[1]!.center.x - + solver.packedComponents[0]!.center.x, + ).toBeCloseTo(1.0, 9) + }) + + test("falls back to decouplingCapsGap when gap is unset", () => { + const solver = new DecouplingCapsHorizontalRowSolver({ + partitionInputProblem: makePartition([makeCap("C1"), makeCap("C2")], { + decouplingCapsGap: 0.3, + }), + }) + solver.solve() + expect( + solver.packedComponents[1]!.center.x - + solver.packedComponents[0]!.center.x, + ).toBeCloseTo(0.8, 9) // 0.5 (width) + 0.3 (gap) + }) + + test("falls back to chipGap when decouplingCapsGap is missing", () => { + const partition = makePartition([makeCap("C1"), makeCap("C2")], { + chipGap: 0.4, + }) + delete (partition as { decouplingCapsGap?: number }).decouplingCapsGap + const solver = new DecouplingCapsHorizontalRowSolver({ + partitionInputProblem: partition, + }) + solver.solve() + expect( + solver.packedComponents[1]!.center.x - + solver.packedComponents[0]!.center.x, + ).toBeCloseTo(0.9, 9) // 0.5 (width) + 0.4 (gap) + }) + + test("non-uniform cap widths still produce a centered, gap-correct row", () => { + const caps = [ + makeCap("C1", { x: 0.5, y: 1.0 }), + makeCap("C2", { x: 1.0, y: 1.0 }), + makeCap("C3", { x: 0.5, y: 1.0 }), + ] + const solver = new DecouplingCapsHorizontalRowSolver({ + partitionInputProblem: makePartition(caps, { + decouplingCapsGap: 0.1, + }), + }) + solver.solve() + const packed = solver.packedComponents + // Total width = 0.5 + 1.0 + 0.5 + 2 * 0.1 = 2.2 + // Leftmost center at -1.1 + 0.5/2 = -0.85 + expect(packed[0]!.center.x).toBeCloseTo(-0.85, 9) + // C2 center at -0.85 + 0.25 + 0.1 + 0.5 = -0.0 + expect(packed[1]!.center.x).toBeCloseTo(0, 9) + // C3 center at 0 + 0.5 + 0.1 + 0.25 = 0.85 + expect(packed[2]!.center.x).toBeCloseTo(0.85, 9) + }) + + test("ordering is deterministic via lexicographic chipId sort", () => { + // Insert in non-alphabetical order; output should still be sorted. + const caps = [makeCap("C10"), makeCap("C2"), makeCap("C1")] + const solver = new DecouplingCapsHorizontalRowSolver({ + partitionInputProblem: makePartition(caps), + }) + solver.solve() + expect(solver.packedComponents.map((p) => p.componentId)).toEqual([ + "C1", + "C10", + "C2", + ]) + }) + + test("rotation is taken from the first availableRotation, defaulting to 0", () => { + const c1 = makeCap("C1") + const c2 = makeCap("C2") + c2.availableRotations = undefined + const solver = new DecouplingCapsHorizontalRowSolver({ + partitionInputProblem: makePartition([c1, c2]), + }) + solver.solve() + expect(solver.packedComponents[0]!.ccwRotationDegrees).toBe(0) + expect(solver.packedComponents[1]!.ccwRotationDegrees).toBe(0) + }) + + test("packedComponents shape is assignment-compatible with PackSolver2 consumers", () => { + const solver = new DecouplingCapsHorizontalRowSolver({ + partitionInputProblem: makePartition([makeCap("C1")]), + }) + solver.solve() + const pc = solver.packedComponents[0]! + expect(pc.componentId).toBeTypeOf("string") + expect(pc.center.x).toBeTypeOf("number") + expect(pc.center.y).toBeTypeOf("number") + expect(pc.ccwRotationDegrees).toBeTypeOf("number") + }) + + test("solver completes in a single step regardless of cap count", () => { + const caps = Array.from({ length: 50 }, (_, i) => + makeCap(`C${String(i).padStart(3, "0")}`), + ) + const solver = new DecouplingCapsHorizontalRowSolver({ + partitionInputProblem: makePartition(caps), + }) + solver.step() + expect(solver.solved).toBe(true) + expect(solver.iterations).toBe(1) + }) + + test("re-running solver produces identical output (idempotent)", () => { + const caps = [makeCap("C1"), makeCap("C2"), makeCap("C3")] + const a = new DecouplingCapsHorizontalRowSolver({ + partitionInputProblem: makePartition(caps), + }) + const b = new DecouplingCapsHorizontalRowSolver({ + partitionInputProblem: makePartition(caps), + }) + a.solve() + b.solve() + expect(b.packedComponents).toEqual(a.packedComponents) + }) + + test("visualize returns rects for every packed cap, with size from chipMap", () => { + const caps = [ + makeCap("C1", { x: 0.5, y: 1.0 }), + makeCap("C2", { x: 0.5, y: 1.0 }), + ] + const solver = new DecouplingCapsHorizontalRowSolver({ + partitionInputProblem: makePartition(caps), + }) + solver.solve() + const viz = solver.visualize() + expect(viz.rects).toHaveLength(2) + expect(viz.rects?.[0]?.width).toBeCloseTo(0.5, 9) + expect(viz.rects?.[0]?.height).toBeCloseTo(1.0, 9) + }) + + test("getConstructorParams round-trips so the BaseSolver pipeline can re-run a phase", () => { + const partition = makePartition([makeCap("C1")]) + const solver = new DecouplingCapsHorizontalRowSolver({ + partitionInputProblem: partition, + gap: 0.3, + }) + const [params] = solver.getConstructorParams() + expect(params.partitionInputProblem).toBe(partition) + expect(params.gap).toBe(0.3) + }) +}) diff --git a/tests/DecouplingCapsHorizontalRowSolver/integration.test.ts b/tests/DecouplingCapsHorizontalRowSolver/integration.test.ts new file mode 100644 index 0000000..3c5707b --- /dev/null +++ b/tests/DecouplingCapsHorizontalRowSolver/integration.test.ts @@ -0,0 +1,101 @@ +import { test, expect } from "bun:test" + +test("decoupling_caps partition produces a horizontal-row layout via the inner solver", () => { + const { + SingleInnerPartitionPackingSolver, + } = require("../../lib/solvers/PackInnerPartitionsSolver/SingleInnerPartitionPackingSolver") + + const solver = new SingleInnerPartitionPackingSolver({ + partitionInputProblem: { + chipMap: { + C1: { chipId: "C1", pins: ["C1.1", "C1.2"], size: { x: 0.5, y: 1.0 } }, + C2: { chipId: "C2", pins: ["C2.1", "C2.2"], size: { x: 0.5, y: 1.0 } }, + C3: { chipId: "C3", pins: ["C3.1", "C3.2"], size: { x: 0.5, y: 1.0 } }, + }, + chipPinMap: { + "C1.1": { pinId: "C1.1", offset: { x: 0, y: 0.5 }, side: "y+" }, + "C1.2": { pinId: "C1.2", offset: { x: 0, y: -0.5 }, side: "y-" }, + "C2.1": { pinId: "C2.1", offset: { x: 0, y: 0.5 }, side: "y+" }, + "C2.2": { pinId: "C2.2", offset: { x: 0, y: -0.5 }, side: "y-" }, + "C3.1": { pinId: "C3.1", offset: { x: 0, y: 0.5 }, side: "y+" }, + "C3.2": { pinId: "C3.2", offset: { x: 0, y: -0.5 }, side: "y-" }, + }, + netMap: {}, + pinStrongConnMap: {}, + netConnMap: {}, + chipGap: 0.2, + partitionGap: 1, + decouplingCapsGap: 0.1, + isPartition: true, + partitionType: "decoupling_caps", + }, + pinIdToStronglyConnectedPins: {}, + }) + + solver.solve() + expect(solver.solved).toBe(true) + expect(solver.layout).toBeDefined() + + const placements = solver.layout!.chipPlacements + expect(Object.keys(placements)).toEqual( + expect.arrayContaining(["C1", "C2", "C3"]), + ) + + // All three caps land on the same y-axis (y=0 — the row). + for (const id of ["C1", "C2", "C3"]) { + expect(placements[id].y).toBe(0) + } + + // Sorted x order (C1 < C2 < C3) so the row reads left-to-right by chipId. + expect(placements.C1.x).toBeLessThan(placements.C2.x) + expect(placements.C2.x).toBeLessThan(placements.C3.x) + + // Uniform spacing — all gaps match within float epsilon. + const gap1 = placements.C2.x - placements.C1.x + const gap2 = placements.C3.x - placements.C2.x + expect(gap1).toBeCloseTo(gap2, 9) +}) + +test("default partitions still drive PackSolver2 (regression guard)", () => { + // The partitionType branch must only kick in for `decoupling_caps`. + // For the default path the solver should still construct PackSolver2, + // which we detect via its different output stat shape (PackSolver2 + // has internal `iterations` typically much larger than 1, while + // DecouplingCapsHorizontalRowSolver completes in one step). + const { + SingleInnerPartitionPackingSolver, + } = require("../../lib/solvers/PackInnerPartitionsSolver/SingleInnerPartitionPackingSolver") + + const solver = new SingleInnerPartitionPackingSolver({ + partitionInputProblem: { + chipMap: { + U1: { chipId: "U1", pins: ["U1.1"], size: { x: 2, y: 2 } }, + }, + chipPinMap: { + "U1.1": { pinId: "U1.1", offset: { x: 0, y: 0 }, side: "x+" }, + }, + netMap: {}, + pinStrongConnMap: {}, + netConnMap: {}, + chipGap: 0.2, + partitionGap: 1, + isPartition: true, + partitionType: "default", + }, + pinIdToStronglyConnectedPins: {}, + }) + + // Step once and inspect the live sub-solver before it finishes. + solver._step() + // PackSolver2 takes multiple steps; the row solver completes in 1. + // Either way, the active sub-solver should NOT be the row solver. + if (solver.activeSubSolver) { + expect(solver.activeSubSolver.constructor.name).not.toBe( + "DecouplingCapsHorizontalRowSolver", + ) + } + + // Drive to completion (PackSolver2 should still solve a 1-chip case). + solver.solve() + expect(solver.solved).toBe(true) +})