diff --git a/lib/solvers/NodeMergeSolver/CoverageMergeSolver.ts b/lib/solvers/NodeMergeSolver/CoverageMergeSolver.ts new file mode 100644 index 0000000..8d0b33b --- /dev/null +++ b/lib/solvers/NodeMergeSolver/CoverageMergeSolver.ts @@ -0,0 +1,224 @@ +import { BaseSolver } from "@tscircuit/solver-utils" +import type { GraphicsObject } from "graphics-debug" +import type { Placed3D, Rect3d } from "lib/rectdiff-types" +import type { ActiveRect } from "./shared" +import { activeRectToRect3d } from "./shared" +import { + addActiveRects, + addGridLines, + addInfoText, + addRect3dOverlays, + addSourcePlacements, +} from "./visualization" + +export type CoverageMergeSolverInput = { + placed: Placed3D[] + xs: number[] + ys: number[] + layerCount: number + layerMasks: number[][] + maxAspectRatio?: number | null +} + +export class CoverageMergeSolver extends BaseSolver { + private mergeRowIndex = 0 + private activeRects = new Map() + private coverageRects: Rect3d[] = [] + private lastMergedRects: Rect3d[] = [] + + constructor(private input: CoverageMergeSolverInput) { + super() + } + + override _setup() { + this.stats = { mergeRowIndex: 0 } + } + + override _step() { + this.lastMergedRects = [] + + if (this.mergeRowIndex >= this.input.ys.length - 1) { + for (const rect of this.activeRects.values()) { + this.pushFinalizedRect(rect) + } + this.activeRects.clear() + this.solved = true + return + } + + const y = this.mergeRowIndex++ + const nextActiveKeys = new Set() + let x = 0 + + while (x < this.input.xs.length - 1) { + const mask = this.input.layerMasks[y]![x]! + if (mask === 0) { + x += 1 + continue + } + + const xStart = x + x += 1 + while ( + x < this.input.xs.length - 1 && + this.input.layerMasks[y]![x] === mask + ) { + x += 1 + } + + const key = `${mask}:${this.input.xs[xStart]}:${this.input.xs[x]}` + const existing = this.activeRects.get(key) + const nextMinY = this.input.ys[y]! + const nextMaxY = this.input.ys[y + 1]! + if (existing && existing.maxY === nextMinY) { + const width = existing.maxX - existing.minX + const currentHeight = existing.maxY - existing.minY + const nextHeight = nextMaxY - existing.minY + const maxAspectRatio = this.input.maxAspectRatio + const exceedsAspect = + maxAspectRatio != null && + width > 0 && + nextHeight > 0 && + currentHeight >= width && + nextHeight > maxAspectRatio * width + + if (exceedsAspect) { + this.pushFinalizedRect(existing) + this.activeRects.set(key, { + minX: this.input.xs[xStart]!, + maxX: this.input.xs[x]!, + minY: nextMinY, + maxY: nextMaxY, + mask, + }) + } else { + existing.maxY = nextMaxY + } + } else { + if (existing) { + this.pushFinalizedRect(existing) + } + this.activeRects.set(key, { + minX: this.input.xs[xStart]!, + maxX: this.input.xs[x]!, + minY: nextMinY, + maxY: nextMaxY, + mask, + }) + } + nextActiveKeys.add(key) + } + + for (const [key, rect] of Array.from(this.activeRects.entries())) { + if (!nextActiveKeys.has(key)) { + this.pushFinalizedRect(rect) + this.activeRects.delete(key) + } + } + + this.stats.mergeRowIndex = this.mergeRowIndex + } + + override getOutput() { + return { + coverageRects: this.coverageRects, + placed: this.input.placed, + xs: this.input.xs, + ys: this.input.ys, + layerCount: this.input.layerCount, + } + } + + private pushFinalizedRect(rect: ActiveRect) { + const rect3d = activeRectToRect3d(rect, this.input.layerCount) + const split = this.splitRectByAspect(rect3d) + if (split.length === 0) return + this.coverageRects.push(...split) + this.lastMergedRects.push(...split) + } + + private splitRectByAspect(rect: Rect3d): Rect3d[] { + const width = rect.maxX - rect.minX + const height = rect.maxY - rect.minY + if (!Number.isFinite(width) || !Number.isFinite(height)) return [] + if (width <= 0 || height <= 0) return [] + const minSizeEps = 1e-6 + if (width < minSizeEps || height < minSizeEps) return [] + + const maxAspectRatio = this.input.maxAspectRatio + if (maxAspectRatio == null || maxAspectRatio <= 0) return [rect] + + const ratio = Math.max(width / height, height / width) + if (ratio <= maxAspectRatio) return [rect] + + if (width >= height) { + const maxWidth = maxAspectRatio * height + if (!Number.isFinite(maxWidth) || maxWidth <= 0) return [] + const parts = Math.max(1, Math.ceil(width / maxWidth)) + if (parts > 10000) return [] + const step = width / parts + return Array.from({ length: parts }, (_, index) => { + const minX = rect.minX + index * step + const maxX = + index === parts - 1 ? rect.maxX : rect.minX + (index + 1) * step + return { + ...rect, + minX, + maxX, + } + }) + } + + const maxHeight = maxAspectRatio * width + if (!Number.isFinite(maxHeight) || maxHeight <= 0) return [] + const parts = Math.max(1, Math.ceil(height / maxHeight)) + if (parts > 10000) return [] + const step = height / parts + return Array.from({ length: parts }, (_, index) => { + const minY = rect.minY + index * step + const maxY = + index === parts - 1 ? rect.maxY : rect.minY + (index + 1) * step + return { + ...rect, + minY, + maxY, + } + }) + } + + override visualize(): GraphicsObject { + const rects: NonNullable = [] + const lines: NonNullable = [] + const texts: NonNullable = [] + + addSourcePlacements(rects, this.input.placed) + addGridLines(lines, this.input.xs, this.input.ys) + addRect3dOverlays(rects, this.coverageRects, "coverage") + addActiveRects( + rects, + Array.from(this.activeRects.values()), + this.input.layerCount, + ) + addRect3dOverlays(rects, this.lastMergedRects, "merged") + + addInfoText( + texts, + this.input.xs, + this.input.ys, + [ + `phase: merge`, + `row: ${this.mergeRowIndex}/${Math.max(0, this.input.ys.length - 1)}`, + `coverage rects: ${this.coverageRects.length}`, + ].join("\n"), + ) + + return { + title: "Coverage Partition - Merge", + coordinateSystem: "cartesian", + rects, + points: [], + lines, + texts, + } + } +} diff --git a/lib/solvers/NodeMergeSolver/MaskGenerationSolver.ts b/lib/solvers/NodeMergeSolver/MaskGenerationSolver.ts new file mode 100644 index 0000000..3725710 --- /dev/null +++ b/lib/solvers/NodeMergeSolver/MaskGenerationSolver.ts @@ -0,0 +1,135 @@ +import { BaseSolver } from "@tscircuit/solver-utils" +import type { GraphicsObject } from "graphics-debug" +import type { Placed3D } from "lib/rectdiff-types" +import { getColorForZLayer } from "lib/utils/getColorForZLayer" +import { addGridLines, addInfoText, addSourcePlacements } from "./visualization" +import { maskToZLayers } from "./shared" + +export type MaskGenerationSolverInput = { + placed: Placed3D[] + xs: number[] + ys: number[] + layerCount: number + layerDiffs: number[][][] +} + +export class MaskGenerationSolver extends BaseSolver { + private layerMasks: number[][] = [] + private maskLayerIndex = 0 + private maskRowIndex = 0 + private lastMaskRow: Array<{ + x1: number + x2: number + y1: number + y2: number + mask: number + }> = [] + + constructor(private input: MaskGenerationSolverInput) { + super() + } + + override _setup() { + this.layerMasks = Array.from( + { length: Math.max(0, this.input.ys.length - 1) }, + () => + Array.from({ length: Math.max(0, this.input.xs.length - 1) }, () => 0), + ) + this.stats = { maskLayerIndex: 0, maskRowIndex: 0 } + } + + override _step() { + this.lastMaskRow = [] + + if (this.maskLayerIndex >= this.input.layerCount) { + this.solved = true + return + } + + if (this.maskRowIndex >= this.input.ys.length - 1) { + this.maskLayerIndex += 1 + this.maskRowIndex = 0 + return + } + + const z = this.maskLayerIndex + const y = this.maskRowIndex++ + const diff = this.input.layerDiffs[z]! + let rowRunning = 0 + + for (let x = 0; x < this.input.xs.length - 1; x++) { + rowRunning += diff[y]![x]! + const cellCount = rowRunning + (y > 0 ? diff[y - 1]![x]! : 0) + diff[y]![x] = cellCount + if (cellCount > 0) { + this.layerMasks[y]![x]! |= 1 << z + this.lastMaskRow.push({ + x1: this.input.xs[x]!, + x2: this.input.xs[x + 1]!, + y1: this.input.ys[y]!, + y2: this.input.ys[y + 1]!, + mask: this.layerMasks[y]![x]!, + }) + } + } + + this.stats.maskLayerIndex = this.maskLayerIndex + this.stats.maskRowIndex = this.maskRowIndex + } + + override getOutput() { + return { + placed: this.input.placed, + xs: this.input.xs, + ys: this.input.ys, + layerCount: this.input.layerCount, + layerMasks: this.layerMasks, + } + } + + override visualize(): GraphicsObject { + const rects: NonNullable = [] + const lines: NonNullable = [] + const texts: NonNullable = [] + + addSourcePlacements(rects, this.input.placed) + addGridLines(lines, this.input.xs, this.input.ys) + + for (const cell of this.lastMaskRow) { + const colors = getColorForZLayer( + maskToZLayers(cell.mask, this.input.layerCount), + ) + rects.push({ + center: { + x: (cell.x1 + cell.x2) / 2, + y: (cell.y1 + cell.y2) / 2, + }, + width: cell.x2 - cell.x1, + height: cell.y2 - cell.y1, + fill: colors.fill, + stroke: colors.stroke, + label: `mask\nz:${maskToZLayers(cell.mask, this.input.layerCount).join(",")}`, + }) + } + + addInfoText( + texts, + this.input.xs, + this.input.ys, + [ + `phase: masks`, + `layer: ${Math.min(this.maskLayerIndex + 1, Math.max(1, this.input.layerCount))}/${Math.max(1, this.input.layerCount)}`, + `row: ${this.maskRowIndex}/${Math.max(0, this.input.ys.length - 1)}`, + ].join("\n"), + ) + + return { + title: "Coverage Partition - Masks", + coordinateSystem: "cartesian", + rects, + points: [], + lines, + texts, + } + } +} diff --git a/lib/solvers/NodeMergeSolver/NodeMergeSolver.ts b/lib/solvers/NodeMergeSolver/NodeMergeSolver.ts new file mode 100644 index 0000000..a243b5a --- /dev/null +++ b/lib/solvers/NodeMergeSolver/NodeMergeSolver.ts @@ -0,0 +1,119 @@ +import { + BasePipelineSolver, + definePipelineStep, + type PipelineStep, +} from "@tscircuit/solver-utils" +import type { GraphicsObject } from "graphics-debug" +import type { CapacityMeshNode } from "lib/types/capacity-mesh-types" +import type { Placed3D, Rect3d, XYRect } from "lib/rectdiff-types" +import { CoverageMergeSolver } from "./CoverageMergeSolver" +import { MaskGenerationSolver } from "./MaskGenerationSolver" +import { ObstacleInjectionSolver } from "./ObstacleInjectionSolver" +import { PlacementDiffSolver } from "./PlacementDiffSolver" +import type { ObstacleEntry } from "./shared" + +export type NodeMergeSolverInput = { + placed: Placed3D[] + boardVoidRects: XYRect[] + mergedObstacles: ObstacleEntry[] + maxAspectRatio?: number | null +} + +export class NodeMergeSolver extends BasePipelineSolver { + placementDiffSolver?: PlacementDiffSolver + maskGenerationSolver?: MaskGenerationSolver + coverageMergeSolver?: CoverageMergeSolver + obstacleInjectionSolver?: ObstacleInjectionSolver + + override pipelineDef: PipelineStep[] = [ + definePipelineStep( + "placementDiffSolver", + PlacementDiffSolver, + (pipeline: NodeMergeSolver) => [ + { + placed: pipeline.inputProblem.placed, + }, + ], + ), + definePipelineStep( + "maskGenerationSolver", + MaskGenerationSolver, + (pipeline: NodeMergeSolver) => { + const output = pipeline.placementDiffSolver?.getOutput() + if (!output) + throw new Error("PlacementDiffSolver did not produce output") + return [output] + }, + ), + definePipelineStep( + "coverageMergeSolver", + CoverageMergeSolver, + (pipeline: NodeMergeSolver) => { + const output = pipeline.maskGenerationSolver?.getOutput() + if (!output) + throw new Error("MaskGenerationSolver did not produce output") + return [ + { + ...output, + maxAspectRatio: pipeline.inputProblem.maxAspectRatio, + }, + ] + }, + ), + definePipelineStep( + "obstacleInjectionSolver", + ObstacleInjectionSolver, + (pipeline: NodeMergeSolver) => { + const output = pipeline.coverageMergeSolver?.getOutput() + if (!output) + throw new Error("CoverageMergeSolver did not produce output") + return [ + { + coverageRects: output.coverageRects, + mergedObstacles: pipeline.inputProblem.mergedObstacles, + maxAspectRatio: pipeline.inputProblem.maxAspectRatio, + }, + ] + }, + ), + ] + + override getOutput(): { meshNodes: CapacityMeshNode[]; rects: Rect3d[] } { + if (this.obstacleInjectionSolver) { + return this.obstacleInjectionSolver.getOutput() + } + if (this.coverageMergeSolver) { + const output = this.coverageMergeSolver.getOutput() + return { + meshNodes: [], + rects: output.coverageRects, + } + } + return { + meshNodes: [], + rects: [], + } + } + + override visualize(): GraphicsObject { + if (this.obstacleInjectionSolver) { + return this.obstacleInjectionSolver.visualize() + } + if (this.coverageMergeSolver) { + return this.coverageMergeSolver.visualize() + } + if (this.maskGenerationSolver) { + return this.maskGenerationSolver.visualize() + } + if (this.placementDiffSolver) { + return this.placementDiffSolver.visualize() + } + return { + title: "Node Merge Pipeline", + coordinateSystem: "cartesian", + rects: [], + points: [], + lines: [], + } + } +} diff --git a/lib/solvers/NodeMergeSolver/ObstacleInjectionSolver.ts b/lib/solvers/NodeMergeSolver/ObstacleInjectionSolver.ts new file mode 100644 index 0000000..da2872c --- /dev/null +++ b/lib/solvers/NodeMergeSolver/ObstacleInjectionSolver.ts @@ -0,0 +1,162 @@ +import { BaseSolver } from "@tscircuit/solver-utils" +import type { GraphicsObject } from "graphics-debug" +import type { CapacityMeshNode } from "lib/types/capacity-mesh-types" +import type { Rect3d } from "lib/rectdiff-types" +import { rectsToMeshNodes } from "../RectDiffExpansionSolver/rectsToMeshNodes" +import type { ObstacleEntry } from "./shared" +import { addInfoText, addRect3dOverlays } from "./visualization" + +export type ObstacleInjectionSolverInput = { + coverageRects: Rect3d[] + mergedObstacles: ObstacleEntry[] + maxAspectRatio?: number | null +} + +export class ObstacleInjectionSolver extends BaseSolver { + private obstacleRects: Rect3d[] = [] + private meshNodes: CapacityMeshNode[] = [] + private obstacleIndex = 0 + private lastAddedObstacle: Rect3d | null = null + + constructor(private input: ObstacleInjectionSolverInput) { + super() + } + + override _setup() { + this.stats = { obstacleIndex: 0 } + } + + override _step() { + this.lastAddedObstacle = null + + if (this.obstacleIndex >= this.input.mergedObstacles.length) { + this.meshNodes = this.splitMeshNodesByAspect( + rectsToMeshNodes([...this.input.coverageRects, ...this.obstacleRects]), + ) + this.solved = true + return + } + + const obstacle = this.input.mergedObstacles[this.obstacleIndex++]! + const rect3d: Rect3d = { + minX: obstacle.rect.x, + minY: obstacle.rect.y, + maxX: obstacle.rect.x + obstacle.rect.width, + maxY: obstacle.rect.y + obstacle.rect.height, + zLayers: obstacle.zLayers, + isObstacle: true, + } + this.obstacleRects.push(rect3d) + this.lastAddedObstacle = rect3d + this.stats.obstacleIndex = this.obstacleIndex + } + + override getOutput(): { meshNodes: CapacityMeshNode[]; rects: Rect3d[] } { + const rects = [...this.input.coverageRects, ...this.obstacleRects] + return { + meshNodes: this.solved + ? this.meshNodes + : this.splitMeshNodesByAspect(rectsToMeshNodes(rects)), + rects, + } + } + + private splitMeshNodesByAspect( + nodes: CapacityMeshNode[], + ): CapacityMeshNode[] { + const maxAspectRatio = this.input.maxAspectRatio + if (maxAspectRatio == null || maxAspectRatio <= 0) return nodes + + const minSizeEps = 1e-6 + const out: CapacityMeshNode[] = [] + + for (const node of nodes) { + const width = node.width + const height = node.height + if (!Number.isFinite(width) || !Number.isFinite(height)) continue + if (width < minSizeEps || height < minSizeEps) continue + + const ratio = Math.max(width / height, height / width) + if (ratio <= maxAspectRatio) { + out.push(node) + continue + } + + if (width >= height) { + const maxWidth = maxAspectRatio * height + if (!Number.isFinite(maxWidth) || maxWidth <= 0) continue + const parts = Math.max(1, Math.ceil(width / maxWidth)) + if (parts > 10000) continue + const step = width / parts + const minX = node.center.x - width / 2 + + for (let index = 0; index < parts; index += 1) { + const partMinX = minX + index * step + const partMaxX = + index === parts - 1 ? minX + width : minX + (index + 1) * step + const partWidth = partMaxX - partMinX + out.push({ + ...node, + capacityMeshNodeId: `${node.capacityMeshNodeId}-s${index}`, + center: { x: (partMinX + partMaxX) / 2, y: node.center.y }, + width: partWidth, + }) + } + continue + } + + const maxHeight = maxAspectRatio * width + if (!Number.isFinite(maxHeight) || maxHeight <= 0) continue + const parts = Math.max(1, Math.ceil(height / maxHeight)) + if (parts > 10000) continue + const step = height / parts + const minY = node.center.y - height / 2 + + for (let index = 0; index < parts; index += 1) { + const partMinY = minY + index * step + const partMaxY = + index === parts - 1 ? minY + height : minY + (index + 1) * step + const partHeight = partMaxY - partMinY + out.push({ + ...node, + capacityMeshNodeId: `${node.capacityMeshNodeId}-s${index}`, + center: { x: node.center.x, y: (partMinY + partMaxY) / 2 }, + height: partHeight, + }) + } + } + + return out + } + + override visualize(): GraphicsObject { + const rects: NonNullable = [] + const texts: NonNullable = [] + + addRect3dOverlays(rects, this.input.coverageRects, "coverage") + addRect3dOverlays(rects, this.obstacleRects, "obstacle") + if (this.lastAddedObstacle) { + addRect3dOverlays(rects, [this.lastAddedObstacle], "add obstacle") + } + + addInfoText( + texts, + this.input.coverageRects.map((r) => r.minX), + this.input.coverageRects.map((r) => r.minY), + [ + `phase: obstacles`, + `obstacles: ${this.obstacleIndex}/${this.input.mergedObstacles.length}`, + `coverage rects: ${this.input.coverageRects.length}`, + ].join("\n"), + ) + + return { + title: "Coverage Partition - Obstacles", + coordinateSystem: "cartesian", + rects, + points: [], + lines: [], + texts, + } + } +} diff --git a/lib/solvers/NodeMergeSolver/PlacementDiffSolver.ts b/lib/solvers/NodeMergeSolver/PlacementDiffSolver.ts new file mode 100644 index 0000000..0bbf3dd --- /dev/null +++ b/lib/solvers/NodeMergeSolver/PlacementDiffSolver.ts @@ -0,0 +1,124 @@ +import { BaseSolver } from "@tscircuit/solver-utils" +import type { GraphicsObject } from "graphics-debug" +import type { Placed3D } from "lib/rectdiff-types" +import { addGridLines, addInfoText, addSourcePlacements } from "./visualization" +import { buildCoverageGrid } from "./shared" + +export type PlacementDiffSolverInput = { + placed: Placed3D[] +} + +export class PlacementDiffSolver extends BaseSolver { + private xs: number[] = [] + private ys: number[] = [] + private xIndex = new Map() + private yIndex = new Map() + private layerCount = 0 + private layerDiffs: Int32Array[][] = [] + private placementIndex = 0 + private lastPlacement: Placed3D | null = null + + constructor(private input: PlacementDiffSolverInput) { + super() + } + + override _setup() { + const grid = buildCoverageGrid(this.input.placed) + this.xs = grid.xs + this.ys = grid.ys + this.xIndex = grid.xIndex + this.yIndex = grid.yIndex + this.layerCount = grid.layerCount + this.layerDiffs = grid.layerDiffs + this.stats = { placementIndex: 0, placed: this.input.placed.length } + } + + override _step() { + if (this.placementIndex >= this.input.placed.length) { + this.solved = true + this.lastPlacement = null + return + } + + const placement = this.input.placed[this.placementIndex++]! + this.lastPlacement = placement + + const x1 = this.xIndex.get(placement.rect.x) + const x2 = this.xIndex.get(placement.rect.x + placement.rect.width) + const y1 = this.yIndex.get(placement.rect.y) + const y2 = this.yIndex.get(placement.rect.y + placement.rect.height) + if ( + x1 === undefined || + x2 === undefined || + y1 === undefined || + y2 === undefined + ) { + return + } + + for (const z of placement.zLayers) { + const diff = this.layerDiffs[z] + if (!diff) continue + diff[y1]![x1]! += 1 + diff[y2]![x1]! -= 1 + diff[y1]![x2]! -= 1 + diff[y2]![x2]! += 1 + } + + this.stats.placementIndex = this.placementIndex + } + + override getOutput() { + return { + placed: this.input.placed, + xs: this.xs, + ys: this.ys, + layerCount: this.layerCount, + layerDiffs: this.layerDiffs.map((rows) => + rows.map((row) => Array.from(row)), + ), + } + } + + override visualize(): GraphicsObject { + const rects: NonNullable = [] + const lines: NonNullable = [] + const texts: NonNullable = [] + + addSourcePlacements(rects, this.input.placed) + addGridLines(lines, this.xs, this.ys) + + if (this.lastPlacement) { + rects.push({ + center: { + x: this.lastPlacement.rect.x + this.lastPlacement.rect.width / 2, + y: this.lastPlacement.rect.y + this.lastPlacement.rect.height / 2, + }, + width: this.lastPlacement.rect.width, + height: this.lastPlacement.rect.height, + fill: "rgba(59, 130, 246, 0.2)", + stroke: "rgba(37, 99, 235, 0.95)", + label: `apply\nz:${this.lastPlacement.zLayers.join(",")}`, + }) + } + + addInfoText( + texts, + this.xs, + this.ys, + [ + `phase: placements`, + `placements: ${this.placementIndex}/${this.input.placed.length}`, + ].join("\n"), + ) + + return { + title: "Coverage Partition - Placements", + coordinateSystem: "cartesian", + rects, + points: [], + lines, + texts, + } + } +} diff --git a/lib/solvers/NodeMergeSolver/shared.ts b/lib/solvers/NodeMergeSolver/shared.ts new file mode 100644 index 0000000..96941d4 --- /dev/null +++ b/lib/solvers/NodeMergeSolver/shared.ts @@ -0,0 +1,114 @@ +import type { Obstacle } from "lib/types/srj-types" +import type { Placed3D, Rect3d, XYRect } from "lib/rectdiff-types" +import { obstacleToXYRect, obstacleZs } from "../RectDiffSeedingSolver/layers" + +export type ActiveRect = { + minX: number + maxX: number + minY: number + maxY: number + mask: number +} + +export type ObstacleEntry = { + rect: XYRect + zLayers: number[] +} + +export const maskToZLayers = (mask: number, layerCount: number) => + Array.from({ length: layerCount }, (_, z) => z).filter( + (z) => (mask & (1 << z)) !== 0, + ) + +export const activeRectToRect3d = ( + rect: ActiveRect, + layerCount: number, +): Rect3d => ({ + minX: rect.minX, + minY: rect.minY, + maxX: rect.maxX, + maxY: rect.maxY, + zLayers: maskToZLayers(rect.mask, layerCount), +}) + +export function buildCoverageGrid(placed: Placed3D[]) { + const maxLayer = placed.reduce( + (currentMax, placement) => + Math.max(currentMax, ...placement.zLayers, currentMax), + 0, + ) + const layerCount = maxLayer + 1 + + const xs = Array.from( + new Set( + placed.flatMap((placement) => [ + placement.rect.x, + placement.rect.x + placement.rect.width, + ]), + ), + ).sort((a, b) => a - b) + const ys = Array.from( + new Set( + placed.flatMap((placement) => [ + placement.rect.y, + placement.rect.y + placement.rect.height, + ]), + ), + ).sort((a, b) => a - b) + + const xIndex = new Map(xs.map((x, index) => [x, index])) + const yIndex = new Map(ys.map((y, index) => [y, index])) + const layerDiffs = Array.from({ length: layerCount }, () => + Array.from( + { length: Math.max(1, ys.length + 1) }, + () => new Int32Array(Math.max(1, xs.length + 1)), + ), + ) + + return { + placed, + layerCount, + xs, + ys, + xIndex, + yIndex, + layerDiffs, + } +} + +export function buildMergedObstacles(params: { + obstacles: Obstacle[] + zIndexByName: Map + obstacleClearance?: number +}): ObstacleEntry[] { + const layersByKey = new Map }>() + + for (const obstacle of params.obstacles ?? []) { + const baseRect = obstacleToXYRect(obstacle) + if (!baseRect) continue + const rect = params.obstacleClearance + ? { + x: baseRect.x - params.obstacleClearance, + y: baseRect.y - params.obstacleClearance, + width: baseRect.width + 2 * params.obstacleClearance, + height: baseRect.height + 2 * params.obstacleClearance, + } + : baseRect + const zLayers = + obstacle.zLayers?.length && obstacle.zLayers.length > 0 + ? obstacle.zLayers + : obstacleZs(obstacle, params.zIndexByName) + const key = `${rect.x}:${rect.y}:${rect.width}:${rect.height}` + let entry = layersByKey.get(key) + if (!entry) { + entry = { rect, layers: new Set() } + layersByKey.set(key, entry) + } + zLayers.forEach((layer: number) => entry!.layers.add(layer)) + } + + return Array.from(layersByKey.values(), ({ rect, layers }) => ({ + rect, + zLayers: Array.from(layers).sort((a, b) => a - b), + })) +} diff --git a/lib/solvers/NodeMergeSolver/visualization.ts b/lib/solvers/NodeMergeSolver/visualization.ts new file mode 100644 index 0000000..c9764c4 --- /dev/null +++ b/lib/solvers/NodeMergeSolver/visualization.ts @@ -0,0 +1,111 @@ +import type { GraphicsObject } from "graphics-debug" +import type { Placed3D, Rect3d } from "lib/rectdiff-types" +import { getColorForZLayer } from "lib/utils/getColorForZLayer" +import type { ActiveRect } from "./shared" +import { maskToZLayers } from "./shared" + +export function addSourcePlacements( + rects: NonNullable, + placed: Placed3D[], +) { + for (const placement of placed) { + rects.push({ + center: { + x: placement.rect.x + placement.rect.width / 2, + y: placement.rect.y + placement.rect.height / 2, + }, + width: placement.rect.width, + height: placement.rect.height, + fill: "rgba(148, 163, 184, 0.08)", + stroke: "rgba(100, 116, 139, 0.3)", + label: `src\nz:${placement.zLayers.join(",")}`, + }) + } +} + +export function addGridLines( + lines: NonNullable, + xs: number[], + ys: number[], +) { + for (const x of xs) { + lines.push({ + points: [ + { x, y: ys[0] ?? 0 }, + { x, y: ys[ys.length - 1] ?? 0 }, + ], + strokeColor: "rgba(148, 163, 184, 0.25)", + strokeDash: "3 3", + }) + } + for (const y of ys) { + lines.push({ + points: [ + { x: xs[0] ?? 0, y }, + { x: xs[xs.length - 1] ?? 0, y }, + ], + strokeColor: "rgba(148, 163, 184, 0.25)", + strokeDash: "3 3", + }) + } +} + +export function addRect3dOverlays( + rects: NonNullable, + rect3ds: Rect3d[], + prefix: string, +) { + for (const rect of rect3ds) { + const colors = getColorForZLayer(rect.zLayers) + rects.push({ + center: { + x: (rect.minX + rect.maxX) / 2, + y: (rect.minY + rect.maxY) / 2, + }, + width: rect.maxX - rect.minX, + height: rect.maxY - rect.minY, + fill: rect.isObstacle ? "rgba(239, 68, 68, 0.25)" : colors.fill, + stroke: rect.isObstacle ? "rgba(220, 38, 38, 0.95)" : colors.stroke, + layer: `z${rect.zLayers.join(",")}`, + label: `${prefix}\nz:${rect.zLayers.join(",")}`, + }) + } +} + +export function addActiveRects( + rects: NonNullable, + activeRects: ActiveRect[], + layerCount: number, +) { + for (const rect of activeRects) { + const zLayers = maskToZLayers(rect.mask, layerCount) + const colors = getColorForZLayer(zLayers) + rects.push({ + center: { + x: (rect.minX + rect.maxX) / 2, + y: (rect.minY + rect.maxY) / 2, + }, + width: rect.maxX - rect.minX, + height: rect.maxY - rect.minY, + fill: "rgba(0,0,0,0)", + stroke: colors.stroke, + strokeDash: "5 3", + label: `active\nz:${zLayers.join(",")}`, + }) + } +} + +export function addInfoText( + texts: NonNullable, + xs: number[], + ys: number[], + info: string, +) { + texts.push({ + x: xs[0] ?? 0, + y: ys[0] ?? 0, + text: info, + anchorSide: "top_left", + fontSize: 0.9, + }) +} diff --git a/lib/solvers/RectDiffExpansionSolver/RectDiffExpansionSolver.ts b/lib/solvers/RectDiffExpansionSolver/RectDiffExpansionSolver.ts index 7387447..24f1f29 100644 --- a/lib/solvers/RectDiffExpansionSolver/RectDiffExpansionSolver.ts +++ b/lib/solvers/RectDiffExpansionSolver/RectDiffExpansionSolver.ts @@ -2,9 +2,7 @@ import { BaseSolver } from "@tscircuit/solver-utils" import type { GraphicsObject } from "graphics-debug" import type { CapacityMeshNode, RTreeRect } from "lib/types/capacity-mesh-types" import { expandRectFromSeed } from "../../utils/expandRectFromSeed" -import { finalizeRects } from "../../utils/finalizeRects" import { resizeSoftOverlaps } from "../../utils/resizeSoftOverlaps" -import { rectsToMeshNodes } from "./rectsToMeshNodes" import type { XYRect, Candidate3D, Placed3D } from "../../rectdiff-types" import type { Obstacle } from "lib/types/srj-types" import RBush from "rbush" @@ -42,7 +40,6 @@ export type RectDiffExpansionSolverInput = { */ export class RectDiffExpansionSolver extends BaseSolver { placedIndexByLayer: Array> = [] - _meshNodes: CapacityMeshNode[] = [] constructor(private input: RectDiffExpansionSolverInput) { super() } @@ -132,15 +129,6 @@ export class RectDiffExpansionSolver extends BaseSolver { private finalizeIfNeeded() { if (this.solved) return - - const rects = finalizeRects({ - placed: this.input.placed, - obstacles: this.input.obstacles, - zIndexByName: this.input.zIndexByName, - boardVoidRects: this.input.boardVoidRects, - obstacleClearance: this.input.obstacleClearance, - }) - this._meshNodes = rectsToMeshNodes(rects) this.solved = true } @@ -153,11 +141,11 @@ export class RectDiffExpansionSolver extends BaseSolver { return Math.min(0.999, base + frac * (1 / (grids + 1))) } - override getOutput(): { meshNodes: CapacityMeshNode[] } { - if (this.solved) return { meshNodes: this._meshNodes } - - // Provide a live preview of the placements before finalization so debuggers - // can inspect intermediary states without forcing the solver to finish. + override getOutput(): { + meshNodes: CapacityMeshNode[] + placed: Placed3D[] + options: RectDiffExpansionSolverInput["options"] + } { const previewNodes: CapacityMeshNode[] = this.input.placed.map( (placement, idx) => ({ capacityMeshNodeId: `expand-preview-${idx}`, @@ -171,7 +159,11 @@ export class RectDiffExpansionSolver extends BaseSolver { layer: `z${placement.zLayers.join(",")}`, }), ) - return { meshNodes: previewNodes } + return { + meshNodes: previewNodes, + placed: this.input.placed, + options: this.input.options, + } } /** Simple visualization of expanded placements. */ diff --git a/lib/solvers/RectDiffGridSolverPipeline/RectDiffGridSolverPipeline.ts b/lib/solvers/RectDiffGridSolverPipeline/RectDiffGridSolverPipeline.ts index 8668920..df358b2 100644 --- a/lib/solvers/RectDiffGridSolverPipeline/RectDiffGridSolverPipeline.ts +++ b/lib/solvers/RectDiffGridSolverPipeline/RectDiffGridSolverPipeline.ts @@ -12,10 +12,15 @@ import type { GridFill3DOptions, XYRect } from "lib/rectdiff-types" import type { CapacityMeshNode, RTreeRect } from "lib/types/capacity-mesh-types" import { RectDiffSeedingSolver } from "lib/solvers/RectDiffSeedingSolver/RectDiffSeedingSolver" import { RectDiffExpansionSolver } from "lib/solvers/RectDiffExpansionSolver/RectDiffExpansionSolver" +import { NodeMergeSolver } from "lib/solvers/NodeMergeSolver/NodeMergeSolver" import type { GraphicsObject } from "graphics-debug" import RBush from "rbush" import { buildObstacleIndexesByLayer } from "./buildObstacleIndexes" import type { Bounds } from "@tscircuit/math-utils" +import { + buildMergedObstacles, + type ObstacleEntry, +} from "../NodeMergeSolver/shared" export type RectDiffGridSolverPipelineInput = { bounds: Bounds @@ -34,9 +39,11 @@ export type RectDiffGridSolverPipelineInput = { export class RectDiffGridSolverPipeline extends BasePipelineSolver { rectDiffSeedingSolver?: RectDiffSeedingSolver rectDiffExpansionSolver?: RectDiffExpansionSolver + nodeMergeSolver?: NodeMergeSolver private obstacleIndexByLayer: Array> private layerNames: string[] private zIndexByName: Map + private mergedObstacles: ObstacleEntry[] constructor(inputProblem: RectDiffGridSolverPipelineInput) { super(inputProblem) @@ -56,6 +63,11 @@ export class RectDiffGridSolverPipeline extends BasePipelineSolver[] = [ @@ -112,6 +124,24 @@ export class RectDiffGridSolverPipeline extends BasePipelineSolver { + const output = pipeline.rectDiffExpansionSolver?.getOutput() + if (!output) { + throw new Error("RectDiffExpansionSolver did not produce output") + } + return [ + { + placed: output.placed, + boardVoidRects: pipeline.inputProblem.boardVoidRects ?? [], + mergedObstacles: pipeline.mergedObstacles, + maxAspectRatio: output.options.maxAspectRatio, + }, + ] + }, + ), ] override getConstructorParams() { @@ -119,6 +149,9 @@ export class RectDiffGridSolverPipeline extends BasePipelineSolver obstacleClearance?: number }): Rect3d[] { - // Convert all placed (free space) nodes to output format - const out: Rect3d[] = params.placed.map((p) => ({ - minX: p.rect.x, - minY: p.rect.y, - maxX: p.rect.x + p.rect.width, - maxY: p.rect.y + p.rect.height, - zLayers: [...p.zLayers].sort((a, b) => a - b), - })) + // Re-partition free-space by actual XY coverage so overlapping per-layer + // placements become explicit multi-layer nodes in the final mesh. + const out: Rect3d[] = partitionPlacedRectsByCoverage(params.placed) const layersByKey = new Map }>() diff --git a/lib/utils/partitionPlacedRectsByCoverage.ts b/lib/utils/partitionPlacedRectsByCoverage.ts new file mode 100644 index 0000000..2f22e08 --- /dev/null +++ b/lib/utils/partitionPlacedRectsByCoverage.ts @@ -0,0 +1,203 @@ +import type { Placed3D, Rect3d } from "../rectdiff-types" + +const rectKey = (rect: { + minX: number + minY: number + maxX: number + maxY: number +}) => `${rect.minX}:${rect.minY}:${rect.maxX}:${rect.maxY}` + +export function partitionPlacedRectsByCoverage(placed: Placed3D[]): Rect3d[] { + if (placed.length === 0) return [] + + const xs = Array.from( + new Set( + placed.flatMap((placement) => [ + placement.rect.x, + placement.rect.x + placement.rect.width, + ]), + ), + ).sort((a, b) => a - b) + const ys = Array.from( + new Set( + placed.flatMap((placement) => [ + placement.rect.y, + placement.rect.y + placement.rect.height, + ]), + ), + ).sort((a, b) => a - b) + + const xIndex = new Map(xs.map((x, index) => [x, index])) + const yIndex = new Map(ys.map((y, index) => [y, index])) + const maxLayer = placed.reduce( + (currentMax, placement) => + Math.max(currentMax, ...placement.zLayers, currentMax), + 0, + ) + const layerCount = maxLayer + 1 + + const layerDiffs = Array.from({ length: layerCount }, () => + Array.from({ length: ys.length + 1 }, () => new Int32Array(xs.length + 1)), + ) + + for (const placement of placed) { + const x1 = xIndex.get(placement.rect.x) + const x2 = xIndex.get(placement.rect.x + placement.rect.width) + const y1 = yIndex.get(placement.rect.y) + const y2 = yIndex.get(placement.rect.y + placement.rect.height) + if ( + x1 === undefined || + x2 === undefined || + y1 === undefined || + y2 === undefined + ) { + continue + } + + for (const z of placement.zLayers) { + const diff = layerDiffs[z] + if (!diff) continue + diff[y1]![x1]! += 1 + diff[y2]![x1]! -= 1 + diff[y1]![x2]! -= 1 + diff[y2]![x2]! += 1 + } + } + + const layerMasks = Array.from( + { length: ys.length - 1 }, + () => new Uint32Array(xs.length - 1), + ) + + for (let z = 0; z < layerCount; z++) { + const diff = layerDiffs[z]! + for (let y = 0; y < ys.length - 1; y++) { + let rowRunning = 0 + for (let x = 0; x < xs.length - 1; x++) { + rowRunning += diff[y]![x]! + const cellCount = rowRunning + (y > 0 ? diff[y - 1]![x]! : 0) + diff[y]![x] = cellCount + if (cellCount > 0) { + layerMasks[y]![x]! |= 1 << z + } + } + } + } + + const activeRects = new Map< + string, + { + minX: number + maxX: number + minY: number + maxY: number + mask: number + } + >() + const out: Rect3d[] = [] + + for (let y = 0; y < ys.length - 1; y++) { + const nextActiveKeys = new Set() + let x = 0 + while (x < xs.length - 1) { + const mask = layerMasks[y]![x]! + if (mask === 0) { + x += 1 + continue + } + const xStart = x + x += 1 + while (x < xs.length - 1 && layerMasks[y]![x] === mask) x += 1 + + const key = `${mask}:${xs[xStart]}:${xs[x]}` + const existing = activeRects.get(key) + if (existing && existing.maxY === ys[y]) { + existing.maxY = ys[y + 1]! + } else { + if (existing) { + out.push({ + minX: existing.minX, + minY: existing.minY, + maxX: existing.maxX, + maxY: existing.maxY, + zLayers: Array.from( + { length: layerCount }, + (_, zIndex) => zIndex, + ).filter((zIndex) => (existing.mask & (1 << zIndex)) !== 0), + }) + } + activeRects.set(key, { + minX: xs[xStart]!, + maxX: xs[x]!, + minY: ys[y]!, + maxY: ys[y + 1]!, + mask, + }) + } + nextActiveKeys.add(key) + } + + for (const [key, rect] of Array.from(activeRects.entries())) { + if (!nextActiveKeys.has(key)) { + out.push({ + minX: rect.minX, + minY: rect.minY, + maxX: rect.maxX, + maxY: rect.maxY, + zLayers: Array.from( + { length: layerCount }, + (_, zIndex) => zIndex, + ).filter((zIndex) => (rect.mask & (1 << zIndex)) !== 0), + }) + activeRects.delete(key) + } + } + } + + for (const rect of activeRects.values()) { + out.push({ + minX: rect.minX, + minY: rect.minY, + maxX: rect.maxX, + maxY: rect.maxY, + zLayers: Array.from({ length: layerCount }, (_, zIndex) => zIndex).filter( + (zIndex) => (rect.mask & (1 << zIndex)) !== 0, + ), + }) + } + + const mergedByFootprint = new Map< + string, + { + minX: number + minY: number + maxX: number + maxY: number + zLayers: Set + } + >() + + for (const rect of out) { + const key = rectKey(rect) + let entry = mergedByFootprint.get(key) + if (!entry) { + entry = { + minX: rect.minX, + minY: rect.minY, + maxX: rect.maxX, + maxY: rect.maxY, + zLayers: new Set(), + } + mergedByFootprint.set(key, entry) + } + for (const z of rect.zLayers) entry.zLayers.add(z) + } + + return Array.from(mergedByFootprint.values(), (entry) => ({ + minX: entry.minX, + minY: entry.minY, + maxX: entry.maxX, + maxY: entry.maxY, + zLayers: Array.from(entry.zLayers).sort((a, b) => a - b), + })) +} diff --git a/tests/rect-diff-solver.test.ts b/tests/rect-diff-solver.test.ts index 164ae24..47db551 100644 --- a/tests/rect-diff-solver.test.ts +++ b/tests/rect-diff-solver.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test" import { RectDiffPipeline } from "../lib/RectDiffPipeline" +import { finalizeRects } from "../lib/utils/finalizeRects" import type { SimpleRouteJson } from "../lib/types/srj-types" test("RectDiffSolver creates mesh nodes with grid-based approach", () => { @@ -141,3 +142,73 @@ test("multi-layer mesh generation", () => { const multiLayerNodes = mesh.filter((n) => (n.availableZ?.length ?? 0) >= 2) expect(multiLayerNodes.length).toBeGreaterThan(0) }) + +test("finalizeRects merges identical free-space footprints across layers", () => { + const rects = finalizeRects({ + placed: [ + { + rect: { x: 1, y: 2, width: 3, height: 4 }, + zLayers: [0], + }, + { + rect: { x: 1, y: 2, width: 3, height: 4 }, + zLayers: [1], + }, + ], + obstacles: [], + boardVoidRects: [], + zIndexByName: new Map(), + }) + + expect(rects).toEqual([ + { + minX: 1, + minY: 2, + maxX: 4, + maxY: 6, + zLayers: [0, 1], + }, + ]) +}) + +test("finalizeRects partitions overlapping free-space into shared multi-layer coverage", () => { + const rects = finalizeRects({ + placed: [ + { + rect: { x: 0, y: 0, width: 4, height: 2 }, + zLayers: [0], + }, + { + rect: { x: 2, y: 0, width: 4, height: 2 }, + zLayers: [1], + }, + ], + obstacles: [], + boardVoidRects: [], + zIndexByName: new Map(), + }) + + expect(rects).toEqual([ + { + minX: 0, + minY: 0, + maxX: 2, + maxY: 2, + zLayers: [0], + }, + { + minX: 2, + minY: 0, + maxX: 4, + maxY: 2, + zLayers: [0, 1], + }, + { + minX: 4, + minY: 0, + maxX: 6, + maxY: 2, + zLayers: [1], + }, + ]) +}) diff --git a/tests/solver/arduino-uno-inner2-ground-bottom-power/__snapshots__/arduino-uno-inner2-ground-bottom-power.snap.svg b/tests/solver/arduino-uno-inner2-ground-bottom-power/__snapshots__/arduino-uno-inner2-ground-bottom-power.snap.svg index 36296f8..21cc451 100644 --- a/tests/solver/arduino-uno-inner2-ground-bottom-power/__snapshots__/arduino-uno-inner2-ground-bottom-power.snap.svg +++ b/tests/solver/arduino-uno-inner2-ground-bottom-power/__snapshots__/arduino-uno-inner2-ground-bottom-power.snap.svg @@ -1,4 +1,4 @@ -Layer z=0Layer z=1Layer z=2Layer z=3