diff --git a/lib/utils/buildRowRuns.ts b/lib/utils/buildRowRuns.ts new file mode 100644 index 0000000..b2e32e7 --- /dev/null +++ b/lib/utils/buildRowRuns.ts @@ -0,0 +1,102 @@ +import type { Placed3D } from "../rectdiff-types" +import { EPS } from "./rectdiff-geometry" +import { dedupeSortedEdges } from "./dedupeSortedEdges" +import { getCellLayerRuns } from "./getCellLayerRuns" + +/** Build horizontal runs for layered rectangles. */ +export 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 +} diff --git a/lib/utils/canonicalizeLayeredRects.ts b/lib/utils/canonicalizeLayeredRects.ts new file mode 100644 index 0000000..bee30d1 --- /dev/null +++ b/lib/utils/canonicalizeLayeredRects.ts @@ -0,0 +1,11 @@ +import type { Placed3D, Rect3d } from "../rectdiff-types" +import { buildRowRuns } from "./buildRowRuns" +import { mergeRunsVertically } from "./mergeRunsVertically" + +/** Rebuild placements into 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/dedupeSortedEdges.ts b/lib/utils/dedupeSortedEdges.ts new file mode 100644 index 0000000..1487a78 --- /dev/null +++ b/lib/utils/dedupeSortedEdges.ts @@ -0,0 +1,16 @@ +import { EPS } from "./rectdiff-geometry" + +/** Dedupe sorted edge values with EPS tolerance. */ +export function dedupeSortedEdges(edges: number[]): number[] { + const sorted = edges.slice().sort((a, b) => a - b) + 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 +} diff --git a/lib/utils/finalizeRects.ts b/lib/utils/finalizeRects.ts index 2cf12a5..80f286c 100644 --- a/lib/utils/finalizeRects.ts +++ b/lib/utils/finalizeRects.ts @@ -4,7 +4,10 @@ import { obstacleToXYRect, obstacleZs, } from "../solvers/RectDiffSeedingSolver/layers" +import { canonicalizeLayeredRects } from "./canonicalizeLayeredRects" +import { intersect1D, subtractRect2D, EPS } from "./rectdiff-geometry" +/** Finalize free-space and obstacle rectangles. */ export function finalizeRects(params: { placed: Placed3D[] obstacles: Obstacle[] @@ -12,8 +15,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 +159,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/getCellLayerRuns.ts b/lib/utils/getCellLayerRuns.ts new file mode 100644 index 0000000..b76e921 --- /dev/null +++ b/lib/utils/getCellLayerRuns.ts @@ -0,0 +1,29 @@ +import type { Placed3D } from "../rectdiff-types" +import { containsPoint } from "./rectdiff-geometry" +import { splitIntoContiguousLayerRuns } from "./splitIntoContiguousLayerRuns" + +/** Collect contiguous zLayer runs for a cell. */ +export 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)) +} diff --git a/lib/utils/mergeRunsVertically.ts b/lib/utils/mergeRunsVertically.ts new file mode 100644 index 0000000..9545bc2 --- /dev/null +++ b/lib/utils/mergeRunsVertically.ts @@ -0,0 +1,84 @@ +import type { Rect3d } from "../rectdiff-types" +import { EPS } from "./rectdiff-geometry" + +/** Merge adjacent runs into rectangles. */ +export 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 + }) +} diff --git a/lib/utils/resizeSoftOverlaps.ts b/lib/utils/resizeSoftOverlaps.ts index ce52ad5..3632d96 100644 --- a/lib/utils/resizeSoftOverlaps.ts +++ b/lib/utils/resizeSoftOverlaps.ts @@ -1,9 +1,10 @@ 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" +/** Resize overlaps for a new placement. */ export function resizeSoftOverlaps( params: { layerCount: number @@ -30,18 +31,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 +72,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 diff --git a/lib/utils/splitIntoContiguousLayerRuns.ts b/lib/utils/splitIntoContiguousLayerRuns.ts new file mode 100644 index 0000000..9c76da0 --- /dev/null +++ b/lib/utils/splitIntoContiguousLayerRuns.ts @@ -0,0 +1,26 @@ +/** Split zLayers into contiguous runs. */ +export function splitIntoContiguousLayerRuns(zLayers: number[]): number[][] { + if (zLayers.length === 0) { + return [] + } + + const sorted = Array.from(new Set(zLayers)).sort((a, b) => a - b) + 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 +} 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