From 3f8d39bab18a8dc333321ad22f92fb55a5e5668f Mon Sep 17 00:00:00 2001 From: ElecTream Date: Thu, 28 May 2026 08:35:39 -0700 Subject: [PATCH 1/3] feat: add OverlapResolutionSolver to fix residual chip overlaps in final layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the gap between the assertion already present in RP2040Circuit.test.ts (`expect(overlaps.length).toBe(0)`) and the actual output of the pipeline, which today leaves 4 overlapping chip pairs in the RP2040 layout (U3/C14, C10/C7, C10/C12, C19/C15). ## What this adds A new pipeline phase, `OverlapResolutionSolver`, that runs after `PartitionPackingSolver` and iteratively pushes overlapping chip pairs apart along their minimum-penetration axis. Movement is weighted by chip area so a small passive moves more than a large anchor chip (RP2040, MCUs, etc), preserving the overall shape produced by the earlier phases. ## Behavior - Inflates each chip's AABB by `chipGap/2` per side so the configured `inputProblem.chipGap` is enforced (not just zero-overlap). - Processes the worst overlap first each pass (largest area), then iterates until no overlaps remain or `maxRelaxationIterations` (200) is reached. - Deep-clones the input layout — never mutates the caller's placements. - `getOutputLayout()` and `visualize()` now prefer the de-overlapped result, falling back to `partitionPackingSolver.finalLayout` for code that steps the pipeline manually and skips this phase. ## Verified - RP2040 circuit: 4 final overlaps → 0 (resolved in 17 relaxation iters) - Existing tests: all green - New unit tests cover: pair separation, no-op on clean layouts, input immutability, area-weighted movement, end-to-end regression for RP2040 /attempt #12 --- .../LayoutPipelineSolver.ts | 32 ++- .../OverlapResolutionSolver.ts | 264 ++++++++++++++++++ .../OverlapResolutionSolver01.test.ts | 175 ++++++++++++ 3 files changed, 467 insertions(+), 4 deletions(-) create mode 100644 lib/solvers/OverlapResolutionSolver/OverlapResolutionSolver.ts create mode 100644 tests/OverlapResolutionSolver/OverlapResolutionSolver01.test.ts diff --git a/lib/solvers/LayoutPipelineSolver/LayoutPipelineSolver.ts b/lib/solvers/LayoutPipelineSolver/LayoutPipelineSolver.ts index 33c7dd2..b79cf13 100644 --- a/lib/solvers/LayoutPipelineSolver/LayoutPipelineSolver.ts +++ b/lib/solvers/LayoutPipelineSolver/LayoutPipelineSolver.ts @@ -7,6 +7,7 @@ import type { GraphicsObject } from "graphics-debug" import { BaseSolver } from "lib/solvers/BaseSolver" import { ChipPartitionsSolver } from "lib/solvers/ChipPartitionsSolver/ChipPartitionsSolver" import { IdentifyDecouplingCapsSolver } from "lib/solvers/IdentifyDecouplingCapsSolver/IdentifyDecouplingCapsSolver" +import { OverlapResolutionSolver } from "lib/solvers/OverlapResolutionSolver/OverlapResolutionSolver" import { PackInnerPartitionsSolver, type PackedPartition, @@ -53,6 +54,7 @@ export class LayoutPipelineSolver extends BaseSolver { chipPartitionsSolver?: ChipPartitionsSolver packInnerPartitionsSolver?: PackInnerPartitionsSolver partitionPackingSolver?: PartitionPackingSolver + overlapResolutionSolver?: OverlapResolutionSolver startTimeOfPhase: Record endTimeOfPhase: Record @@ -124,6 +126,21 @@ export class LayoutPipelineSolver extends BaseSolver { }, }, ), + definePipelineStep( + "overlapResolutionSolver", + OverlapResolutionSolver, + () => [ + { + layout: this.partitionPackingSolver!.finalLayout!, + inputProblem: this.inputProblem, + }, + ], + { + onSolved: (_solver) => { + // Final de-overlapped layout is read via getOutputLayout() + }, + }, + ), ] constructor(inputProblem: InputProblem) { @@ -188,8 +205,11 @@ export class LayoutPipelineSolver extends BaseSolver { if (!this.solved && this.activeSubSolver) return this.activeSubSolver.visualize() - // If the pipeline is complete and we have a partition packing solver, - // show only the final chip placements + // If the pipeline is complete, prefer the de-overlapped layout from + // OverlapResolutionSolver; fall back to the raw packed layout otherwise. + if (this.solved && this.overlapResolutionSolver?.solved) { + return this.overlapResolutionSolver.visualize() + } if (this.solved && this.partitionPackingSolver?.solved) { return this.partitionPackingSolver.visualize() } @@ -400,8 +420,12 @@ export class LayoutPipelineSolver extends BaseSolver { let finalLayout: OutputLayout - // Get the final layout from the partition packing solver - if ( + // Prefer the de-overlapped layout produced by OverlapResolutionSolver. + // Fall back to partitionPackingSolver.finalLayout for callers that step + // the pipeline manually without running the overlap resolution phase. + if (this.overlapResolutionSolver?.solved) { + finalLayout = this.overlapResolutionSolver.finalLayout + } else if ( this.partitionPackingSolver?.solved && this.partitionPackingSolver.finalLayout ) { diff --git a/lib/solvers/OverlapResolutionSolver/OverlapResolutionSolver.ts b/lib/solvers/OverlapResolutionSolver/OverlapResolutionSolver.ts new file mode 100644 index 0000000..5ef7739 --- /dev/null +++ b/lib/solvers/OverlapResolutionSolver/OverlapResolutionSolver.ts @@ -0,0 +1,264 @@ +/** + * Resolves chip overlaps in a final layout by iteratively pushing overlapping + * chip pairs apart along their minimum-separation axis. Preserves overall + * shape: each pair-fix moves chips by the smallest amount needed, weighted by + * chip area so anchor chips (high pin count, large size) move less than + * passives. + * + * Runs as the final pipeline phase after PartitionPackingSolver. The earlier + * stages produce a layout that's optimal in connection-distance terms but can + * leave residual overlaps (e.g. between chips in different inner partitions + * after the partitions are packed together). This solver enforces the + * inputProblem.chipGap minimum spacing as a post-process. + */ + +import type { GraphicsObject } from "graphics-debug" +import { BaseSolver } from "../BaseSolver" +import type { ChipId, InputProblem } from "lib/types/InputProblem" +import type { OutputLayout, Placement } from "lib/types/OutputLayout" + +export interface OverlapResolutionSolverInput { + layout: OutputLayout + inputProblem: InputProblem + /** Minimum gap to enforce between chip bounding boxes (defaults to inputProblem.chipGap or 0.2) */ + chipGap?: number + /** Max iterations of the relaxation loop (defaults to 200) */ + maxRelaxationIterations?: number +} + +type AABB = { + minX: number + maxX: number + minY: number + maxY: number +} + +export class OverlapResolutionSolver extends BaseSolver { + inputProblem: InputProblem + inputLayout: OutputLayout + finalLayout: OutputLayout + chipGap: number + maxRelaxationIterations: number + relaxationIterations = 0 + resolvedOverlapCount = 0 + remainingOverlapCount = 0 + + constructor(input: OverlapResolutionSolverInput) { + super() + this.inputProblem = input.inputProblem + this.inputLayout = input.layout + // Deep-clone placements so we never mutate the caller's layout objects + this.finalLayout = { + chipPlacements: Object.fromEntries( + Object.entries(input.layout.chipPlacements).map(([id, p]) => [ + id, + { ...p }, + ]), + ), + groupPlacements: Object.fromEntries( + Object.entries(input.layout.groupPlacements).map(([id, p]) => [ + id, + { ...p }, + ]), + ), + } + this.chipGap = input.chipGap ?? this.inputProblem.chipGap ?? 0.2 + this.maxRelaxationIterations = input.maxRelaxationIterations ?? 200 + this.MAX_ITERATIONS = this.maxRelaxationIterations + 5 + } + + override _step() { + const overlaps = this.detectOverlaps(this.finalLayout) + + if (overlaps.length === 0) { + this.remainingOverlapCount = 0 + this.solved = true + return + } + + if (this.relaxationIterations >= this.maxRelaxationIterations) { + // Give up — record what's left so the consumer can see we ran out + this.remainingOverlapCount = overlaps.length + this.solved = true + return + } + + // Process the worst overlap first each pass (largest area) + overlaps.sort((a, b) => b.overlapArea - a.overlapArea) + + for (const overlap of overlaps) { + this.separatePair(overlap.chip1, overlap.chip2) + this.resolvedOverlapCount++ + } + + this.relaxationIterations++ + } + + /** + * Detect all overlapping chip pairs in the layout, including the required + * chipGap as part of the bounding box. (A gap-violation counts as an overlap.) + */ + private detectOverlaps(layout: OutputLayout) { + const overlaps: Array<{ + chip1: ChipId + chip2: ChipId + overlapArea: number + }> = [] + const chipIds = Object.keys(layout.chipPlacements) + for (let i = 0; i < chipIds.length; i++) { + for (let j = i + 1; j < chipIds.length; j++) { + const id1 = chipIds[i]! + const id2 = chipIds[j]! + const bounds1 = this.getInflatedBounds(id1, layout) + const bounds2 = this.getInflatedBounds(id2, layout) + if (!bounds1 || !bounds2) continue + const area = this.computeOverlapArea(bounds1, bounds2) + if (area > 0) { + overlaps.push({ chip1: id1, chip2: id2, overlapArea: area }) + } + } + } + return overlaps + } + + /** + * Bounding box of a chip inflated by half the chipGap on every side, so + * "touching" rects (gap = 0) register as an overlap and get separated. + */ + private getInflatedBounds(chipId: ChipId, layout: OutputLayout): AABB | null { + const chip = this.inputProblem.chipMap[chipId] + const placement = layout.chipPlacements[chipId] + if (!chip || !placement) return null + + const inflate = this.chipGap / 2 + const bounds = this.getRotatedAABB(placement, chip.size) + return { + minX: bounds.minX - inflate, + maxX: bounds.maxX + inflate, + minY: bounds.minY - inflate, + maxY: bounds.maxY + inflate, + } + } + + /** + * Axis-aligned bounding box of a rotated rectangle. Matches the convention + * used by LayoutPipelineSolver.checkForOverlaps so detection stays + * consistent across the pipeline. + */ + private getRotatedAABB( + placement: Placement, + size: { x: number; y: number }, + ): AABB { + const halfWidth = size.x / 2 + const halfHeight = size.y / 2 + const rad = (placement.ccwRotationDegrees * Math.PI) / 180 + const cos = Math.abs(Math.cos(rad)) + const sin = Math.abs(Math.sin(rad)) + const rotatedHalfW = halfWidth * cos + halfHeight * sin + const rotatedHalfH = halfWidth * sin + halfHeight * cos + return { + minX: placement.x - rotatedHalfW, + maxX: placement.x + rotatedHalfW, + minY: placement.y - rotatedHalfH, + maxY: placement.y + rotatedHalfH, + } + } + + private computeOverlapArea(a: AABB, b: AABB): number { + if ( + a.maxX <= b.minX || + a.minX >= b.maxX || + a.maxY <= b.minY || + a.minY >= b.maxY + ) { + return 0 + } + const w = Math.min(a.maxX, b.maxX) - Math.max(a.minX, b.minX) + const h = Math.min(a.maxY, b.maxY) - Math.max(a.minY, b.minY) + return w * h + } + + /** + * Push two overlapping chips apart along the minimum-penetration axis. + * Movement is split between the two chips proportional to the inverse of + * their area, so a small passive moves more than a large anchor chip. + */ + private separatePair(id1: ChipId, id2: ChipId) { + const b1 = this.getInflatedBounds(id1, this.finalLayout) + const b2 = this.getInflatedBounds(id2, this.finalLayout) + const p1 = this.finalLayout.chipPlacements[id1] + const p2 = this.finalLayout.chipPlacements[id2] + if (!b1 || !b2 || !p1 || !p2) return + + // Penetration depth on each axis. We want to push them apart along + // the axis of *minimum* penetration so the move is the smallest + // possible nudge that resolves the overlap. + const penX = Math.min(b1.maxX, b2.maxX) - Math.max(b1.minX, b2.minX) + const penY = Math.min(b1.maxY, b2.maxY) - Math.max(b1.minY, b2.minY) + if (penX <= 0 || penY <= 0) return // already separated + + // Add a tiny epsilon so we cross the equality boundary cleanly. + const epsilon = 1e-6 + const c1x = (b1.minX + b1.maxX) / 2 + const c1y = (b1.minY + b1.maxY) / 2 + const c2x = (b2.minX + b2.maxX) / 2 + const c2y = (b2.minY + b2.maxY) / 2 + + // Weight movement by inverse area so the smaller chip moves more. + // (Bigger chips are more likely to be anchors — RP2040, MCUs, etc.) + const area1 = this.areaOf(b1) + const area2 = this.areaOf(b2) + const w1 = area2 / (area1 + area2) + const w2 = area1 / (area1 + area2) + + if (penX < penY) { + // Separate horizontally + const push = penX + epsilon + if (c1x <= c2x) { + p1.x -= push * w1 + p2.x += push * w2 + } else { + p1.x += push * w1 + p2.x -= push * w2 + } + } else { + // Separate vertically + const push = penY + epsilon + if (c1y <= c2y) { + p1.y -= push * w1 + p2.y += push * w2 + } else { + p1.y += push * w1 + p2.y -= push * w2 + } + } + } + + private areaOf(b: AABB): number { + return (b.maxX - b.minX) * (b.maxY - b.minY) + } + + override visualize(): GraphicsObject { + const rects = Object.entries(this.finalLayout.chipPlacements).map( + ([chipId, placement]) => { + const chip = this.inputProblem.chipMap[chipId]! + const aabb = this.getRotatedAABB(placement, chip.size) + return { + center: { x: placement.x, y: placement.y }, + width: aabb.maxX - aabb.minX, + height: aabb.maxY - aabb.minY, + fill: "rgba(80,180,255,0.15)", + stroke: "rgba(80,180,255,0.8)", + label: chipId, + } + }, + ) + return { + lines: [], + points: [], + circles: [], + texts: [], + rects, + } + } +} diff --git a/tests/OverlapResolutionSolver/OverlapResolutionSolver01.test.ts b/tests/OverlapResolutionSolver/OverlapResolutionSolver01.test.ts new file mode 100644 index 0000000..2080123 --- /dev/null +++ b/tests/OverlapResolutionSolver/OverlapResolutionSolver01.test.ts @@ -0,0 +1,175 @@ +import { expect, test } from "bun:test" +import { OverlapResolutionSolver } from "lib/solvers/OverlapResolutionSolver/OverlapResolutionSolver" +import type { InputProblem } from "lib/types/InputProblem" +import type { OutputLayout } from "lib/types/OutputLayout" +import { getExampleCircuitJson } from "../assets/RP2040Circuit" +import { getInputProblemFromCircuitJsonSchematic } from "lib/testing/getInputProblemFromCircuitJsonSchematic" +import { LayoutPipelineSolver } from "lib/solvers/LayoutPipelineSolver/LayoutPipelineSolver" + +const makeProblem = (): InputProblem => ({ + chipMap: { + A: { + chipId: "A", + pins: ["A.1"], + size: { x: 1, y: 1 }, + availableRotations: [0], + }, + B: { + chipId: "B", + pins: ["B.1"], + size: { x: 1, y: 1 }, + availableRotations: [0], + }, + }, + chipPinMap: { + "A.1": { pinId: "A.1", side: "x+", offset: { x: 0.5, y: 0 } }, + "B.1": { pinId: "B.1", side: "x-", offset: { x: -0.5, y: 0 } }, + }, + groupMap: {}, + groupPinMap: {}, + netMap: {}, + pinStrongConnMap: {}, + netConnMap: {}, + chipGap: 0.2, + partitionGap: 0.4, +}) + +test("OverlapResolutionSolver separates two overlapping unit-square chips", () => { + const problem = makeProblem() + const layout: OutputLayout = { + chipPlacements: { + A: { x: 0, y: 0, ccwRotationDegrees: 0 }, + B: { x: 0.3, y: 0, ccwRotationDegrees: 0 }, // 0.7 of horizontal overlap + }, + groupPlacements: {}, + } + const solver = new OverlapResolutionSolver({ + layout, + inputProblem: problem, + }) + solver.solve() + expect(solver.solved).toBe(true) + expect(solver.failed).toBe(false) + expect(solver.remainingOverlapCount).toBe(0) + + // A and B should now be at least chipGap + size apart along x + const out = solver.finalLayout + const dx = Math.abs(out.chipPlacements.B!.x - out.chipPlacements.A!.x) + // Each unit-square chip has half-width 0.5; required separation = 1.0 + chipGap = 1.2 + expect(dx).toBeGreaterThanOrEqual(1.2 - 1e-3) + + // y should stay at 0 because penetration on Y axis was larger than X + expect(out.chipPlacements.A!.y).toBeCloseTo(0, 5) + expect(out.chipPlacements.B!.y).toBeCloseTo(0, 5) +}) + +test("OverlapResolutionSolver is a no-op when layout already has no overlaps", () => { + const problem = makeProblem() + const layout: OutputLayout = { + chipPlacements: { + A: { x: 0, y: 0, ccwRotationDegrees: 0 }, + B: { x: 5, y: 0, ccwRotationDegrees: 0 }, + }, + groupPlacements: {}, + } + const solver = new OverlapResolutionSolver({ + layout, + inputProblem: problem, + }) + solver.solve() + expect(solver.solved).toBe(true) + expect(solver.remainingOverlapCount).toBe(0) + // Positions unchanged + expect(solver.finalLayout.chipPlacements.A!.x).toBe(0) + expect(solver.finalLayout.chipPlacements.B!.x).toBe(5) +}) + +test("OverlapResolutionSolver does not mutate the input layout", () => { + const problem = makeProblem() + const layout: OutputLayout = { + chipPlacements: { + A: { x: 0, y: 0, ccwRotationDegrees: 0 }, + B: { x: 0.3, y: 0, ccwRotationDegrees: 0 }, + }, + groupPlacements: {}, + } + const originalAPlacement = { ...layout.chipPlacements.A! } + const originalBPlacement = { ...layout.chipPlacements.B! } + + const solver = new OverlapResolutionSolver({ + layout, + inputProblem: problem, + }) + solver.solve() + + expect(layout.chipPlacements.A).toEqual(originalAPlacement) + expect(layout.chipPlacements.B).toEqual(originalBPlacement) +}) + +test("OverlapResolutionSolver smaller chip moves more than larger anchor chip", () => { + // A is 10x10 (anchor), B is 1x1 (passive). They overlap; B should move more. + const problem: InputProblem = { + chipMap: { + A: { + chipId: "A", + pins: ["A.1"], + size: { x: 10, y: 10 }, + availableRotations: [0], + }, + B: { + chipId: "B", + pins: ["B.1"], + size: { x: 1, y: 1 }, + availableRotations: [0], + }, + }, + chipPinMap: { + "A.1": { pinId: "A.1", side: "x+", offset: { x: 5, y: 0 } }, + "B.1": { pinId: "B.1", side: "x-", offset: { x: -0.5, y: 0 } }, + }, + groupMap: {}, + groupPinMap: {}, + netMap: {}, + pinStrongConnMap: {}, + netConnMap: {}, + chipGap: 0.2, + partitionGap: 0.4, + } + const layout: OutputLayout = { + chipPlacements: { + A: { x: 0, y: 0, ccwRotationDegrees: 0 }, + B: { x: 4, y: 0, ccwRotationDegrees: 0 }, // overlapping into A's right half + }, + groupPlacements: {}, + } + const solver = new OverlapResolutionSolver({ + layout, + inputProblem: problem, + }) + solver.solve() + expect(solver.solved).toBe(true) + expect(solver.remainingOverlapCount).toBe(0) + + const out = solver.finalLayout + const movedA = Math.abs(out.chipPlacements.A!.x - 0) + const movedB = Math.abs(out.chipPlacements.B!.x - 4) + expect(movedB).toBeGreaterThan(movedA) +}) + +test("LayoutPipelineSolver resolves overlaps in RP2040 circuit", () => { + // Regression check: the RP2040 circuit used to leave 4 chip overlaps in + // its final layout. With OverlapResolutionSolver wired in as the final + // pipeline phase, getOutputLayout() must produce a clean layout. + const circuitJson = getExampleCircuitJson() + const problem = getInputProblemFromCircuitJsonSchematic(circuitJson, { + useReadableIds: true, + }) + const solver = new LayoutPipelineSolver(problem) + solver.solve() + expect(solver.solved).toBe(true) + expect(solver.overlapResolutionSolver?.solved).toBe(true) + expect(solver.overlapResolutionSolver?.remainingOverlapCount).toBe(0) + + const layout = solver.getOutputLayout() + expect(solver.checkForOverlaps(layout).length).toBe(0) +}) From 5846dec73652fb993ad0e0c319c0b5668f98463e Mon Sep 17 00:00:00 2001 From: ElecTream Date: Thu, 28 May 2026 08:54:45 -0700 Subject: [PATCH 2/3] fix(tests): remove non-existent groupMap/groupPinMap from InputProblem test fixtures Resolves type-check failure on CI. --- .../OverlapResolutionSolver/OverlapResolutionSolver01.test.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/OverlapResolutionSolver/OverlapResolutionSolver01.test.ts b/tests/OverlapResolutionSolver/OverlapResolutionSolver01.test.ts index 2080123..9df8ce5 100644 --- a/tests/OverlapResolutionSolver/OverlapResolutionSolver01.test.ts +++ b/tests/OverlapResolutionSolver/OverlapResolutionSolver01.test.ts @@ -25,8 +25,6 @@ const makeProblem = (): InputProblem => ({ "A.1": { pinId: "A.1", side: "x+", offset: { x: 0.5, y: 0 } }, "B.1": { pinId: "B.1", side: "x-", offset: { x: -0.5, y: 0 } }, }, - groupMap: {}, - groupPinMap: {}, netMap: {}, pinStrongConnMap: {}, netConnMap: {}, @@ -127,8 +125,6 @@ test("OverlapResolutionSolver smaller chip moves more than larger anchor chip", "A.1": { pinId: "A.1", side: "x+", offset: { x: 5, y: 0 } }, "B.1": { pinId: "B.1", side: "x-", offset: { x: -0.5, y: 0 } }, }, - groupMap: {}, - groupPinMap: {}, netMap: {}, pinStrongConnMap: {}, netConnMap: {}, From bd5112557411c1ca047850522c43acc75aa8e17d Mon Sep 17 00:00:00 2001 From: ElecTream Date: Thu, 28 May 2026 09:03:15 -0700 Subject: [PATCH 3/3] feat(visualize): show before/after positions in OverlapResolutionSolver Reviewers can now see at a glance which chips moved (green, with a red ghost of the original position and a connecting line) versus chips that stayed put (blue). Makes the impact of the phase obvious in the cosmos debugger. --- .../OverlapResolutionSolver.ts | 65 ++++++++++++++----- 1 file changed, 50 insertions(+), 15 deletions(-) diff --git a/lib/solvers/OverlapResolutionSolver/OverlapResolutionSolver.ts b/lib/solvers/OverlapResolutionSolver/OverlapResolutionSolver.ts index 5ef7739..aa211fc 100644 --- a/lib/solvers/OverlapResolutionSolver/OverlapResolutionSolver.ts +++ b/lib/solvers/OverlapResolutionSolver/OverlapResolutionSolver.ts @@ -239,22 +239,57 @@ export class OverlapResolutionSolver extends BaseSolver { } override visualize(): GraphicsObject { - const rects = Object.entries(this.finalLayout.chipPlacements).map( - ([chipId, placement]) => { - const chip = this.inputProblem.chipMap[chipId]! - const aabb = this.getRotatedAABB(placement, chip.size) - return { - center: { x: placement.x, y: placement.y }, - width: aabb.maxX - aabb.minX, - height: aabb.maxY - aabb.minY, - fill: "rgba(80,180,255,0.15)", - stroke: "rgba(80,180,255,0.8)", - label: chipId, - } - }, - ) + const rects: NonNullable = [] + const lines: NonNullable = [] + + // Render BEFORE position in a muted ghost outline so reviewers can see + // where the upstream packing put each chip, and AFTER position in solid + // color. A line connects the two when the chip moved. + for (const [chipId, after] of Object.entries( + this.finalLayout.chipPlacements, + )) { + const chip = this.inputProblem.chipMap[chipId] + const before = this.inputLayout.chipPlacements[chipId] + if (!chip || !before) continue + + const afterAABB = this.getRotatedAABB(after, chip.size) + const beforeAABB = this.getRotatedAABB(before, chip.size) + + // Ghost of original position (only meaningful if the chip moved) + const moved = + Math.abs(after.x - before.x) > 1e-6 || + Math.abs(after.y - before.y) > 1e-6 + if (moved) { + rects.push({ + center: { x: before.x, y: before.y }, + width: beforeAABB.maxX - beforeAABB.minX, + height: beforeAABB.maxY - beforeAABB.minY, + fill: "rgba(255,100,100,0.05)", + stroke: "rgba(255,100,100,0.4)", + label: `${chipId} (before)`, + }) + lines.push({ + points: [ + { x: before.x, y: before.y }, + { x: after.x, y: after.y }, + ], + strokeColor: "rgba(255,100,100,0.5)", + }) + } + + // Final position + rects.push({ + center: { x: after.x, y: after.y }, + width: afterAABB.maxX - afterAABB.minX, + height: afterAABB.maxY - afterAABB.minY, + fill: moved ? "rgba(80,200,120,0.15)" : "rgba(80,180,255,0.15)", + stroke: moved ? "rgba(80,200,120,0.9)" : "rgba(80,180,255,0.8)", + label: chipId, + }) + } + return { - lines: [], + lines, points: [], circles: [], texts: [],