diff --git a/lib/HighDensitySolverA08/A08_BreakoutSolver.ts b/lib/HighDensitySolverA08/A08_BreakoutSolver.ts index 3bd6ac1..681c616 100644 --- a/lib/HighDensitySolverA08/A08_BreakoutSolver.ts +++ b/lib/HighDensitySolverA08/A08_BreakoutSolver.ts @@ -108,8 +108,10 @@ type ForceIterationSnapshot = { snapshots: MidpointForceSnapshot[] } +type ShrinkAmountBySide = Partial> + type ShrinkRectResult = - | { ok: true; rect: RectBounds } + | { ok: true; rect: RectBounds; appliedShrinkBySide: ShrinkAmountBySide } | { ok: false; reason: "unchanged" | "collapsed" } const BREAKOUT_MIDPOINT_INDEX = 1 @@ -133,6 +135,7 @@ export interface A08BreakoutSolverProps { initialRectMarginMm?: number innerRectMarginMm?: number rectShrinkStepMm?: number + maxShrinkMargin?: number breakoutTraceMarginMm?: number breakoutBoundaryMarginMm?: number breakoutSegmentCount?: number @@ -364,6 +367,7 @@ export class HighDensitySolverA08BreakoutSolver extends BaseSolver { effort: number initialRectMarginMm: number rectShrinkStepMm: number + maxShrinkMargin?: number breakoutTraceMarginMm: number breakoutBoundaryMarginMm: number breakoutSegmentCount: number @@ -388,12 +392,26 @@ export class HighDensitySolverA08BreakoutSolver extends BaseSolver { private anchorsBySide = new Map() private pendingShrinkSides: Side[] = [] private nextForceSideCursor = 0 + private shrinkMarginBySide: Record = { + left: 0, + right: 0, + bottom: 0, + top: 0, + } constructor(props: A08BreakoutSolverProps) { super() const breakoutTraceMarginMm = props.breakoutTraceMarginMm ?? defaultA08Params.breakoutTraceMarginMm - this.constructorProps = props + const maxShrinkMargin = + props.maxShrinkMargin === undefined || + !Number.isFinite(props.maxShrinkMargin) + ? defaultA08Params.maxShrinkMargin + : Math.max(0, props.maxShrinkMargin) + this.constructorProps = { + ...props, + maxShrinkMargin, + } this.nodeWithPortPoints = props.nodeWithPortPoints this.cellSizeMm = props.cellSizeMm ?? defaultA08Params.cellSizeMm this.maxCellCount = props.maxCellCount @@ -407,6 +425,7 @@ export class HighDensitySolverA08BreakoutSolver extends BaseSolver { defaultA08Params.initialRectMarginMm this.rectShrinkStepMm = props.rectShrinkStepMm ?? defaultA08Params.rectShrinkStepMm + this.maxShrinkMargin = maxShrinkMargin this.breakoutTraceMarginMm = breakoutTraceMarginMm this.breakoutBoundaryMarginMm = getDefaultA08BreakoutBoundaryMarginMm(props) // The breakout path is implemented as a single midpoint, so this stays fixed. @@ -452,6 +471,12 @@ export class HighDensitySolverA08BreakoutSolver extends BaseSolver { this.iterationsAtCurrentRect = 0 this.lastForceIteration = null this.nextForceSideCursor = 0 + this.shrinkMarginBySide = { + left: 0, + right: 0, + bottom: 0, + top: 0, + } this.spreadAssignments = [] this.breakoutRoutes = [] this.innerNodeWithPortPoints = null @@ -1395,9 +1420,29 @@ export class HighDensitySolverA08BreakoutSolver extends BaseSolver { ) } + private getShrinkAmountsBySide(sides: Side[]): ShrinkAmountBySide { + const shrinkBySide: ShrinkAmountBySide = {} + + for (const side of sides) { + const remainingShrinkMargin = + this.maxShrinkMargin === undefined + ? this.rectShrinkStepMm + : Math.max(0, this.maxShrinkMargin - this.shrinkMarginBySide[side]) + const shrinkAmount = Math.min( + this.rectShrinkStepMm, + remainingShrinkMargin, + ) + if (shrinkAmount > EPSILON) { + shrinkBySide[side] = shrinkAmount + } + } + + return shrinkBySide + } + private shrinkRectBySides( innerRect: RectBounds, - sides: Side[], + shrinkBySide: ShrinkAmountBySide, ): ShrinkRectResult { const minDimension = Math.max( this.cellSizeMm, @@ -1409,43 +1454,45 @@ export class HighDensitySolverA08BreakoutSolver extends BaseSolver { minY: innerRect.minY, maxY: innerRect.maxY, } + const appliedShrinkBySide: ShrinkAmountBySide = {} let changed = false - for (const side of sides) { + for (const side of SIDE_ORDER) { + const shrinkAmount = shrinkBySide[side] ?? 0 + if (shrinkAmount <= EPSILON) continue + switch (side) { case "left": if ( - nextBounds.maxX - (nextBounds.minX + this.rectShrinkStepMm) > + nextBounds.maxX - (nextBounds.minX + shrinkAmount) > minDimension ) { - nextBounds.minX += this.rectShrinkStepMm + nextBounds.minX += shrinkAmount + appliedShrinkBySide.left = shrinkAmount changed = true } break case "right": - if ( - nextBounds.maxX - this.rectShrinkStepMm - nextBounds.minX > - minDimension - ) { - nextBounds.maxX -= this.rectShrinkStepMm + if (nextBounds.maxX - shrinkAmount - nextBounds.minX > minDimension) { + nextBounds.maxX -= shrinkAmount + appliedShrinkBySide.right = shrinkAmount changed = true } break case "bottom": if ( - nextBounds.maxY - (nextBounds.minY + this.rectShrinkStepMm) > + nextBounds.maxY - (nextBounds.minY + shrinkAmount) > minDimension ) { - nextBounds.minY += this.rectShrinkStepMm + nextBounds.minY += shrinkAmount + appliedShrinkBySide.bottom = shrinkAmount changed = true } break case "top": - if ( - nextBounds.maxY - this.rectShrinkStepMm - nextBounds.minY > - minDimension - ) { - nextBounds.maxY -= this.rectShrinkStepMm + if (nextBounds.maxY - shrinkAmount - nextBounds.minY > minDimension) { + nextBounds.maxY -= shrinkAmount + appliedShrinkBySide.top = shrinkAmount changed = true } break @@ -1464,7 +1511,7 @@ export class HighDensitySolverA08BreakoutSolver extends BaseSolver { return { ok: false, reason: "collapsed" } } - return { ok: true, rect: nextRect } + return { ok: true, rect: nextRect, appliedShrinkBySide } } private applyPendingShrink() { @@ -1474,13 +1521,19 @@ export class HighDensitySolverA08BreakoutSolver extends BaseSolver { return false } - const shrinkResult = this.shrinkRectBySides( - this.innerRect, - this.pendingShrinkSides, - ) + const shrinkBySide = this.getShrinkAmountsBySide(this.pendingShrinkSides) this.pendingShrinkSides = [] + if (Object.keys(shrinkBySide).length === 0) { + return this.acceptCurrentInnerRectForA01() + } + + const shrinkResult = this.shrinkRectBySides(this.innerRect, shrinkBySide) + if (!shrinkResult.ok) { + if (this.maxShrinkMargin !== undefined) { + return this.acceptCurrentInnerRectForA01() + } this.error = shrinkResult.reason === "collapsed" ? "A08_BreakoutSolver inner rect collapsed during shrink" @@ -1489,11 +1542,22 @@ export class HighDensitySolverA08BreakoutSolver extends BaseSolver { return false } + for (const side of SIDE_ORDER) { + this.shrinkMarginBySide[side] += + shrinkResult.appliedShrinkBySide[side] ?? 0 + } this.shrinkCount += 1 this.reinitializeForInnerRect(shrinkResult.rect) return true } + private acceptCurrentInnerRectForA01() { + this.lastForceIteration = null + this.solved = true + this.progress = 1 + return true + } + override visualize(): GraphicsObject { const rects = [ { diff --git a/lib/HighDensitySolverA08/HighDensitySolverA08.ts b/lib/HighDensitySolverA08/HighDensitySolverA08.ts index 150af81..b11deca 100644 --- a/lib/HighDensitySolverA08/HighDensitySolverA08.ts +++ b/lib/HighDensitySolverA08/HighDensitySolverA08.ts @@ -32,6 +32,7 @@ export interface HighDensitySolverA08Props extends HighDensitySolverA01Props { initialRectMarginMm?: number innerRectMarginMm?: number rectShrinkStepMm?: number + maxShrinkMargin?: number breakoutTraceMarginMm?: number breakoutBoundaryMarginMm?: number breakoutSegmentCount?: number @@ -48,6 +49,11 @@ function normalizeA08Props( ): HighDensitySolverA08Props { const breakoutTraceMarginMm = props.breakoutTraceMarginMm ?? defaultA08Params.breakoutTraceMarginMm + const maxShrinkMargin = + props.maxShrinkMargin === undefined || + !Number.isFinite(props.maxShrinkMargin) + ? defaultA08Params.maxShrinkMargin + : Math.max(0, props.maxShrinkMargin) return { ...props, @@ -67,6 +73,7 @@ function normalizeA08Props( defaultA08Params.initialRectMarginMm, rectShrinkStepMm: props.rectShrinkStepMm ?? defaultA08Params.rectShrinkStepMm, + maxShrinkMargin, breakoutTraceMarginMm, breakoutBoundaryMarginMm: getDefaultA08BreakoutBoundaryMarginMm(props), breakoutSegmentCount: @@ -103,6 +110,7 @@ function toBreakoutSolverProps( initialRectMarginMm: props.initialRectMarginMm, innerRectMarginMm: props.innerRectMarginMm, rectShrinkStepMm: props.rectShrinkStepMm, + maxShrinkMargin: props.maxShrinkMargin, breakoutTraceMarginMm: props.breakoutTraceMarginMm, breakoutBoundaryMarginMm: props.breakoutBoundaryMarginMm, breakoutSegmentCount: props.breakoutSegmentCount, diff --git a/lib/default-params.ts b/lib/default-params.ts index 024738c..33b976b 100644 --- a/lib/default-params.ts +++ b/lib/default-params.ts @@ -103,6 +103,7 @@ export const defaultA08Params: Required< | "effort" | "initialRectMarginMm" | "rectShrinkStepMm" + | "maxShrinkMargin" | "breakoutTraceMarginMm" | "breakoutSegmentCount" | "breakoutMaxIterationsPerRect" @@ -120,6 +121,7 @@ export const defaultA08Params: Required< effort: 1, initialRectMarginMm: 0.2, rectShrinkStepMm: 0.1, + maxShrinkMargin: 2.4, breakoutTraceMarginMm: 0.1, breakoutSegmentCount: 2, breakoutMaxIterationsPerRect: 60, diff --git a/tests/a08/default-params.test.ts b/tests/a08/default-params.test.ts index 8d805bd..1ca927a 100644 --- a/tests/a08/default-params.test.ts +++ b/tests/a08/default-params.test.ts @@ -37,6 +37,7 @@ test("A08 breakout solver defaults stay aligned with defaultA08Params", () => { expect(solver.effort).toBe(defaultA08Params.effort) expect(solver.initialRectMarginMm).toBe(defaultA08Params.initialRectMarginMm) expect(solver.rectShrinkStepMm).toBe(defaultA08Params.rectShrinkStepMm) + expect(solver.maxShrinkMargin).toBe(defaultA08Params.maxShrinkMargin) expect(solver.breakoutTraceMarginMm).toBe( defaultA08Params.breakoutTraceMarginMm, ) diff --git a/tests/a08/max-shrink-margin.test.ts b/tests/a08/max-shrink-margin.test.ts new file mode 100644 index 0000000..f8b5a1a --- /dev/null +++ b/tests/a08/max-shrink-margin.test.ts @@ -0,0 +1,83 @@ +import { expect, test } from "bun:test" +import dataset02Json from "@tscircuit/hypergraph/datasets/jumper-graph-solver/dataset02.json" +import { + convertDataset02SampleToNodeWithPortPoints, + type Dataset02Sample, +} from "../../lib/dataset02/convertDataset02SampleToNodeWithPortPoints" +import { defaultA08Params } from "../../lib/default-params" +import { + HighDensitySolverA08, + HighDensitySolverA08BreakoutSolver, +} from "../../lib/HighDensitySolverA08/HighDensitySolverA08" +import { getNodeBounds } from "../../lib/HighDensitySolverA08/shared" +import type { NodeWithPortPoints } from "../../lib/types" + +const dataset02 = dataset02Json as Dataset02Sample[] + +test("A08 breakout shrink honors a partial maxShrinkMargin per side", () => { + const nodeWithPortPoints: NodeWithPortPoints = { + capacityMeshNodeId: "a08-max-shrink-margin", + center: { x: 0, y: 0 }, + width: 10, + height: 10, + availableZ: [0, 1], + portPoints: [], + } + + const solver = new HighDensitySolverA08BreakoutSolver({ + ...defaultA08Params, + nodeWithPortPoints, + cellSizeMm: 1, + rectShrinkStepMm: 0.1, + maxShrinkMargin: 0.05, + maxCellCount: 100, + }) + + solver.solve() + + expect(solver.failed).toBeFalse() + expect(solver.solved).toBeTrue() + expect(solver.innerRect).not.toBeNull() + expect(solver.shrinkCount).toBe(1) + expect(solver.innerRect!.width).toBeCloseTo(9.9, 6) + expect(solver.innerRect!.height).toBeCloseTo(9.9, 6) +}) + +test("A08 advances to A01 after reaching maxShrinkMargin even if breakout checks stay unsolved", () => { + const sample = dataset02[9] + if (!sample) { + throw new Error("dataset02 sample010 is missing") + } + + const nodeWithPortPoints = convertDataset02SampleToNodeWithPortPoints( + sample, + { + capacityMeshNodeId: "dataset02-10-max-shrink-margin", + availableZ: [0, 1], + }, + ) + + const solver = new HighDensitySolverA08({ + ...defaultA08Params, + nodeWithPortPoints, + effort: 10, + initialRectMarginMm: 1, + breakoutMaxIterationsPerRect: 1, + maxShrinkMargin: 0, + }) + solver.MAX_ITERATIONS = 100_000_000 + solver.solveUntilStage("A01") + + const outerBounds = getNodeBounds(nodeWithPortPoints) + + expect(solver.stage).toBe("A01") + expect(solver.failed).toBeFalse() + expect(solver.breakoutSolver).not.toBeNull() + expect(solver.breakoutSolver!.stats?.shrinkCount).toBe(0) + expect(solver.breakoutSolver!.stats?.unsolvedSides).toContain("bottom") + expect(solver.innerRect).not.toBeNull() + expect(solver.innerRect!.minX).toBeCloseTo(outerBounds.minX + 1, 6) + expect(solver.innerRect!.maxX).toBeCloseTo(outerBounds.maxX - 1, 6) + expect(solver.innerRect!.minY).toBeCloseTo(outerBounds.minY + 1, 6) + expect(solver.innerRect!.maxY).toBeCloseTo(outerBounds.maxY - 1, 6) +})