From cd216eed0b57d342c9a6f9972a29aae2c29f6905 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?0hm=E2=98=98=EF=B8=8F?= Date: Wed, 15 Apr 2026 20:34:08 +0530 Subject: [PATCH 1/3] Preserve shared multilayer regions --- lib/utils/canonicalizeLayeredRects.ts | 225 ++++++++++++++++++++++++++ lib/utils/finalizeRects.ts | 122 +++++++++++++- lib/utils/resizeSoftOverlaps.ts | 49 ++++-- 3 files changed, 383 insertions(+), 13 deletions(-) create mode 100644 lib/utils/canonicalizeLayeredRects.ts diff --git a/lib/utils/canonicalizeLayeredRects.ts b/lib/utils/canonicalizeLayeredRects.ts new file mode 100644 index 0000000..975f82e --- /dev/null +++ b/lib/utils/canonicalizeLayeredRects.ts @@ -0,0 +1,225 @@ +import type { Placed3D, Rect3d } from "../rectdiff-types" +import { EPS, containsPoint } from "./rectdiff-geometry" + +const sortNumbers = (a: number, b: number) => a - b + +function dedupeSortedEdges(edges: number[]): number[] { + const sorted = edges.slice().sort(sortNumbers) + const out: number[] = [] + + for (const edge of sorted) { + const last = out[out.length - 1] + if (last === undefined || Math.abs(edge - last) > EPS) { + out.push(edge) + } + } + + return out +} + +function splitIntoContiguousLayerRuns(zLayers: number[]): number[][] { + if (zLayers.length === 0) return [] + + const sorted = Array.from(new Set(zLayers)).sort(sortNumbers) + const out: number[][] = [] + let current = [sorted[0]!] + + for (let i = 1; i < sorted.length; i++) { + const z = sorted[i]! + const prev = current[current.length - 1]! + + if (z === prev + 1) { + current.push(z) + continue + } + + out.push(current) + current = [z] + } + + out.push(current) + return out +} + +function getCellLayerRuns(params: { + minX: number + maxX: number + minY: number + maxY: number + placed: Placed3D[] +}): number[][] { + const { minX, maxX, minY, maxY, placed } = params + const midX = (minX + maxX) / 2 + const midY = (minY + maxY) / 2 + const zLayers = new Set() + + for (const placement of placed) { + if (!containsPoint(placement.rect, { x: midX, y: midY })) continue + + for (const z of placement.zLayers) { + zLayers.add(z) + } + } + + return splitIntoContiguousLayerRuns(Array.from(zLayers)) +} + +function buildRowRuns(placed: Placed3D[]) { + const xEdges = dedupeSortedEdges( + placed.flatMap((placement) => [ + placement.rect.x, + placement.rect.x + placement.rect.width, + ]), + ) + const yEdges = dedupeSortedEdges( + placed.flatMap((placement) => [ + placement.rect.y, + placement.rect.y + placement.rect.height, + ]), + ) + + const runs: Array<{ + minX: number + maxX: number + minY: number + maxY: number + zLayers: number[] + }> = [] + + for (let yi = 0; yi < yEdges.length - 1; yi++) { + const minY = yEdges[yi]! + const maxY = yEdges[yi + 1]! + if (maxY - minY <= EPS) continue + + const openRuns = new Map< + string, + { + minX: number + maxX: number + minY: number + maxY: number + zLayers: number[] + } + >() + + for (let xi = 0; xi < xEdges.length - 1; xi++) { + const minX = xEdges[xi]! + const maxX = xEdges[xi + 1]! + if (maxX - minX <= EPS) continue + + const layerRuns = getCellLayerRuns({ + minX, + maxX, + minY, + maxY, + placed, + }) + + const seenKeys = new Set() + + for (const zLayers of layerRuns) { + const key = zLayers.join(",") + seenKeys.add(key) + const existing = openRuns.get(key) + + if (existing && Math.abs(existing.maxX - minX) <= EPS) { + existing.maxX = maxX + continue + } + + if (existing) runs.push(existing) + + openRuns.set(key, { + minX, + maxX, + minY, + maxY, + zLayers, + }) + } + + for (const [key, run] of openRuns) { + if (seenKeys.has(key)) continue + runs.push(run) + openRuns.delete(key) + } + } + + for (const run of openRuns.values()) { + runs.push(run) + } + } + + return runs +} + +function mergeRunsVertically( + runs: Array<{ + minX: number + maxX: number + minY: number + maxY: number + zLayers: number[] + }>, +): Rect3d[] { + const sorted = runs.slice().sort((a, b) => { + if (Math.abs(a.minY - b.minY) > EPS) return a.minY - b.minY + if (Math.abs(a.maxY - b.maxY) > EPS) return a.maxY - b.maxY + if (Math.abs(a.minX - b.minX) > EPS) return a.minX - b.minX + if (Math.abs(a.maxX - b.maxX) > EPS) return a.maxX - b.maxX + return a.zLayers.join(",").localeCompare(b.zLayers.join(",")) + }) + + const out: Rect3d[] = [] + const active = new Map() + + for (const run of sorted) { + const key = [ + run.minX.toFixed(9), + run.maxX.toFixed(9), + run.zLayers.join(","), + ].join(":") + const existing = active.get(key) + + if (existing && Math.abs(existing.maxY - run.minY) <= EPS) { + existing.maxY = run.maxY + continue + } + + if (existing) out.push(existing) + + active.set(key, { + minX: run.minX, + minY: run.minY, + maxX: run.maxX, + maxY: run.maxY, + zLayers: run.zLayers.slice(), + }) + } + + for (const rect of active.values()) { + out.push(rect) + } + + return out + .filter( + (rect) => rect.maxX - rect.minX > EPS && rect.maxY - rect.minY > EPS, + ) + .sort((a, b) => { + const aKey = a.zLayers.join(",") + const bKey = b.zLayers.join(",") + if (aKey !== bKey) return aKey.localeCompare(bKey) + if (Math.abs(a.minY - b.minY) > EPS) return a.minY - b.minY + if (Math.abs(a.minX - b.minX) > EPS) return a.minX - b.minX + if (Math.abs(a.maxY - b.maxY) > EPS) return a.maxY - b.maxY + return a.maxX - b.maxX + }) +} + +/** + * Rebuilds placements into clean rectangles grouped by shared zLayers. + */ +export function canonicalizeLayeredRects(placed: Placed3D[]): Rect3d[] { + if (placed.length === 0) return [] + return mergeRunsVertically(buildRowRuns(placed)) +} diff --git a/lib/utils/finalizeRects.ts b/lib/utils/finalizeRects.ts index 2cf12a5..916a86d 100644 --- a/lib/utils/finalizeRects.ts +++ b/lib/utils/finalizeRects.ts @@ -4,6 +4,8 @@ import { obstacleToXYRect, obstacleZs, } from "../solvers/RectDiffSeedingSolver/layers" +import { canonicalizeLayeredRects } from "./canonicalizeLayeredRects" +import { intersect1D, subtractRect2D, EPS } from "./rectdiff-geometry" export function finalizeRects(params: { placed: Placed3D[] @@ -12,8 +14,120 @@ export function finalizeRects(params: { zIndexByName: Map obstacleClearance?: number }): Rect3d[] { - // Convert all placed (free space) nodes to output format - const out: Rect3d[] = params.placed.map((p) => ({ + const promotedRects = canonicalizeLayeredRects(params.placed).filter( + (rect) => rect.zLayers.length > 1, + ) + + let freePlacements: Placed3D[] = params.placed.map((placement) => ({ + rect: { ...placement.rect }, + zLayers: placement.zLayers.slice().sort((a, b) => a - b), + })) + + for (const promoted of promotedRects) { + const promotedRect: XYRect = { + x: promoted.minX, + y: promoted.minY, + width: promoted.maxX - promoted.minX, + height: promoted.maxY - promoted.minY, + } + + const nextPlacements: Placed3D[] = [] + + for (const placement of freePlacements) { + const sharedZ = placement.zLayers.filter((z) => + promoted.zLayers.includes(z), + ) + if (sharedZ.length === 0) { + nextPlacements.push(placement) + continue + } + + const Xi = intersect1D( + [placement.rect.x, placement.rect.x + placement.rect.width], + [promotedRect.x, promotedRect.x + promotedRect.width], + ) + const Yi = intersect1D( + [placement.rect.y, placement.rect.y + placement.rect.height], + [promotedRect.y, promotedRect.y + promotedRect.height], + ) + let overlapCore: { + x: number + y: number + width: number + height: number + } | null = null + if (Xi && Yi && Xi[1] - Xi[0] > EPS && Yi[1] - Yi[0] > EPS) { + overlapCore = { + x: Xi[0], + y: Yi[0], + width: Xi[1] - Xi[0], + height: Yi[1] - Yi[0], + } + } + if (!overlapCore) { + nextPlacements.push(placement) + continue + } + + const outsideParts = subtractRect2D(placement.rect, promotedRect) + for (const part of outsideParts) { + if (part.width > EPS && part.height > EPS) { + nextPlacements.push({ + rect: part, + zLayers: placement.zLayers.slice(), + }) + } + } + + const unaffectedZ = placement.zLayers.filter( + (z) => !promoted.zLayers.includes(z), + ) + if ( + unaffectedZ.length > 0 && + overlapCore.width > EPS && + overlapCore.height > EPS + ) { + nextPlacements.push({ + rect: overlapCore, + zLayers: unaffectedZ, + }) + } + } + + nextPlacements.push({ + rect: promotedRect, + zLayers: promoted.zLayers.slice(), + }) + + const deduped = new Map() + for (const placement of nextPlacements) { + if (placement.rect.width <= EPS || placement.rect.height <= EPS) continue + placement.zLayers = Array.from(new Set(placement.zLayers)).sort( + (a, b) => a - b, + ) + if (placement.zLayers.length === 0) continue + + const key = [ + placement.rect.x.toFixed(9), + placement.rect.y.toFixed(9), + placement.rect.width.toFixed(9), + placement.rect.height.toFixed(9), + ].join(":") + const existing = deduped.get(key) + if (!existing) { + deduped.set(key, placement) + continue + } + for (const z of placement.zLayers) { + if (!existing.zLayers.includes(z)) existing.zLayers.push(z) + } + existing.zLayers.sort((a, b) => a - b) + } + + freePlacements = Array.from(deduped.values()) + } + + const out: Rect3d[] = freePlacements.map((p) => ({ minX: p.rect.x, minY: p.rect.y, maxX: p.rect.x + p.rect.width, @@ -44,7 +158,9 @@ export function finalizeRects(params: { entry = { rect, layers: new Set() } layersByKey.set(key, entry) } - zLayers.forEach((layer: number) => entry!.layers.add(layer)) + zLayers.forEach((layer: number) => { + entry!.layers.add(layer) + }) } for (const { rect, layers } of layersByKey.values()) { diff --git a/lib/utils/resizeSoftOverlaps.ts b/lib/utils/resizeSoftOverlaps.ts index ce52ad5..949ab0e 100644 --- a/lib/utils/resizeSoftOverlaps.ts +++ b/lib/utils/resizeSoftOverlaps.ts @@ -1,6 +1,6 @@ import type { RTreeRect } from "lib/types/capacity-mesh-types" import type { Placed3D } from "../rectdiff-types" -import { overlaps, subtractRect2D, EPS } from "./rectdiff-geometry" +import { overlaps, subtractRect2D, intersect1D, EPS } from "./rectdiff-geometry" import type RBush from "rbush" import { rectToTree } from "./rectToTree" @@ -30,18 +30,37 @@ export function resizeSoftOverlaps( if (sharedZ.length === 0) continue if (!overlaps(old.rect, newR)) continue - // Carve the overlap on the shared layers + // Carve only the shared layers. Non-overlapped pieces keep the full + // original layer set, and the overlapped core keeps only unaffected layers. const parts = subtractRect2D(old.rect, newR) + const Xi = intersect1D( + [old.rect.x, old.rect.x + old.rect.width], + [newR.x, newR.x + newR.width], + ) + const Yi = intersect1D( + [old.rect.y, old.rect.y + old.rect.height], + [newR.y, newR.y + newR.height], + ) + let overlapCore: { + x: number + y: number + width: number + height: number + } | null = null + if (Xi && Yi && Xi[1] - Xi[0] > EPS && Yi[1] - Yi[0] > EPS) { + overlapCore = { + x: Xi[0], + y: Yi[0], + width: Xi[1] - Xi[0], + height: Yi[1] - Yi[0], + } + } + const unaffectedZ = old.zLayers.filter((z) => !newZs.includes(z)) - // We will replace `old` entirely; re-add unaffected layers (same rect object). + // We will replace `old` entirely and rebuild it from parts. removeIdx.push(i) - const unaffectedZ = old.zLayers.filter((z) => !newZs.includes(z)) - if (unaffectedZ.length > 0) { - toAdd.push({ rect: old.rect, zLayers: unaffectedZ }) - } - - // Re-add carved pieces for affected layers, dropping tiny slivers + // Outside the overlap, the old node still exists on all of its layers. const minW = Math.min( params.options.minSingle.width, params.options.minMulti.width, @@ -52,9 +71,19 @@ export function resizeSoftOverlaps( ) for (const p of parts) { if (p.width + EPS >= minW && p.height + EPS >= minH) { - toAdd.push({ rect: p, zLayers: sharedZ.slice() }) + toAdd.push({ rect: p, zLayers: old.zLayers.slice() }) } } + + // Inside the overlap, only the old node's non-shared layers remain. + if ( + overlapCore && + unaffectedZ.length > 0 && + overlapCore.width + EPS >= minW && + overlapCore.height + EPS >= minH + ) { + toAdd.push({ rect: overlapCore, zLayers: unaffectedZ }) + } } // Remove fully overlapped nodes and keep indexes in sync From 91336785cdb305b2d4f0adc28dcb841f6e64ada5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?0hm=E2=98=98=EF=B8=8F?= Date: Wed, 15 Apr 2026 20:34:24 +0530 Subject: [PATCH 2/3] Update solver snapshots --- tests/__snapshots__/board-outline.snap.svg | 4 +- .../rectDiffGridSolverPipeline.snap.svg | 6 +-- .../both-points-equivalent.snap.svg | 2 +- .../__snapshots__/bugreport01-be84eb.snap.svg | 6 +-- .../__snapshots__/bugreport02-bc4361.snap.svg | 6 +-- .../__snapshots__/bugreport03-fe4a17.snap.svg | 6 +-- .../__snapshots__/bugreport07-d3f3be.snap.svg | 6 +-- .../__snapshots__/bugreport08-e3ec95.snap.svg | 6 +-- .../__snapshots__/bugreport09-618e09.snap.svg | 2 +- .../__snapshots__/bugreport10-71239a.snap.svg | 6 +-- .../__snapshots__/bugreport11-b2de3c.snap.svg | 6 +-- .../__snapshots__/bugreport12-35ce1c.snap.svg | 6 +-- .../__snapshots__/bugreport13-b9a758.snap.svg | 6 +-- .../__snapshots__/bugreport16-d95f38.snap.svg | 6 +-- .../__snapshots__/bugreport18-1b2d06.snap.svg | 6 +-- .../__snapshots__/bugreport19.snap.svg | 6 +-- .../bugreport20-obstacle-clipping.snap.svg | 6 +-- .../bugreport21-board-outline.snap.svg | 6 +-- .../__snapshots__/bugreport22-2a75ce.snap.svg | 6 +-- .../bugreport23-LGA15x4.snap.svg | 4 +- .../__snapshots__/bugreport24-05597c.snap.svg | 6 +-- .../__snapshots__/bugreport25-4b1d55.snap.svg | 6 +-- .../__snapshots__/bugreport36-bf8303.snap.svg | 6 +-- .../__snapshots__/interaction.snap.svg | 6 +-- .../__snapshots__/multi-point.snap.svg | 6 +-- .../__snapshots__/no-better-path.snap.svg | 6 +-- .../__snapshots__/offboardconnects01.snap.svg | 2 +- ...hould-return-root-connection-name.snap.svg | 6 +-- .../merge-single-layer-node.snap.svg | 44 +++++++++++++++++++ .../__snapshots__/transitivity.snap.svg | 6 +-- 30 files changed, 123 insertions(+), 79 deletions(-) create mode 100644 tests/solver/repros/merge-single-layer-node/__snapshots__/merge-single-layer-node.snap.svg diff --git a/tests/__snapshots__/board-outline.snap.svg b/tests/__snapshots__/board-outline.snap.svg index 8f9b27f..80d6fb1 100644 --- a/tests/__snapshots__/board-outline.snap.svg +++ b/tests/__snapshots__/board-outline.snap.svg @@ -1,4 +1,4 @@ - \ No newline at end of file diff --git a/tests/solver/transitivity/__snapshots__/transitivity.snap.svg b/tests/solver/transitivity/__snapshots__/transitivity.snap.svg index 560398e..4db7018 100644 --- a/tests/solver/transitivity/__snapshots__/transitivity.snap.svg +++ b/tests/solver/transitivity/__snapshots__/transitivity.snap.svg @@ -1,4 +1,4 @@ -Layer z=0Layer z=1