From 77151ca0c92e15558da23d6b87d99985f0a7ba16 Mon Sep 17 00:00:00 2001 From: s300169140 Date: Mon, 11 May 2026 18:17:44 +0000 Subject: [PATCH] feat: dedicated decoupling-cap layout with VCC-pin alignment (#15) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closes #15 Adds a specialized layout path for partitions tagged \`partitionType: "decoupling_caps"\`. Instead of running them through the generic \`PackSolver2\`, the caps are placed in a centered horizontal row, each rotated so its positive-voltage pin consistently faces the same direction across the row. Why rotation alignment matters: a hand-drawn schematic of decoupling caps always has every cap oriented identically (VCC up, GND down, or the opposite) — never alternating. The previous layout code (and an alternative attempt in #59) just used \`availableRotations[0]\`, which left mixed orientations whenever the input was wired with VCC on different pins of different caps. Algorithm: 1. For each cap in the partition, find which pin (if any) is connected to a positive-voltage net (\`netMap[netId].isPositiveVoltageSource\`) 2. Pick a target side for that pin via majority vote of the caps' natural-rotation positions (preferring \`y+\` on ties), so already- correctly-oriented caps don't get unnecessarily rotated 3. For each cap, pick the rotation from \`availableRotations\` that puts its positive pin on the target side; fall back to the first allowed rotation when the cap has no voltage signal Caps are then laid out left-to-right with \`decouplingCapsGap\` (or \`chipGap\` as fallback) and the resulting row is centered on x=0. Tests added: - happy-path: three caps where one is wired backwards; verifies it gets flipped 180° while the others keep their natural rotation - no-voltage-signal: cap with no positive-voltage net falls back to \`availableRotations[0]\` Note: the existing test \`IdentifyDecouplingCapsSolver06.test.ts\` fails on \`main\` due to an unrelated import error in the linked \`LayoutPipelineSolver06.page.tsx\`. That failure is unchanged by this PR. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../SingleInnerPartitionPackingSolver.ts | 130 ++++++++++++++++ .../DecouplingCapsLayout.test.ts | 146 ++++++++++++++++++ 2 files changed, 276 insertions(+) create mode 100644 tests/PackInnerPartitionsSolver/DecouplingCapsLayout.test.ts diff --git a/lib/solvers/PackInnerPartitionsSolver/SingleInnerPartitionPackingSolver.ts b/lib/solvers/PackInnerPartitionsSolver/SingleInnerPartitionPackingSolver.ts index 88db103..5bc91fa 100644 --- a/lib/solvers/PackInnerPartitionsSolver/SingleInnerPartitionPackingSolver.ts +++ b/lib/solvers/PackInnerPartitionsSolver/SingleInnerPartitionPackingSolver.ts @@ -13,6 +13,7 @@ import type { ChipId, NetId, ChipPin, + Chip, PartitionInputProblem, } from "../../types/InputProblem" import { visualizeInputProblem } from "../LayoutPipelineSolver/visualizeInputProblem" @@ -38,6 +39,15 @@ export class SingleInnerPartitionPackingSolver extends BaseSolver { } override _step() { + // Decoupling-cap partitions use a dedicated linear-row layout instead of + // the generic packer. Caps line up horizontally with each cap rotated so + // its positive-voltage pin faces the same direction across the row. + if (this.partitionInputProblem.partitionType === "decoupling_caps") { + this.layout = this.createDecouplingCapsLayout() + this.solved = true + return + } + // Initialize PackSolver2 if not already created if (!this.activeSubSolver) { const packInput = this.createPackInput() @@ -64,6 +74,126 @@ export class SingleInnerPartitionPackingSolver extends BaseSolver { } } + /** + * Lay out the decoupling capacitors in a horizontal row, centered around the + * partition origin. Each cap is rotated (0° or 180°) so that the pin + * connected to a positive-voltage net consistently faces the same direction + * (y+ when possible) — that's the convention seen in hand-drawn schematics + * and is what makes a row of decoupling caps look uniform. + */ + private createDecouplingCapsLayout(): OutputLayout { + const { chipMap } = this.partitionInputProblem + const chips = Object.values(chipMap) + const gap = + this.partitionInputProblem.decouplingCapsGap ?? + this.partitionInputProblem.chipGap ?? + 0.1 + + // Pick a target side for the positive-voltage pin once for the partition, + // by majority vote of the caps' natural (rotation=0) layout. This keeps + // already-correctly-oriented caps unchanged when possible. + const naturalSides = chips + .map((c) => this.getPositivePinNaturalSide(c)) + .filter((s): s is "y+" | "y-" | "x+" | "x-" => s != null) + const targetSide = this.pickTargetSide(naturalSides) + + const chipPlacements: Record = {} + let currentX = 0 + for (const chip of chips) { + const rotation = this.pickRotationForPositivePin(chip, targetSide) + chipPlacements[chip.chipId] = { + x: currentX + chip.size.x / 2, + y: 0, + ccwRotationDegrees: rotation, + } + currentX += chip.size.x + gap + } + + // Center the row around x=0. + const totalWidth = Math.max(currentX - gap, 0) + const offsetX = -totalWidth / 2 + for (const id in chipPlacements) { + chipPlacements[id]!.x += offsetX + } + + return { chipPlacements, groupPlacements: {} } + } + + /** + * Side (y+/y-/x+/x-) of the chip where the positive-voltage pin sits at + * rotation 0. Returns null if no pin is connected to a positive-voltage net. + */ + private getPositivePinNaturalSide( + chip: Chip, + ): "y+" | "y-" | "x+" | "x-" | null { + const { chipPinMap, netMap, netConnMap } = this.partitionInputProblem + for (const pinId of chip.pins) { + const pin = chipPinMap[pinId] + if (!pin) continue + for (const netId in netMap) { + if (!netMap[netId]?.isPositiveVoltageSource) continue + if (netConnMap[`${pinId}-${netId}` as `${PinId}-${NetId}`]) { + return pin.side + } + } + } + return null + } + + /** + * Pick the consistent target side for positive-voltage pins. Prefers y+ when + * supported, otherwise picks the most common natural side so we minimize the + * number of caps that need to be flipped from their input orientation. + */ + private pickTargetSide( + naturalSides: Array<"y+" | "y-" | "x+" | "x-">, + ): "y+" | "y-" | "x+" | "x-" { + if (naturalSides.length === 0) return "y+" + const counts: Record = {} + for (const s of naturalSides) counts[s] = (counts[s] ?? 0) + 1 + // Default to y+ unless another side has strictly more votes. + let best: "y+" | "y-" | "x+" | "x-" = "y+" + let bestCount = counts["y+"] ?? 0 + for (const s of ["y-", "x+", "x-"] as const) { + if ((counts[s] ?? 0) > bestCount) { + best = s + bestCount = counts[s]! + } + } + return best + } + + /** + * Pick a rotation (in degrees, ccw) such that the cap's positive-voltage pin + * ends up on the target side after rotation. Falls back to the first + * availableRotation when there's no positive-voltage pin to align. + */ + private pickRotationForPositivePin( + chip: Chip, + targetSide: "y+" | "y-" | "x+" | "x-", + ): number { + const naturalSide = this.getPositivePinNaturalSide(chip) + const allowed = chip.availableRotations ?? [0, 90, 180, 270] + if (naturalSide == null) return allowed[0] ?? 0 + + const rotateSide = ( + side: "y+" | "y-" | "x+" | "x-", + deg: 0 | 90 | 180 | 270, + ): "y+" | "y-" | "x+" | "x-" => { + // CCW rotation of a side label. + const order = ["x+", "y+", "x-", "y-"] as const + const idx = order.indexOf(side) + const steps = (deg / 90) | 0 + return order[(idx + steps) % 4]! + } + + for (const rot of allowed) { + if (rotateSide(naturalSide, rot) === targetSide) return rot + } + // Target unreachable with this cap's rotation set; keep its first allowed. + return allowed[0] ?? 0 + } + private createPackInput(): PackInput { // Fall back to filtered mapping (weak + strong) const pinToNetworkMap = createFilteredNetworkMapping({ diff --git a/tests/PackInnerPartitionsSolver/DecouplingCapsLayout.test.ts b/tests/PackInnerPartitionsSolver/DecouplingCapsLayout.test.ts new file mode 100644 index 0000000..19abe04 --- /dev/null +++ b/tests/PackInnerPartitionsSolver/DecouplingCapsLayout.test.ts @@ -0,0 +1,146 @@ +import { test, expect } from "bun:test" +import { SingleInnerPartitionPackingSolver } from "lib/solvers/PackInnerPartitionsSolver/SingleInnerPartitionPackingSolver" +import type { PartitionInputProblem } from "lib/types/InputProblem" + +/** + * Regression test for tscircuit/matchpack#15 - Specialized Layout for + * Decoupling Capacitors. + * + * For partitions tagged `decoupling_caps`, the solver should: + * + * 1. Lay caps out in a horizontal row, centered around x=0 + * 2. Pick each cap's rotation so the pin connected to the positive-voltage + * net consistently faces the same direction across the row (i.e. all + * VCC pins up, or all VCC pins down — never mixed within one row) + * 3. Use the configured `decouplingCapsGap` for spacing + */ + +const baseProblem = (): PartitionInputProblem => ({ + chipMap: {}, + chipPinMap: {}, + netMap: { + VCC: { netId: "VCC", isPositiveVoltageSource: true }, + GND: { netId: "GND", isGround: true }, + }, + pinStrongConnMap: {}, + netConnMap: {}, + chipGap: 0.1, + partitionGap: 0.2, + decouplingCapsGap: 0.3, + isPartition: true, + partitionType: "decoupling_caps", +}) + +test("decoupling-caps partition lays caps in a centered horizontal row", () => { + const problem = baseProblem() + problem.chipMap = { + C1: { + chipId: "C1", + pins: ["C1.1", "C1.2"], + size: { x: 0.5, y: 1.0 }, + isDecouplingCap: true, + availableRotations: [0, 180], + }, + C2: { + chipId: "C2", + pins: ["C2.1", "C2.2"], + size: { x: 0.5, y: 1.0 }, + isDecouplingCap: true, + availableRotations: [0, 180], + }, + C3: { + chipId: "C3", + pins: ["C3.1", "C3.2"], + size: { x: 0.5, y: 1.0 }, + isDecouplingCap: true, + availableRotations: [0, 180], + }, + } + // C1 and C3 have VCC on y+ (top). C2 has VCC on y- (bottom). + // The solver should rotate C2 by 180° so all VCC pins face y+. + problem.chipPinMap = { + "C1.1": { pinId: "C1.1", offset: { x: 0, y: 0.5 }, side: "y+" }, + "C1.2": { pinId: "C1.1", 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-" }, + } + problem.netConnMap = { + "C1.1-VCC": true, + "C1.2-GND": true, + "C2.2-VCC": true, // C2 wired backwards — VCC on bottom pin + "C2.1-GND": true, + "C3.1-VCC": true, + "C3.2-GND": true, + } + + const solver = new SingleInnerPartitionPackingSolver({ + partitionInputProblem: problem, + pinIdToStronglyConnectedPins: {}, + }) + solver.solve() + + expect(solver.solved).toBe(true) + expect(solver.layout).not.toBeNull() + const placements = solver.layout!.chipPlacements + + // All three caps placed + expect(Object.keys(placements).sort()).toEqual(["C1", "C2", "C3"]) + + // All at y=0 (linear row) + expect(placements.C1!.y).toBe(0) + expect(placements.C2!.y).toBe(0) + expect(placements.C3!.y).toBe(0) + + // Row is centered: leftmost x and rightmost x are symmetric around 0 + const xs = [placements.C1!.x, placements.C2!.x, placements.C3!.x].sort( + (a, b) => a - b, + ) + expect(xs[0]! + xs[2]!).toBeCloseTo(0, 5) + + // Consistent gap (size 0.5 + gap 0.3 = 0.8 between centers) + expect(xs[1]! - xs[0]!).toBeCloseTo(0.8, 5) + expect(xs[2]! - xs[1]!).toBeCloseTo(0.8, 5) + + // C1 and C3 keep their natural rotation (VCC already on y+). + expect(placements.C1!.ccwRotationDegrees).toBe(0) + expect(placements.C3!.ccwRotationDegrees).toBe(0) + + // C2 is flipped 180° to align its VCC pin to y+. + expect(placements.C2!.ccwRotationDegrees).toBe(180) +}) + +test("decoupling-caps with no positive-voltage pin info falls back to first available rotation", () => { + const problem = baseProblem() + // Strip the voltage-source flag so we have no orientation signal. + problem.netMap = { + NET_A: { netId: "NET_A" }, + NET_B: { netId: "NET_B" }, + } + problem.chipMap = { + C1: { + chipId: "C1", + pins: ["C1.1", "C1.2"], + size: { x: 0.5, y: 1.0 }, + availableRotations: [0, 180], + }, + } + problem.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-" }, + } + problem.netConnMap = { + "C1.1-NET_A": true, + "C1.2-NET_B": true, + } + + const solver = new SingleInnerPartitionPackingSolver({ + partitionInputProblem: problem, + pinIdToStronglyConnectedPins: {}, + }) + solver.solve() + + expect(solver.solved).toBe(true) + expect(solver.layout!.chipPlacements.C1!.ccwRotationDegrees).toBe(0) +})