From cfcaf77c548ba94a6e2b5371ba5877c26469a6cf Mon Sep 17 00:00:00 2001 From: seveibar Date: Tue, 28 Jul 2026 11:15:21 -0700 Subject: [PATCH 1/3] feat: support immutable routes during trace simplification --- .../SameNetViaMergerSolver.ts | 19 +- .../MultiSimplifiedPathSolver.ts | 16 +- .../SingleSimplifiedPathSolver5_Deg45.ts | 40 ++-- .../TraceSimplificationSolver.ts | 5 + .../UselessViaRemovalSolver.ts | 9 +- ...ce-simplification-immutable-routes.test.ts | 194 ++++++++++++++++++ 6 files changed, 254 insertions(+), 29 deletions(-) create mode 100644 tests/features/trace-simplification-immutable-routes.test.ts diff --git a/lib/solvers/SameNetViaMergerSolver/SameNetViaMergerSolver.ts b/lib/solvers/SameNetViaMergerSolver/SameNetViaMergerSolver.ts index a4fb22212..05185d47e 100644 --- a/lib/solvers/SameNetViaMergerSolver/SameNetViaMergerSolver.ts +++ b/lib/solvers/SameNetViaMergerSolver/SameNetViaMergerSolver.ts @@ -14,6 +14,8 @@ import { segmentToBoxMinDistance } from "@tscircuit/math-utils" export interface SameNetViaMergerSolverInput { inputHdRoutes: HighDensityRoute[] + /** Routed copper that participates in collision checks but is never changed. */ + otherHdRoutes?: ReadonlyArray obstacles: Obstacle[] colorMap: Record layerCount: number @@ -37,7 +39,11 @@ const getNetForRoute = ( connMap: ConnectivityMap, route: HighDensityRoute, ): string => { - const net = connMap.idToNetMap[route.connectionName] + const net = + connMap.idToNetMap[route.connectionName] ?? + (route.rootConnectionName + ? connMap.idToNetMap[route.rootConnectionName] + : undefined) if (!net) { throw new Error( `SameNetViaMergerSolver could not find net for route "${route.connectionName}"`, @@ -170,6 +176,13 @@ export class SameNetViaMergerSolver extends BaseSolver { obstacleSHI: ObstacleSpatialHashIndex hdRouteSHI: HighDensityRouteSpatialIndex + private createHdRouteSpatialIndex(): HighDensityRouteSpatialIndex { + return new HighDensityRouteSpatialIndex([ + ...this.mergedViaHdRoutes, + ...(this.input.otherHdRoutes ?? []), + ]) + } + constructor(private input: SameNetViaMergerSolverInput) { super() if (!input.connMap) { @@ -192,7 +205,7 @@ export class SameNetViaMergerSolver extends BaseSolver { "flatbush", this.input.obstacles, ) - this.hdRouteSHI = new HighDensityRouteSpatialIndex(this.inputHdRoutes) + this.hdRouteSHI = this.createHdRouteSpatialIndex() this.vias = [] this.offendingVias = [] this.connMap = input.connMap @@ -444,7 +457,7 @@ export class SameNetViaMergerSolver extends BaseSolver { } } this.rebuildVias() - this.hdRouteSHI = new HighDensityRouteSpatialIndex(this.mergedViaHdRoutes) + this.hdRouteSHI = this.createHdRouteSpatialIndex() this.stats.mergedViaGroups = groups.length this.stats.mergedViaCount = mergedViaCount } diff --git a/lib/solvers/SimplifiedPathSolver/MultiSimplifiedPathSolver.ts b/lib/solvers/SimplifiedPathSolver/MultiSimplifiedPathSolver.ts index dbe2189e6..14139fe2b 100644 --- a/lib/solvers/SimplifiedPathSolver/MultiSimplifiedPathSolver.ts +++ b/lib/solvers/SimplifiedPathSolver/MultiSimplifiedPathSolver.ts @@ -20,6 +20,7 @@ export class MultiSimplifiedPathSolver extends BaseSolver { activeSubSolver: SingleSimplifiedPathSolver | null = null unsimplifiedHdRoutes: HighDensityIntraNodeRoute[] + otherHdRoutes: ReadonlyArray obstacles: Obstacle[] connMap: ConnectivityMap colorMap: Record @@ -28,6 +29,8 @@ export class MultiSimplifiedPathSolver extends BaseSolver { constructor(params: { unsimplifiedHdRoutes: HighDensityIntraNodeRoute[] + /** Routed copper that participates in collision checks but is never changed. */ + otherHdRoutes?: ReadonlyArray obstacles: Obstacle[] connMap?: ConnectivityMap colorMap?: Record @@ -38,11 +41,12 @@ export class MultiSimplifiedPathSolver extends BaseSolver { this.MAX_ITERATIONS = 100e6 this.unsimplifiedHdRoutes = params.unsimplifiedHdRoutes + this.otherHdRoutes = params.otherHdRoutes ?? [] const inferredLayerCount = Math.max( 2, - ...params.unsimplifiedHdRoutes.flatMap((r) => - r.route.map((p) => p.z + 1), + ...[...params.unsimplifiedHdRoutes, ...this.otherHdRoutes].flatMap( + (r) => r.route.map((p) => p.z + 1), ), ) || 2 this.obstacles = createObjectsWithZLayers( @@ -68,9 +72,11 @@ export class MultiSimplifiedPathSolver extends BaseSolver { this.activeSubSolver = new SingleSimplifiedPathSolver5({ inputRoute: hdRoute, - otherHdRoutes: this.unsimplifiedHdRoutes - .slice(this.currentUnsimplifiedHdRouteIndex + 1) - .concat(this.simplifiedHdRoutes), + otherHdRoutes: this.otherHdRoutes.concat( + this.unsimplifiedHdRoutes + .slice(this.currentUnsimplifiedHdRouteIndex + 1) + .concat(this.simplifiedHdRoutes), + ), obstacles: this.obstacles, connMap: this.connMap, colorMap: this.colorMap, diff --git a/lib/solvers/SimplifiedPathSolver/SingleSimplifiedPathSolver5_Deg45.ts b/lib/solvers/SimplifiedPathSolver/SingleSimplifiedPathSolver5_Deg45.ts index 854f5bf32..56811cc1e 100644 --- a/lib/solvers/SimplifiedPathSolver/SingleSimplifiedPathSolver5_Deg45.ts +++ b/lib/solvers/SimplifiedPathSolver/SingleSimplifiedPathSolver5_Deg45.ts @@ -69,6 +69,25 @@ export class SingleSimplifiedPathSolver5 extends SingleSimplifiedPathSolver { TAIL_JUMP_RATIO: number = 0.8 + private isSameNetRoute(otherRoute: HighDensityIntraNodeRoute): boolean { + const inputRouteIds = [ + this.inputRoute.connectionName, + this.inputRoute.rootConnectionName, + ].filter((id): id is string => id !== undefined) + const otherRouteIds = [ + otherRoute.connectionName, + otherRoute.rootConnectionName, + ].filter((id): id is string => id !== undefined) + + return inputRouteIds.some((inputRouteId) => + otherRouteIds.some( + (otherRouteId) => + inputRouteId === otherRouteId || + this.connMap.areIdsConnected(inputRouteId, otherRouteId), + ), + ) + } + constructor( params: ConstructorParameters[0], ) { @@ -130,12 +149,7 @@ export class SingleSimplifiedPathSolver5 extends SingleSimplifiedPathSolver { this.filteredObstaclePathSegments = this.otherHdRoutes.flatMap( (hdRoute) => { - if ( - this.connMap.areIdsConnected( - this.inputRoute.connectionName, - hdRoute.connectionName, - ) - ) { + if (this.isSameNetRoute(hdRoute)) { return [] } @@ -158,12 +172,7 @@ export class SingleSimplifiedPathSolver5 extends SingleSimplifiedPathSolver { this.segmentTree = new SegmentTree(this.filteredObstaclePathSegments) this.filteredVias = this.otherHdRoutes.flatMap((hdRoute) => { - if ( - this.connMap.areIdsConnected( - this.inputRoute.connectionName, - hdRoute.connectionName, - ) - ) { + if (this.isSameNetRoute(hdRoute)) { return [] } @@ -256,12 +265,7 @@ export class SingleSimplifiedPathSolver5 extends SingleSimplifiedPathSolver { // Collect jumper pads from other routes as obstacles this.filteredJumperPads = this.otherHdRoutes.flatMap((hdRoute) => { - if ( - this.connMap.areIdsConnected( - this.inputRoute.connectionName, - hdRoute.connectionName, - ) - ) { + if (this.isSameNetRoute(hdRoute)) { return [] } diff --git a/lib/solvers/TraceSimplificationSolver/TraceSimplificationSolver.ts b/lib/solvers/TraceSimplificationSolver/TraceSimplificationSolver.ts index 80bede456..8c5d45f88 100644 --- a/lib/solvers/TraceSimplificationSolver/TraceSimplificationSolver.ts +++ b/lib/solvers/TraceSimplificationSolver/TraceSimplificationSolver.ts @@ -71,6 +71,7 @@ export class TraceSimplificationSolver extends BaseSolver { * - defaultViaDiameter: Default diameter for vias * - layerCount: Number of routing layers * - minTraceToPadEdgeClearance: Minimum trace-edge clearance to pads/vias + * - otherHdRoutes: Immutable routed traces to avoid while simplifying * - iterations: Number of complete simplification iterations (default: 2) */ constructor( @@ -83,6 +84,7 @@ export class TraceSimplificationSolver extends BaseSolver { readonly defaultViaDiameter: number readonly layerCount: number readonly minTraceToPadEdgeClearance?: number + readonly otherHdRoutes?: ReadonlyArray }, ) { super() @@ -229,6 +231,7 @@ export class TraceSimplificationSolver extends BaseSolver { case "via_removal": this.activeSubSolver = new UselessViaRemovalSolver({ unsimplifiedHdRoutes: this.hdRoutes, + otherHdRoutes: [...(this.simplificationConfig.otherHdRoutes ?? [])], obstacles: [...this.simplificationConfig.obstacles], colorMap: { ...this.simplificationConfig.colorMap }, layerCount: this.simplificationConfig.layerCount, @@ -250,6 +253,7 @@ export class TraceSimplificationSolver extends BaseSolver { case "via_merging": this.activeSubSolver = new SameNetViaMergerSolver({ inputHdRoutes: this.hdRoutes, + otherHdRoutes: [...(this.simplificationConfig.otherHdRoutes ?? [])], obstacles: [...this.simplificationConfig.obstacles], colorMap: { ...this.simplificationConfig.colorMap }, layerCount: this.simplificationConfig.layerCount, @@ -265,6 +269,7 @@ export class TraceSimplificationSolver extends BaseSolver { case "path_simplification": this.activeSubSolver = new MultiSimplifiedPathSolver({ unsimplifiedHdRoutes: this.hdRoutes, + otherHdRoutes: [...(this.simplificationConfig.otherHdRoutes ?? [])], obstacles: [...this.simplificationConfig.obstacles], connMap: this.simplificationConfig.connMap, colorMap: { ...this.simplificationConfig.colorMap }, diff --git a/lib/solvers/UselessViaRemovalSolver/UselessViaRemovalSolver.ts b/lib/solvers/UselessViaRemovalSolver/UselessViaRemovalSolver.ts index 70c0a7ae0..dc2893fff 100644 --- a/lib/solvers/UselessViaRemovalSolver/UselessViaRemovalSolver.ts +++ b/lib/solvers/UselessViaRemovalSolver/UselessViaRemovalSolver.ts @@ -13,6 +13,8 @@ import type { ConnectivityMap } from "circuit-json-to-connectivity-map" export interface UselessViaRemovalSolverInput { unsimplifiedHdRoutes: HighDensityRoute[] + /** Routed copper that participates in collision checks but is never changed. */ + otherHdRoutes?: ReadonlyArray obstacles: Obstacle[] colorMap: Record layerCount: number @@ -52,9 +54,10 @@ export class UselessViaRemovalSolver extends BaseSolver { "flatbush", this.input.obstacles, ) - this.hdRouteSHI = new HighDensityRouteSpatialIndex( - this.unsimplifiedHdRoutes, - ) + this.hdRouteSHI = new HighDensityRouteSpatialIndex([ + ...this.unsimplifiedHdRoutes, + ...(input.otherHdRoutes ?? []), + ]) } _step() { diff --git a/tests/features/trace-simplification-immutable-routes.test.ts b/tests/features/trace-simplification-immutable-routes.test.ts new file mode 100644 index 000000000..bd71453fc --- /dev/null +++ b/tests/features/trace-simplification-immutable-routes.test.ts @@ -0,0 +1,194 @@ +import { expect, test } from "bun:test" +import { ConnectivityMap } from "circuit-json-to-connectivity-map" +import { SameNetViaMergerSolver } from "lib/solvers/SameNetViaMergerSolver/SameNetViaMergerSolver" +import { TraceSimplificationSolver } from "lib/solvers/TraceSimplificationSolver/TraceSimplificationSolver" +import { UselessViaRemovalSolver } from "lib/solvers/UselessViaRemovalSolver/UselessViaRemovalSolver" +import type { HighDensityRoute } from "lib/types/high-density-types" +import { minimumDistanceBetweenSegments } from "lib/utils/minimumDistanceBetweenSegments" + +const editableRoute: HighDensityRoute = { + connectionName: "editable", + traceThickness: 0.15, + viaDiameter: 0.3, + route: [ + { x: -2, y: 0, z: 0 }, + { x: -1, y: 1.5, z: 0 }, + { x: 1, y: 1.5, z: 0 }, + { x: 2, y: 0, z: 0 }, + ], + vias: [], +} + +const immutableRoute: HighDensityRoute = { + connectionName: "fixed_piece", + rootConnectionName: "fixed", + traceThickness: 0.15, + viaDiameter: 0.3, + route: [ + { x: 0, y: -0.5, z: 0 }, + { x: 0, y: 0.5, z: 0 }, + ], + vias: [], +} + +const solve = (otherHdRoutes: HighDensityRoute[] = []) => { + const connMap = new ConnectivityMap({}) + connMap.addConnections([["fixed", "fixed_piece"]]) + const solver = new TraceSimplificationSolver({ + hdRoutes: [structuredClone(editableRoute)], + otherHdRoutes, + obstacles: [], + connMap, + colorMap: {}, + defaultViaDiameter: 0.3, + layerCount: 2, + }) + solver.solve() + expect(solver.failed).toBe(false) + return solver.simplifiedHdRoutes +} + +const getMinimumRouteDistance = ( + first: HighDensityRoute, + second: HighDensityRoute, +) => { + let minimumDistance = Number.POSITIVE_INFINITY + for (let firstIndex = 1; firstIndex < first.route.length; firstIndex++) { + const firstStart = first.route[firstIndex - 1]! + const firstEnd = first.route[firstIndex]! + if (firstStart.z !== firstEnd.z) continue + + for ( + let secondIndex = 1; + secondIndex < second.route.length; + secondIndex++ + ) { + const secondStart = second.route[secondIndex - 1]! + const secondEnd = second.route[secondIndex]! + if (secondStart.z !== secondEnd.z || firstStart.z !== secondStart.z) { + continue + } + minimumDistance = Math.min( + minimumDistance, + minimumDistanceBetweenSegments( + firstStart, + firstEnd, + secondStart, + secondEnd, + ), + ) + } + } + return minimumDistance +} + +test("trace simplification avoids immutable routed traces without emitting or mutating them", () => { + const immutableSnapshot = structuredClone(immutableRoute) + + const routesWithoutFixedCopper = solve() + expect(routesWithoutFixedCopper).toHaveLength(1) + expect( + getMinimumRouteDistance(routesWithoutFixedCopper[0]!, immutableRoute), + ).toBe(0) + + const routesWithFixedCopper = solve([immutableRoute]) + expect(routesWithFixedCopper).toHaveLength(1) + expect(routesWithFixedCopper[0]!.connectionName).toBe("editable") + expect( + getMinimumRouteDistance(routesWithFixedCopper[0]!, immutableRoute), + ).toBeGreaterThanOrEqual(0.25) + expect(immutableRoute).toEqual(immutableSnapshot) +}) + +test("via removal keeps a layer detour that crosses an immutable route", () => { + const routeWithLayerDetour: HighDensityRoute = { + connectionName: "editable", + traceThickness: 0.15, + viaDiameter: 0.3, + route: [ + { x: -1, y: 0, z: 0 }, + { x: -0.5, y: 0, z: 0 }, + { x: -0.5, y: 0, z: 1 }, + { x: 0.5, y: 0, z: 1 }, + { x: 0.5, y: 0, z: 0 }, + { x: 1, y: 0, z: 0 }, + ], + vias: [ + { x: -0.5, y: 0 }, + { x: 0.5, y: 0 }, + ], + } + const connMap = new ConnectivityMap({}) + connMap.addConnections([["fixed", "fixed_piece"]]) + const runViaRemoval = (otherHdRoutes: HighDensityRoute[] = []) => { + const solver = new UselessViaRemovalSolver({ + unsimplifiedHdRoutes: [structuredClone(routeWithLayerDetour)], + otherHdRoutes, + obstacles: [], + colorMap: {}, + layerCount: 2, + connMap, + }) + solver.solve() + expect(solver.failed).toBe(false) + return solver.getOptimizedHdRoutes()! + } + + expect(runViaRemoval()[0]!.vias).toHaveLength(0) + const immutableSnapshot = structuredClone(immutableRoute) + const guardedRoutes = runViaRemoval([immutableRoute]) + expect(guardedRoutes).toHaveLength(1) + expect(guardedRoutes[0]!.vias).toHaveLength(2) + expect(immutableRoute).toEqual(immutableSnapshot) +}) + +test("same-net via merging collision-checks immutable routes without emitting them", () => { + const makeViaRoute = ( + connectionName: string, + viaX: number, + ): HighDensityRoute => ({ + connectionName, + traceThickness: 0.15, + viaDiameter: 0.3, + route: [ + { x: viaX - 0.25, y: 0, z: 0 }, + { x: viaX, y: 0, z: 0 }, + { x: viaX, y: 0, z: 1 }, + { x: viaX - 0.25, y: 0, z: 1 }, + ], + vias: [{ x: viaX, y: 0 }], + }) + const inputRoutes = [ + makeViaRoute("editable_a", -0.25), + makeViaRoute("editable_b", 0.25), + ] + const connMap = new ConnectivityMap({}) + connMap.addConnections([ + ["editable_a", "editable_b"], + ["fixed", "fixed_piece"], + ]) + const runViaMerge = (otherHdRoutes: HighDensityRoute[] = []) => { + const solver = new SameNetViaMergerSolver({ + inputHdRoutes: structuredClone(inputRoutes), + otherHdRoutes, + obstacles: [], + colorMap: {}, + layerCount: 2, + connMap, + }) + solver.solve() + expect(solver.failed).toBe(false) + return solver.getMergedViaHdRoutes()! + } + const countViaLocations = (routes: HighDensityRoute[]) => + new Set( + routes.flatMap((route) => route.vias.map((via) => `${via.x}:${via.y}`)), + ).size + + expect(countViaLocations(runViaMerge())).toBe(1) + const immutableSnapshot = structuredClone(immutableRoute) + const guardedRoutes = runViaMerge([immutableRoute]) + expect(guardedRoutes).toHaveLength(2) + expect(countViaLocations(guardedRoutes)).toBe(2) + expect(immutableRoute).toEqual(immutableSnapshot) +}) From f61b40901f41c8af6bf825c4f3eb5700efc66df4 Mon Sep 17 00:00:00 2001 From: seveibar Date: Tue, 28 Jul 2026 11:38:06 -0700 Subject: [PATCH 2/3] test: snapshot immutable trace simplification --- .../TraceSimplificationSolver.ts | 32 ++++++++++++++ ...able-routes-immutable-routed-peer.snap.svg | 44 +++++++++++++++++++ ...ce-simplification-immutable-routes.test.ts | 20 ++++++++- 3 files changed, 94 insertions(+), 2 deletions(-) create mode 100644 tests/features/__snapshots__/trace-simplification-immutable-routes-immutable-routed-peer.snap.svg diff --git a/lib/solvers/TraceSimplificationSolver/TraceSimplificationSolver.ts b/lib/solvers/TraceSimplificationSolver/TraceSimplificationSolver.ts index 8c5d45f88..e24853f3f 100644 --- a/lib/solvers/TraceSimplificationSolver/TraceSimplificationSolver.ts +++ b/lib/solvers/TraceSimplificationSolver/TraceSimplificationSolver.ts @@ -332,6 +332,38 @@ export class TraceSimplificationSolver extends BaseSolver { }) } + // Draw immutable routed copper as subdued, dashed layer-colored peers. + for (const route of this.simplificationConfig.otherHdRoutes ?? []) { + for (let i = 0; i < route.route.length - 1; i++) { + const current = route.route[i] + const next = route.route[i + 1] + if (current.z !== next.z) continue + + visualization.lines.push({ + points: [ + { x: current.x, y: current.y }, + { x: next.x, y: next.y }, + ], + strokeColor: + current.z === 0 + ? "rgba(160, 32, 32, 0.55)" + : "rgba(32, 32, 160, 0.55)", + strokeWidth: route.traceThickness, + strokeDash: [0.08, 0.08], + label: `${route.connectionName} immutable (z=${current.z})`, + }) + } + + for (const via of route.vias) { + visualization.circles.push({ + center: { x: via.x, y: via.y }, + radius: route.viaDiameter / 2, + fill: "rgba(96, 96, 96, 0.45)", + label: `${route.connectionName} immutable via`, + }) + } + } + // Draw output routes and vias for (const route of this.hdRoutes) { if (route.route.length === 0) continue diff --git a/tests/features/__snapshots__/trace-simplification-immutable-routes-immutable-routed-peer.snap.svg b/tests/features/__snapshots__/trace-simplification-immutable-routes-immutable-routed-peer.snap.svg new file mode 100644 index 000000000..84df66dd1 --- /dev/null +++ b/tests/features/__snapshots__/trace-simplification-immutable-routes-immutable-routed-peer.snap.svg @@ -0,0 +1,44 @@ + diff --git a/tests/features/trace-simplification-immutable-routes.test.ts b/tests/features/trace-simplification-immutable-routes.test.ts index bd71453fc..1fe2e69c1 100644 --- a/tests/features/trace-simplification-immutable-routes.test.ts +++ b/tests/features/trace-simplification-immutable-routes.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test" import { ConnectivityMap } from "circuit-json-to-connectivity-map" +import { getSvgFromGraphicsObject } from "graphics-debug" import { SameNetViaMergerSolver } from "lib/solvers/SameNetViaMergerSolver/SameNetViaMergerSolver" import { TraceSimplificationSolver } from "lib/solvers/TraceSimplificationSolver/TraceSimplificationSolver" import { UselessViaRemovalSolver } from "lib/solvers/UselessViaRemovalSolver/UselessViaRemovalSolver" @@ -31,10 +32,10 @@ const immutableRoute: HighDensityRoute = { vias: [], } -const solve = (otherHdRoutes: HighDensityRoute[] = []) => { +const createTraceSimplifier = (otherHdRoutes: HighDensityRoute[] = []) => { const connMap = new ConnectivityMap({}) connMap.addConnections([["fixed", "fixed_piece"]]) - const solver = new TraceSimplificationSolver({ + return new TraceSimplificationSolver({ hdRoutes: [structuredClone(editableRoute)], otherHdRoutes, obstacles: [], @@ -43,6 +44,10 @@ const solve = (otherHdRoutes: HighDensityRoute[] = []) => { defaultViaDiameter: 0.3, layerCount: 2, }) +} + +const solve = (otherHdRoutes: HighDensityRoute[] = []) => { + const solver = createTraceSimplifier(otherHdRoutes) solver.solve() expect(solver.failed).toBe(false) return solver.simplifiedHdRoutes @@ -100,6 +105,17 @@ test("trace simplification avoids immutable routed traces without emitting or mu expect(immutableRoute).toEqual(immutableSnapshot) }) +test("trace simplification visualizes immutable routed peers", () => { + const solver = createTraceSimplifier([immutableRoute]) + solver.solve() + + expect(solver.failed).toBe(false) + expect(getSvgFromGraphicsObject(solver.visualize())).toMatchSvgSnapshot( + import.meta.path, + { svgName: "immutable-routed-peer" }, + ) +}) + test("via removal keeps a layer detour that crosses an immutable route", () => { const routeWithLayerDetour: HighDensityRoute = { connectionName: "editable", From 858a4c74e8840770cd17adf8c3c12c0c1c4c84ac Mon Sep 17 00:00:00 2001 From: seveibar Date: Tue, 28 Jul 2026 11:46:43 -0700 Subject: [PATCH 3/3] test: clarify immutable trace simplification snapshot --- ...able-routes-immutable-routed-peer.snap.svg | 60 +++++- ...ce-simplification-immutable-routes.test.ts | 184 +++++++++++++++++- 2 files changed, 231 insertions(+), 13 deletions(-) diff --git a/tests/features/__snapshots__/trace-simplification-immutable-routes-immutable-routed-peer.snap.svg b/tests/features/__snapshots__/trace-simplification-immutable-routes-immutable-routed-peer.snap.svg index 84df66dd1..f1ab4ea90 100644 --- a/tests/features/__snapshots__/trace-simplification-immutable-routes-immutable-routed-peer.snap.svg +++ b/tests/features/__snapshots__/trace-simplification-immutable-routes-immutable-routed-peer.snap.svg @@ -1,4 +1,6 @@ - + ]]> + + + AFTER • SIMPLIFIED AROUND FIXED COPPER5 green points remain (4 removed); faint orange = input.Red direct shortcut was rejected; gray copper is unchanged. + + diff --git a/tests/features/trace-simplification-immutable-routes.test.ts b/tests/features/trace-simplification-immutable-routes.test.ts index 1fe2e69c1..d7febd418 100644 --- a/tests/features/trace-simplification-immutable-routes.test.ts +++ b/tests/features/trace-simplification-immutable-routes.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "bun:test" import { ConnectivityMap } from "circuit-json-to-connectivity-map" -import { getSvgFromGraphicsObject } from "graphics-debug" +import { getSvgFromGraphicsObject, type GraphicsObject } from "graphics-debug" +import { stackSvgsHorizontally } from "stack-svgs" import { SameNetViaMergerSolver } from "lib/solvers/SameNetViaMergerSolver/SameNetViaMergerSolver" import { TraceSimplificationSolver } from "lib/solvers/TraceSimplificationSolver/TraceSimplificationSolver" import { UselessViaRemovalSolver } from "lib/solvers/UselessViaRemovalSolver/UselessViaRemovalSolver" @@ -13,8 +14,13 @@ const editableRoute: HighDensityRoute = { viaDiameter: 0.3, route: [ { x: -2, y: 0, z: 0 }, - { x: -1, y: 1.5, z: 0 }, - { x: 1, y: 1.5, z: 0 }, + { x: -1.5, y: 0.2, z: 0 }, + { x: -1.2, y: 1.2, z: 0 }, + { x: -0.6, y: 1.6, z: 0 }, + { x: 0, y: 1.8, z: 0 }, + { x: 0.6, y: 1.6, z: 0 }, + { x: 1.2, y: 1.2, z: 0 }, + { x: 1.5, y: 0.2, z: 0 }, { x: 2, y: 0, z: 0 }, ], vias: [], @@ -26,8 +32,8 @@ const immutableRoute: HighDensityRoute = { traceThickness: 0.15, viaDiameter: 0.3, route: [ - { x: 0, y: -0.5, z: 0 }, - { x: 0, y: 0.5, z: 0 }, + { x: 0, y: -0.65, z: 0 }, + { x: 0, y: 0.65, z: 0 }, ], vias: [], } @@ -87,6 +93,74 @@ const getMinimumRouteDistance = ( return minimumDistance } +const getRouteGraphics = ({ + route, + strokeColor, + strokeWidth, + strokeDash, + pointFill, +}: { + route: HighDensityRoute + strokeColor: string + strokeWidth: number + strokeDash?: number[] + pointFill?: string +}): GraphicsObject => ({ + lines: route.route.slice(1).flatMap((point, index) => { + const previousPoint = route.route[index]! + if (previousPoint.z !== point.z) return [] + return [ + { + points: [previousPoint, point], + strokeColor, + strokeWidth, + strokeDash, + }, + ] + }), + circles: pointFill + ? route.route.map((point) => ({ + center: point, + radius: 0.055, + fill: pointFill, + stroke: strokeColor, + })) + : [], +}) + +const mergeGraphics = ( + ...graphicsObjects: GraphicsObject[] +): GraphicsObject => ({ + coordinateSystem: "cartesian", + lines: graphicsObjects.flatMap((graphics) => graphics.lines ?? []), + circles: graphicsObjects.flatMap((graphics) => graphics.circles ?? []), +}) + +const addPanelHeader = ({ + svg, + title, + details, +}: { + svg: string + title: string + details: [string, string] +}) => { + const headerHeight = 76 + const bodyStart = svg.indexOf(">") + 1 + const bodyEnd = svg.lastIndexOf("") + const width = Number(svg.match(/\bwidth="([^"]+)"/)?.[1] ?? 640) + const height = Number(svg.match(/\bheight="([^"]+)"/)?.[1] ?? 420) + + return `${title}${details[0]}${details[1]}${svg.slice( + bodyStart, + bodyEnd, + )}` +} + test("trace simplification avoids immutable routed traces without emitting or mutating them", () => { const immutableSnapshot = structuredClone(immutableRoute) @@ -110,10 +184,104 @@ test("trace simplification visualizes immutable routed peers", () => { solver.solve() expect(solver.failed).toBe(false) - expect(getSvgFromGraphicsObject(solver.visualize())).toMatchSvgSnapshot( - import.meta.path, - { svgName: "immutable-routed-peer" }, + const simplifiedRoute = solver.simplifiedHdRoutes[0]! + expect(editableRoute.route).toHaveLength(9) + expect(simplifiedRoute.route).toHaveLength(5) + + const immutableGraphics = getRouteGraphics({ + route: immutableRoute, + strokeColor: "#3f3f46", + strokeWidth: 0.16, + strokeDash: [0.06, 0.06], + pointFill: "#d4d4d8", + }) + const inputGraphics = mergeGraphics( + getRouteGraphics({ + route: editableRoute, + strokeColor: "#d97706", + strokeWidth: 0.12, + pointFill: "#ffedd5", + }), + immutableGraphics, + ) + const outputGraphics = mergeGraphics( + getRouteGraphics({ + route: editableRoute, + strokeColor: "rgba(217, 119, 6, 0.28)", + strokeWidth: 0.06, + strokeDash: [0.05, 0.05], + }), + { + lines: [ + { + points: [ + { x: -2, y: 0 }, + { x: 2, y: 0 }, + ], + strokeColor: "#dc2626", + strokeWidth: 0.045, + strokeDash: [0.08, 0.08], + }, + { + points: [ + { x: -0.11, y: -0.11 }, + { x: 0.11, y: 0.11 }, + ], + strokeColor: "#dc2626", + strokeWidth: 0.055, + }, + { + points: [ + { x: -0.11, y: 0.11 }, + { x: 0.11, y: -0.11 }, + ], + strokeColor: "#dc2626", + strokeWidth: 0.055, + }, + ], + }, + getRouteGraphics({ + route: simplifiedRoute, + strokeColor: "#16803c", + strokeWidth: 0.14, + pointFill: "#dcfce7", + }), + immutableGraphics, ) + + const renderPanel = (graphics: GraphicsObject) => + getSvgFromGraphicsObject(graphics, { + backgroundColor: "white", + svgWidth: 560, + svgHeight: 420, + hideInlineLabels: true, + }) + + expect( + stackSvgsHorizontally( + [ + addPanelHeader({ + svg: renderPanel(inputGraphics), + title: "BEFORE • UNSIMPLIFIED EDITABLE ROUTE", + details: [ + "9 orange points are eligible for simplification.", + "Gray dashed copper is pre-routed and read-only.", + ], + }), + addPanelHeader({ + svg: renderPanel(outputGraphics), + title: "AFTER • SIMPLIFIED AROUND FIXED COPPER", + details: [ + "5 green points remain (4 removed); faint orange = input.", + "Red direct shortcut was rejected; gray copper is unchanged.", + ], + }), + ], + { gap: 12, normalizeSize: false }, + ), + ).toMatchSvgSnapshot(import.meta.path, { + svgName: "immutable-routed-peer", + }) }) test("via removal keeps a layer detour that crosses an immutable route", () => {