diff --git a/.gitignore b/.gitignore index 369e71b..e3d1ca2 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,5 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json .DS_Store .vscode -repomix-output.xml \ No newline at end of file +repomix-output.xml +*.diff.png \ No newline at end of file diff --git a/components/SolverDebugger3d.tsx b/components/SolverDebugger3d.tsx index 9a7f273..28ccd29 100644 --- a/components/SolverDebugger3d.tsx +++ b/components/SolverDebugger3d.tsx @@ -80,9 +80,9 @@ function buildPrismsFromNodes( const xyKey = (n: CapacityMeshNode) => `${n.center.x.toFixed(8)}|${n.center.y.toFixed(8)}|${n.width.toFixed(8)}|${n.height.toFixed(8)}` const azKey = (n: CapacityMeshNode) => { - const zs = (n.availableZ && n.availableZ.length ? [...new Set(n.availableZ)] : [0]).sort( - (a, b) => a - b, - ) + const zs = ( + n.availableZ && n.availableZ.length ? [...new Set(n.availableZ)] : [0] + ).sort((a, b) => a - b) return `zset:${zs.join(",")}` } const key = (n: CapacityMeshNode) => `${xyKey(n)}|${azKey(n)}` @@ -226,8 +226,13 @@ const ThreeBoardView: React.FC<{ const w = el.clientWidth || 800 const h = el.clientHeight || height - const renderer = new THREE.WebGLRenderer({ antialias: true }) - renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)) + const renderer = new THREE.WebGLRenderer({ + antialias: true, + alpha: true, + premultipliedAlpha: false, + }) + // Increase pixel ratio for better alphaHash quality + renderer.setPixelRatio(window.devicePixelRatio) renderer.setSize(w, h) el.innerHTML = "" el.appendChild(renderer.domElement) @@ -300,7 +305,7 @@ const ThreeBoardView: React.FC<{ const dx = b.maxX - b.minX const dz = b.maxY - b.minY // map board Y -> three Z const dy = (b.z1 - b.z0) * layerThickness - const cx = (b.minX + b.maxX) / 2 + const cx = -((b.minX + b.maxX) / 2) // negate X to match expected orientation const cz = (b.minY + b.maxY) / 2 // Negate Y so z=0 is at top, higher z goes down const cy = -((b.z0 + b.z1) / 2) * layerThickness @@ -321,6 +326,7 @@ const ThreeBoardView: React.FC<{ opacity: clampedOpacity, transparent: clampedOpacity < 1, alphaHash: clampedOpacity < 1, + alphaToCoverage: true, }) const mesh = new THREE.Mesh(geom, mat) @@ -472,7 +478,7 @@ const ThreeBoardView: React.FC<{ const dist = size * 2.0 // Camera looks from above-right-front, with negative Y being "up" (z=0 at top) camera.position.set( - fitBox.maxX + dist * 0.6, + -(fitBox.maxX + dist * 0.6), // negate X to account for flipped axis -dy / 2 + dist, // negative Y is up, so position above the center fitBox.maxY + dist * 0.6, ) @@ -480,7 +486,7 @@ const ThreeBoardView: React.FC<{ camera.far = dist * 10 + size * 10 camera.updateProjectionMatrix() controls.target.set( - (fitBox.minX + fitBox.maxX) / 2, + -((fitBox.minX + fitBox.maxX) / 2), // negate X to account for flipped axis -dy / 2, // center of the inverted Y range (fitBox.minY + fitBox.maxY) / 2, ) @@ -553,7 +559,7 @@ export const SolverDebugger3d: React.FC = ({ solver, simpleRouteJson, layerThickness = 1, - height = 460, + height = 600, defaultShowRoot = true, defaultShowObstacles = false, // don't show obstacles by default defaultShowOutput = true, @@ -568,31 +574,58 @@ export const SolverDebugger3d: React.FC = ({ const [showOutput, setShowOutput] = useState(defaultShowOutput) const [wireframeOutput, setWireframeOutput] = useState(defaultWireframeOutput) - const [meshOpacity, setMeshOpacity] = useState(1) // fully opaque by default - const [shrinkBoxes, setShrinkBoxes] = useState(false) + const [meshOpacity, setMeshOpacity] = useState(0.6) + const [shrinkBoxes, setShrinkBoxes] = useState(true) const [boxShrinkAmount, setBoxShrinkAmount] = useState(0.1) - const [showBorders, setShowBorders] = useState(false) + const [showBorders, setShowBorders] = useState(true) - // Keep mesh nodes in sync with solver - const meshNodes = useMemo(() => { + // Mesh nodes state - updated when solver completes or during stepping + const [meshNodes, setMeshNodes] = useState([]) + + // Update mesh nodes from solver output + const updateMeshNodes = useCallback(() => { try { - if ( - (solver as any).solved !== true && - typeof solver.solve === "function" - ) { - solver.solve() - } - } catch {} - return solver.getOutput().meshNodes ?? [] + const output = solver.getOutput() + const nodes = output.meshNodes ?? [] + setMeshNodes(nodes) + } catch { + setMeshNodes([]) + } }, [solver]) + // Initialize mesh nodes on mount (in case solver is already solved) + useEffect(() => { + updateMeshNodes() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + // Handle solver completion + const handleSolverCompleted = useCallback(() => { + updateMeshNodes() + }, [updateMeshNodes]) + + // Poll for updates during stepping (GenericSolverDebugger doesn't have onStep) + useEffect(() => { + const interval = setInterval(() => { + // Only update if solver has output available + if ((solver as any).solved || (solver as any).stats?.placed > 0) { + updateMeshNodes() + } + }, 100) // Poll every 100ms during active solving + + return () => clearInterval(interval) + }, [updateMeshNodes, solver]) + const toggle3d = useCallback(() => setShow3d((s) => !s), []) const rebuild = useCallback(() => setRebuildKey((k) => k + 1), []) return ( <>
- +
b + eps -} -function intersect1D( - a0: number, - a1: number, - b0: number, - b1: number, - eps: number, -): [number, number] | null { - const lo = Math.max(a0, b0) - const hi = Math.min(a1, b1) - return gt(hi, lo, eps) ? [lo, hi] : null -} -function nonEmptyBox(b: Box, eps: number) { - return gt(b.maxX, b.minX, eps) && gt(b.maxY, b.minY, eps) && b.z1 - b.z0 > 0 -} -function ensureContiguous(z: number[]) { - const zs = [...new Set(z)].sort((a, b) => a - b) - for (let i = 1; i < zs.length; i++) { - if (zs[i] !== zs[i - 1] + 1) { - throw new Error( - `zLayers must be contiguous integers: ${JSON.stringify(z)}`, - ) - } - } - return zs -} -function toBoxFromRoot(root: Rect3d): Box { - const z = ensureContiguous(root.zLayers) - return { - minX: root.minX, - minY: root.minY, - maxX: root.maxX, - maxY: root.maxY, - z0: z[0], - z1: z[z.length - 1] + 1, - } -} -function toBoxFromCutout(c: Rect3d, rootZs: Set): Box | null { - const filtered = [...new Set(c.zLayers)] - .filter((z) => rootZs.has(z)) - .sort((a, b) => a - b) - if (filtered.length === 0) return null - const zs = ensureContiguous(filtered) - return { - minX: c.minX, - minY: c.minY, - maxX: c.maxX, - maxY: c.maxY, - z0: zs[0], - z1: zs[zs.length - 1] + 1, - } -} -function intersects(a: Box, b: Box, eps: number) { - return ( - intersect1D(a.minX, a.maxX, b.minX, b.maxX, eps) && - intersect1D(a.minY, a.maxY, b.minY, b.maxY, eps) && - Math.min(a.z1, b.z1) > Math.max(a.z0, b.z0) - ) -} -function subtractBox(A: Box, B: Box, eps: number): Box[] { - if (!intersects(A, B, eps)) return [A] - const Xi = intersect1D(A.minX, A.maxX, B.minX, B.maxX, eps) - const Yi = intersect1D(A.minY, A.maxY, B.minY, B.maxY, eps) - const Z0 = Math.max(A.z0, B.z0) - const Z1 = Math.min(A.z1, B.z1) - if (!Xi || !Yi || !(Z1 > Z0)) return [A] - - const [X0, X1] = Xi - const [Y0, Y1] = Yi - const out: Box[] = [] - - // Left slab - if (gt(X0, A.minX, eps)) - out.push({ - minX: A.minX, - maxX: X0, - minY: A.minY, - maxY: A.maxY, - z0: A.z0, - z1: A.z1, - }) - // Right slab - if (gt(A.maxX, X1, eps)) - out.push({ - minX: X1, - maxX: A.maxX, - minY: A.minY, - maxY: A.maxY, - z0: A.z0, - z1: A.z1, - }) - - // Middle X range -> split along Y - const midX0 = Math.max(A.minX, X0) - const midX1 = Math.min(A.maxX, X1) - - // Front (lower Y) - if (gt(Y0, A.minY, eps)) - out.push({ - minX: midX0, - maxX: midX1, - minY: A.minY, - maxY: Y0, - z0: A.z0, - z1: A.z1, - }) - // Back (upper Y) - if (gt(A.maxY, Y1, eps)) - out.push({ - minX: midX0, - maxX: midX1, - minY: Y1, - maxY: A.maxY, - z0: A.z0, - z1: A.z1, - }) - - // Center X,Y -> split along Z - const midY0 = Math.max(A.minY, Y0) - const midY1 = Math.min(A.maxY, Y1) - - if (Z0 > A.z0) - out.push({ - minX: midX0, - maxX: midX1, - minY: midY0, - maxY: midY1, - z0: A.z0, - z1: Z0, - }) - if (A.z1 > Z1) - out.push({ - minX: midX0, - maxX: midX1, - minY: midY0, - maxY: midY1, - z0: Z1, - z1: A.z1, - }) - - return out.filter((b) => nonEmptyBox(b, eps)) -} -function subtractCutoutFromList(boxes: Box[], cutout: Box, eps: number) { - const out: Box[] = [] - for (const b of boxes) { - if (intersects(b, cutout, eps)) { - const parts = subtractBox(b, cutout, eps) - for (const p of parts) out.push(p) - } else { - out.push(b) - } - } - return out -} -function mulberry32(a: number) { - return function () { - let t = (a += 0x6d2b79f5) - t = Math.imul(t ^ (t >>> 15), t | 1) - t ^= t + Math.imul(t ^ (t >>> 7), t | 61) - return ((t ^ (t >>> 14)) >>> 0) / 4294967296 - } -} -function subtractAll(rootBox: Box, cutoutBoxes: Box[], eps: number, seed = 0) { - const rnd = mulberry32(seed) - const cuts = [...cutoutBoxes].sort( - (a, b) => - a.z0 - b.z0 || - a.z1 - b.z1 || - a.minY - b.minY || - a.minX - b.minX || - a.maxX - b.maxX || - a.maxY - b.maxY, - ) - if (seed !== 0) { - for (let i = cuts.length - 1; i > 0; i--) { - const j = Math.floor(rnd() * (i + 1)) - const tmp = cuts[i] - cuts[i] = cuts[j] - cuts[j] = tmp - } - } - let free = [rootBox] - for (const c of cuts) free = subtractCutoutFromList(free, c, eps) - return free -} -function mergeAlongAxis(boxes: Box[], axis: "X" | "Y" | "Z", eps: number) { - return mergeAlongAxisWithMinXY(boxes, axis, eps, undefined) -} - -function mergeAlongAxisWithMinXY( - boxes: Box[], - axis: "X" | "Y" | "Z", - eps: number, - minLenForMultiLayerXY: number | undefined, -) { - if (boxes.length <= 1) return boxes - const groups = new Map() - const R = (v: number) => v.toFixed(12) - - const keyX = (b: Box) => `y:${R(b.minY)}-${R(b.maxY)}|z:${b.z0}-${b.z1}` - const keyY = (b: Box) => `x:${R(b.minX)}-${R(b.maxX)}|z:${b.z0}-${b.z1}` - const keyZ = (b: Box) => - `x:${R(b.minX)}-${R(b.maxX)}|y:${R(b.minY)}-${R(b.maxY)}` - const keyFn = axis === "X" ? keyX : axis === "Y" ? keyY : keyZ - - for (const b of boxes) { - const k = keyFn(b) - const arr = groups.get(k) - if (arr) arr.push(b) - else groups.set(k, [b]) - } - - const out: Box[] = [] - for (const arr of groups.values()) { - if (axis === "X") { - arr.sort((a, b) => a.minX - b.minX || a.maxX - b.maxX) - let cur = arr[0] - for (let i = 1; i < arr.length; i++) { - const n = arr[i] - if (almostEq(cur.maxX, n.minX, eps)) { - cur = { ...cur, maxX: n.maxX } - } else { - out.push(cur) - cur = n - } - } - out.push(cur) - } else if (axis === "Y") { - arr.sort((a, b) => a.minY - b.minY || a.maxY - b.maxY) - let cur = arr[0] - for (let i = 1; i < arr.length; i++) { - const n = arr[i] - if (almostEq(cur.maxY, n.minY, eps)) { - cur = { ...cur, maxY: n.maxY } - } else { - out.push(cur) - cur = n - } - } - out.push(cur) - } else { - arr.sort((a, b) => a.z0 - b.z0 || a.z1 - b.z1) - let cur = arr[0] - for (let i = 1; i < arr.length; i++) { - const n = arr[i] - if (cur.z1 === n.z0) { - // Only merge across Z if XY footprint is large enough (if a threshold is provided) - const allowMerge = - minLenForMultiLayerXY == null || - ((cur.maxX - cur.minX) >= (minLenForMultiLayerXY - eps) && - (cur.maxY - cur.minY) >= (minLenForMultiLayerXY - eps)) - if (allowMerge) { - cur = { ...cur, z1: n.z1 } - } else { - out.push(cur) - cur = n - } - } else { - out.push(cur) - cur = n - } - } - out.push(cur) - } - } - return out -} -function coalesce( - boxes: Box[], - order: Array<"X" | "Y" | "Z">, - eps: number, - maxCycles = 4, - minLenForMultiLayerXY?: number, -) { - let cur = boxes.slice() - for (let cycle = 0; cycle < maxCycles; cycle++) { - const prevLen = cur.length - for (const ax of order) - cur = mergeAlongAxisWithMinXY(cur, ax, eps, minLenForMultiLayerXY) - if (cur.length === prevLen) break - } - return cur -} -function permutations(arr: T[]) { - const res: T[][] = [] - const used = Array(arr.length).fill(false) - const curr: T[] = [] - const backtrack = () => { - if (curr.length === arr.length) { - res.push(curr.slice()) - return - } - for (let i = 0; i < arr.length; i++) { - if (used[i]) continue - used[i] = true - curr.push(arr[i]) - backtrack() - curr.pop() - used[i] = false - } - } - backtrack() - return res -} -function scoreBoxes(boxes: Box[], thickness: number, p: number) { - let s = 0 - for (const b of boxes) { - const dx = b.maxX - b.minX - const dy = b.maxY - b.minY - const dz = (b.z1 - b.z0) * thickness - const vol = dx * dy * dz - s += Math.pow(vol, p) - } - return s -} -function totalVolume(boxes: Box[], thickness: number) { - let v = 0 - for (const b of boxes) { - v += (b.maxX - b.minX) * (b.maxY - b.minY) * (b.z1 - b.z0) * thickness - } - return v -} -function boxToRect3d(b: Box): Rect3d { - const zLayers: number[] = [] - for (let z = b.z0; z < b.z1; z++) zLayers.push(z) - return { minX: b.minX, minY: b.minY, maxX: b.maxX, maxY: b.maxY, zLayers } -} -/** - * After coalescing, enforce: if a box spans multiple layers, it must be at least - * `minLenForMultiLayerXY` in BOTH X and Y. Otherwise, split it into per-layer slabs. - */ -function enforceMultiLayerMinXY( - boxes: Box[], - minLenForMultiLayerXY: number, - eps: number, -): Box[] { - if (!(minLenForMultiLayerXY > 0)) return boxes.slice() - const out: Box[] = [] - for (const b of boxes) { - const layers = b.z1 - b.z0 - if (layers <= 1) { - out.push(b) - continue - } - const dx = b.maxX - b.minX - const dy = b.maxY - b.minY - const canSpan = - dx >= (minLenForMultiLayerXY - eps) && dy >= (minLenForMultiLayerXY - eps) - if (canSpan) { - out.push(b) - } else { - // Split across Z into single-layer boxes to fully fill the space - for (let z = b.z0; z < b.z1; z++) { - out.push({ ...b, z0: z, z1: z + 1 }) - } - } - } - return out -} -function solveNoDiscretization( - problem: { rootRect: Rect3d; cutouts: Rect3d[] }, - options: { - Z_LAYER_THICKNESS: number - p: number - order: "AUTO" | "X,Y,Z" | "X,Z,Y" | "Y,X,Z" | "Y,Z,X" | "Z,X,Y" | "Z,Y,X" - maxCycles: number - eps: number - seed: number - /** Minimum XY length required to allow multi-layer spans */ - minLenForMultiLayerXY?: number - }, -): SolveResult { - const { - Z_LAYER_THICKNESS, - p, - order, - maxCycles, - eps, - seed, - minLenForMultiLayerXY, - } = options - const rootZs = new Set(problem.rootRect.zLayers) - const rootBox = toBoxFromRoot(problem.rootRect) - const cutoutBoxes = problem.cutouts - .map((c) => toBoxFromCutout(c, rootZs)) - .filter((b): b is Box => !!b) - - if (!nonEmptyBox(rootBox, eps)) { - throw new Error("Root box is empty.") - } +import type { GridFill3DOptions, RectDiffState } from "./rectdiff/types" +import { initState, stepGrid, stepExpansion, finalizeRects, computeProgress } from "./rectdiff/engine" +import { rectsToMeshNodes } from "./rectdiff/rectsToMeshNodes" - const diffBoxes = subtractAll(rootBox, cutoutBoxes, eps, seed) - const orders = - order === "AUTO" - ? permutations<"X" | "Y" | "Z">(["X", "Y", "Z"]) - : [order.split(",") as Array<"X" | "Y" | "Z">] - - let best: Box[] | null = null - let bestScore = -Infinity - let bestOrder: string | null = null - for (const ord of orders) { - const merged = coalesce( - diffBoxes, - ord, - eps, - maxCycles, - minLenForMultiLayerXY, - ) - const sc = scoreBoxes(merged, Z_LAYER_THICKNESS, p) - if (sc > bestScore) { - best = merged - bestScore = sc - bestOrder = ord.join(",") - } - } - // Enforce the XY-size constraint post-pass as well, to split any residual - // multi-layer boxes that are too small in X or Y. - const preBoxes = best ?? diffBoxes - const boxes = - minLenForMultiLayerXY != null - ? enforceMultiLayerMinXY(preBoxes, minLenForMultiLayerXY, eps) - : preBoxes - return { - boxes, - rects: boxes.map(boxToRect3d), - // Keep score consistent with final boxes - score: scoreBoxes(boxes, Z_LAYER_THICKNESS, p), - orderUsed: bestOrder ?? "X,Y,Z", - totalFreeVolume: totalVolume(boxes, Z_LAYER_THICKNESS), - } -} - -/** - * Utility: canonical layer ordering for deterministic z indexing. - * top -> innerN (ascending) -> bottom -> other (lexicographic) - */ -function layerSortKey(name: string) { - if (name.toLowerCase() === "top") return -1_000_000 - if (name.toLowerCase() === "bottom") return 1_000_000 - const m = /^inner(\d+)$/i.exec(name) - if (m) return parseInt(m[1]!, 10) || 0 - return 100 + name.toLowerCase().charCodeAt(0) -} -function canonicalizeLayerOrder(names: string[]) { - return [...new Set(names)].sort((a, b) => { - const ka = layerSortKey(a) - const kb = layerSortKey(b) - if (ka !== kb) return ka - kb - return a.localeCompare(b) - }) -} -function contiguousRuns(nums: number[]) { - const zs = [...new Set(nums)].sort((a, b) => a - b) - if (zs.length === 0) return [] as number[][] - const groups: number[][] = [] - let run: number[] = [zs[0]] - for (let i = 1; i < zs.length; i++) { - if (zs[i] === zs[i - 1] + 1) run.push(zs[i]) - else { - groups.push(run) - run = [zs[i]] - } - } - groups.push(run) - return groups -} -function toXYBoundsRect( - center: { x: number; y: number }, - width: number, - height: number, -) { - const hw = width / 2 - const hh = height / 2 - return { - minX: center.x - hw, - maxX: center.x + hw, - minY: center.y - hh, - maxY: center.y + hh, - } -} +// A streaming, one-step-per-iteration solver. +// Tests that call `solver.solve()` still work because BaseSolver.solve() +// loops until this.solved flips true. export class RectDiffSolver extends BaseSolver { private srj: SimpleRouteJson - private layerNames: string[] = [] - private layerIndexByName = new Map() - private topLayerIndex = 0 - private result: SolveResult | null = null - private meshNodes: CapacityMeshNode[] = [] - private minLengthForMultipleLayers = 0.4 + private mode: "grid" | "exact" + private gridOptions: Partial + private state!: RectDiffState + private _meshNodes: CapacityMeshNode[] = [] - constructor(params: { + constructor(opts: { simpleRouteJson: SimpleRouteJson - minLengthForMultipleLayers?: number + mode?: "grid" | "exact" + gridOptions?: Partial }) { super() - this.srj = params.simpleRouteJson - this.minLengthForMultipleLayers = - params.minLengthForMultipleLayers ?? this.minLengthForMultipleLayers - - // Discover & index layers deterministically - const found = new Set() - for (const ob of this.srj.obstacles ?? []) { - for (const l of ob.layers ?? []) found.add(l) - } - for (const conn of this.srj.connections ?? []) { - for (const pt of conn.pointsToConnect ?? []) { - if (pt.layer) found.add(pt.layer) + this.srj = opts.simpleRouteJson + this.mode = opts.mode ?? "grid" + this.gridOptions = opts.gridOptions ?? {} + } + + override _setup() { + // For now "exact" mode falls back to grid; keep switch if you add exact later. + this.state = initState(this.srj, this.gridOptions) + this.stats = { + phase: this.state.phase, + gridIndex: this.state.gridIndex, + } + } + + /** IMPORTANT: exactly ONE small step per call */ + override _step() { + if (this.state.phase === "GRID") { + stepGrid(this.state) + } else if (this.state.phase === "EXPANSION") { + stepExpansion(this.state) + } else if (this.state.phase === "DONE") { + // Finalize once + if (!this.solved) { + const rects = finalizeRects(this.state) + this._meshNodes = rectsToMeshNodes(rects) + this.solved = true } + return } - if (found.size === 0) found.add("top") - this.layerNames = canonicalizeLayerOrder([...found]) - this.layerNames.forEach((n, i) => this.layerIndexByName.set(n, i)) - this.topLayerIndex = - this.layerIndexByName.get("top") ?? (this.layerNames.length ? 0 : 0) - } - - /** Perform the whole solve in a single step for now (keeps snapshots deterministic). */ - override _step(): void { - try { - // Build Problem: root + cutouts (split into contiguous z runs) - const rootRect: Rect3d = { - minX: this.srj.bounds.minX, - minY: this.srj.bounds.minY, - maxX: this.srj.bounds.maxX, - maxY: this.srj.bounds.maxY, - zLayers: Array.from({ length: this.layerNames.length }, (_, i) => i), - } - const cutouts: Rect3d[] = [] - for (const ob of this.srj.obstacles ?? []) { - // Only rectangles and ovals are supported; ovals -> bounding rect - if (ob.type !== "rect" && ob.type !== "oval") continue - const xy = toXYBoundsRect(ob.center, ob.width, ob.height) - - // Map obstacle's layer strings to z indices and split across contiguous runs - const zIdxs = (ob.layers ?? []) - .map((ln) => this.layerIndexByName.get(ln)) - .filter((v): v is number => typeof v === "number") - - for (const run of contiguousRuns(zIdxs)) { - cutouts.push({ - ...xy, - zLayers: run.slice(), - }) - } - } - - // Solve with defaults (thickness=1 unit, exponent p=2) - const res = solveNoDiscretization( - { rootRect, cutouts }, - { - Z_LAYER_THICKNESS: 1, - p: 2, - order: "AUTO", - maxCycles: 6, - eps: EPS_DEFAULT, - seed: 0, - minLenForMultiLayerXY: this.minLengthForMultipleLayers, - }, - ) - - this.result = res - - // Produce CapacityMeshNodes: one per layer slice of each merged box - const nodes: CapacityMeshNode[] = [] - let nid = 0 - for (const b of res.boxes) { - const dx = b.maxX - b.minX - const dy = b.maxY - b.minY - const cx = (b.minX + b.maxX) / 2 - const cy = (b.minY + b.maxY) / 2 - const availableZ: number[] = [] - for (let z = b.z0; z < b.z1; z++) availableZ.push(z) - - for (let z = b.z0; z < b.z1; z++) { - const lname = this.layerNames[z] ?? `layer_${z}` - nodes.push({ - capacityMeshNodeId: `node_${nid++}`, - center: { x: cx, y: cy }, - width: dx, - height: dy, - layer: lname, - availableZ: availableZ.slice(), - }) - } - } - this.meshNodes = nodes + // Lightweight stats for debugger + this.stats.phase = this.state.phase + this.stats.gridIndex = this.state.gridIndex + this.stats.placed = this.state.placed.length + } - ;(this as any).solved = true - } catch (err: any) { - ;(this as any).failed = true - ;(this as any).errorMessage = String(err?.message ?? err) - } + // Let BaseSolver update this.progress automatically if present. + computeProgress(): number { + return computeProgress(this.state) } override getOutput(): { meshNodes: CapacityMeshNode[] } { - return { meshNodes: this.meshNodes } + return { meshNodes: this._meshNodes } } - /** - * 2D visualization (SVG) showing capacity nodes across all layers: - * - Board outline - * - Obstacles on all layers (red) - * - Capacity mesh nodes on all layers (green) with layer info - */ + // Helper to get color based on z layer + private getColorForZLayer(zLayers: number[]): { fill: string; stroke: string } { + const minZ = Math.min(...zLayers) + const colors = [ + { fill: "#dbeafe", stroke: "#3b82f6" }, // blue (z=0) + { fill: "#fef3c7", stroke: "#f59e0b" }, // amber (z=1) + { fill: "#d1fae5", stroke: "#10b981" }, // green (z=2) + { fill: "#e9d5ff", stroke: "#a855f7" }, // purple (z=3) + { fill: "#fed7aa", stroke: "#f97316" }, // orange (z=4) + { fill: "#fecaca", stroke: "#ef4444" }, // red (z=5) + ] + return colors[minZ % colors.length] + } + + // Streaming visualization: board + obstacles + current placements. override visualize(): GraphicsObject { const rects: NonNullable = [] + const points: NonNullable = [] - // Board outline + // board rects.push({ center: { x: (this.srj.bounds.minX + this.srj.bounds.maxX) / 2, @@ -659,7 +102,7 @@ export class RectDiffSolver extends BaseSolver { label: "board", }) - // Obstacles on all layers — red translucent + // obstacles (rect & oval as bounding boxes) for (const ob of this.srj.obstacles ?? []) { if (ob.type === "rect" || ob.type === "oval") { rects.push({ @@ -669,42 +112,48 @@ export class RectDiffSolver extends BaseSolver { fill: "#fee2e2", stroke: "#ef4444", layer: "obstacle", - label: ["obstacle", ob.zLayers?.join(",")].join("\n"), + label: "obstacle", }) } } - // Capacity nodes on all layers — green translucent - if (this.meshNodes.length > 0) { - // Sort for deterministic ordering - const sortedNodes = [...this.meshNodes].sort( - (a, b) => - a.center.x - b.center.x || - a.center.y - b.center.y || - a.width - b.width || - a.height - b.height || - a.layer.localeCompare(b.layer), - ) + // candidate positions (where expansion started from) + if (this.state?.candidates?.length) { + for (const cand of this.state.candidates) { + points.push({ + x: cand.x, + y: cand.y, + radius: 2, + fill: "#9333ea", + stroke: "#6b21a8", + label: `z:${cand.z}`, + }) + } + } - for (const node of sortedNodes) { - // Format zLayers as a comma-separated string - const zLayersStr = node.availableZ.join(",") + // current placements (streaming) if not yet solved + if (this.state?.placed?.length) { + for (const p of this.state.placed) { + const colors = this.getColorForZLayer(p.zLayers) rects.push({ - center: { x: node.center.x, y: node.center.y }, - width: node.width, - height: node.height, - fill: "#d1fae5", - stroke: "#10b981", - layer: node.layer, - label: `free\nz:${zLayersStr}`, + center: { x: p.rect.x + p.rect.width / 2, y: p.rect.y + p.rect.height / 2 }, + width: p.rect.width, + height: p.rect.height, + fill: colors.fill, + stroke: colors.stroke, + label: `free\nz:${p.zLayers.join(",")}`, }) } } return { - title: "RectDiff (all layers)", + title: "RectDiff (incremental)", coordinateSystem: "cartesian", rects, + points, } } } + +// Re-export types for convenience +export type { GridFill3DOptions } from "./rectdiff/types" diff --git a/lib/solvers/rectdiff/candidates.ts b/lib/solvers/rectdiff/candidates.ts new file mode 100644 index 0000000..c1f4239 --- /dev/null +++ b/lib/solvers/rectdiff/candidates.ts @@ -0,0 +1,397 @@ +// lib/solvers/rectdiff/candidates.ts +import type { Candidate3D, XYRect } from "./types" +import { EPS, clamp, containsPoint, distancePointToRectEdges } from "./geometry" + +function isFullyOccupiedAllLayers( + x: number, + y: number, + layerCount: number, + obstaclesByLayer: XYRect[][], + placedByLayer: XYRect[][], +): boolean { + for (let z = 0; z < layerCount; z++) { + const obs = obstaclesByLayer[z] ?? [] + const placed = placedByLayer[z] ?? [] + const occ = + obs.some((b) => containsPoint(b, x, y)) || + placed.some((b) => containsPoint(b, x, y)) + if (!occ) return false + } + return true +} + +export function computeCandidates3D( + bounds: XYRect, + gridSize: number, + layerCount: number, + obstaclesByLayer: XYRect[][], + placedByLayer: XYRect[][], // all current nodes (soft + hard) + hardPlacedByLayer: XYRect[][], // only full-stack nodes, treated as hard +): Candidate3D[] { + const out = new Map() // key by (x,y) + + for (let x = bounds.x; x < bounds.x + bounds.width; x += gridSize) { + for (let y = bounds.y; y < bounds.y + bounds.height; y += gridSize) { + // Skip outermost row/col (stable with prior behavior) + if ( + Math.abs(x - bounds.x) < EPS || + Math.abs(y - bounds.y) < EPS || + x > bounds.x + bounds.width - gridSize - EPS || + y > bounds.y + bounds.height - gridSize - EPS + ) { + continue + } + + // New rule: Only drop if EVERY layer is occupied (by obstacle or node) + if (isFullyOccupiedAllLayers(x, y, layerCount, obstaclesByLayer, placedByLayer)) continue + + // Find the best (longest) free contiguous Z span at (x,y) ignoring soft nodes. + let bestSpan: number[] = [] + let bestZ = 0 + for (let z = 0; z < layerCount; z++) { + const s = longestFreeSpanAroundZ( + x, + y, + z, + layerCount, + 1, + undefined, // no cap here + obstaclesByLayer, + hardPlacedByLayer, // IMPORTANT: ignore soft nodes + ) + if (s.length > bestSpan.length) { + bestSpan = s + bestZ = z + } + } + const anchorZ = bestSpan.length + ? bestSpan[Math.floor(bestSpan.length / 2)]! + : bestZ + + // Distance heuristic against hard blockers only (obstacles + full-stack) + const hardAtZ = [ + ...(obstaclesByLayer[anchorZ] ?? []), + ...(hardPlacedByLayer[anchorZ] ?? []), + ] + const d = Math.min( + distancePointToRectEdges(x, y, bounds), + ...(hardAtZ.length + ? hardAtZ.map((b) => distancePointToRectEdges(x, y, b)) + : [Infinity]), + ) + + const k = `${x.toFixed(6)}|${y.toFixed(6)}` + const cand: Candidate3D = { + x, + y, + z: anchorZ, + distance: d, + zSpanLen: bestSpan.length, + } + const prev = out.get(k) + if ( + !prev || + cand.zSpanLen! > (prev.zSpanLen ?? 0) || + (cand.zSpanLen === prev.zSpanLen && cand.distance > prev.distance) + ) { + out.set(k, cand) + } + } + } + + const arr = Array.from(out.values()) + arr.sort((a, b) => (b.zSpanLen! - a.zSpanLen!) || (b.distance - a.distance)) + return arr +} + +/** Longest contiguous free span around z (optionally capped) */ +export function longestFreeSpanAroundZ( + x: number, + y: number, + z: number, + layerCount: number, + minSpan: number, + maxSpan: number | undefined, + obstaclesByLayer: XYRect[][], + placedByLayer: XYRect[][], +): number[] { + const isFreeAt = (layer: number) => { + const blockers = [...(obstaclesByLayer[layer] ?? []), ...(placedByLayer[layer] ?? [])] + return !blockers.some((b) => containsPoint(b, x, y)) + } + let lo = z + let hi = z + while (lo - 1 >= 0 && isFreeAt(lo - 1)) lo-- + while (hi + 1 < layerCount && isFreeAt(hi + 1)) hi++ + + if (typeof maxSpan === "number") { + const target = clamp(maxSpan, 1, layerCount) + // trim symmetrically (keeping z inside) + while (hi - lo + 1 > target) { + if (z - lo > hi - z) lo++ + else hi-- + } + } + + const res: number[] = [] + for (let i = lo; i <= hi; i++) res.push(i) + return res.length >= minSpan ? res : [] +} + +export function computeDefaultGridSizes(bounds: XYRect): number[] { + const ref = Math.max(bounds.width, bounds.height) + return [ref / 8, ref / 16, ref / 32] +} + +/** Compute exact uncovered segments along a 1D line given a list of covering intervals */ +function computeUncoveredSegments( + lineStart: number, + lineEnd: number, + coveringIntervals: Array<{ start: number; end: number }>, + minSegmentLength: number, +): Array<{ start: number; end: number; center: number }> { + if (coveringIntervals.length === 0) { + const center = (lineStart + lineEnd) / 2 + return [{ start: lineStart, end: lineEnd, center }] + } + + // Sort intervals by start position + const sorted = [...coveringIntervals].sort((a, b) => a.start - b.start) + + // Merge overlapping intervals + const merged: Array<{ start: number; end: number }> = [] + let current = { ...sorted[0]! } + + for (let i = 1; i < sorted.length; i++) { + const interval = sorted[i]! + if (interval.start <= current.end + EPS) { + // Overlapping or adjacent - merge + current.end = Math.max(current.end, interval.end) + } else { + // No overlap - save current and start new + merged.push(current) + current = { ...interval } + } + } + merged.push(current) + + // Find gaps between merged intervals + const uncovered: Array<{ start: number; end: number; center: number }> = [] + + // Check gap before first interval + if (merged[0]!.start > lineStart + EPS) { + const start = lineStart + const end = merged[0]!.start + if (end - start >= minSegmentLength) { + uncovered.push({ start, end, center: (start + end) / 2 }) + } + } + + // Check gaps between intervals + for (let i = 0; i < merged.length - 1; i++) { + const start = merged[i]!.end + const end = merged[i + 1]!.start + if (end - start >= minSegmentLength) { + uncovered.push({ start, end, center: (start + end) / 2 }) + } + } + + // Check gap after last interval + if (merged[merged.length - 1]!.end < lineEnd - EPS) { + const start = merged[merged.length - 1]!.end + const end = lineEnd + if (end - start >= minSegmentLength) { + uncovered.push({ start, end, center: (start + end) / 2 }) + } + } + + return uncovered +} + +/** Exact edge analysis: find uncovered segments along board edges and blocker edges */ +export function computeEdgeCandidates3D( + bounds: XYRect, + minSize: number, + layerCount: number, + obstaclesByLayer: XYRect[][], + placedByLayer: XYRect[][], // all nodes + hardPlacedByLayer: XYRect[][], // full-stack nodes +): Candidate3D[] { + const out: Candidate3D[] = [] + // Use small inset from edges for placement + const δ = Math.max(minSize * 0.15, EPS * 3) + const dedup = new Set() + const key = (x: number, y: number, z: number) => `${z}|${x.toFixed(6)}|${y.toFixed(6)}` + + function fullyOcc(x: number, y: number) { + return isFullyOccupiedAllLayers(x, y, layerCount, obstaclesByLayer, placedByLayer) + } + + function pushIfFree(x: number, y: number, z: number) { + if ( + x < bounds.x + EPS || y < bounds.y + EPS || + x > bounds.x + bounds.width - EPS || y > bounds.y + bounds.height - EPS + ) return + if (fullyOcc(x, y)) return // new rule: only drop if truly impossible + + // Distance uses obstacles + hard nodes (soft nodes ignored for ranking) + const hard = [ + ...(obstaclesByLayer[z] ?? []), + ...(hardPlacedByLayer[z] ?? []), + ] + const d = Math.min( + distancePointToRectEdges(x, y, bounds), + ...(hard.length ? hard.map((b) => distancePointToRectEdges(x, y, b)) : [Infinity]), + ) + + const k = key(x, y, z) + if (dedup.has(k)) return + dedup.add(k) + + // Approximate z-span strength at this z (ignoring soft nodes) + const span = longestFreeSpanAroundZ(x, y, z, layerCount, 1, undefined, obstaclesByLayer, hardPlacedByLayer) + out.push({ x, y, z, distance: d, zSpanLen: span.length, isEdgeSeed: true }) + } + + for (let z = 0; z < layerCount; z++) { + const blockers = [...(obstaclesByLayer[z] ?? []), ...(hardPlacedByLayer[z] ?? [])] + + // 1) Board edges — find exact uncovered segments along each edge + + // First, check corners explicitly + const corners = [ + { x: bounds.x + δ, y: bounds.y + δ }, // top-left + { x: bounds.x + bounds.width - δ, y: bounds.y + δ }, // top-right + { x: bounds.x + δ, y: bounds.y + bounds.height - δ }, // bottom-left + { x: bounds.x + bounds.width - δ, y: bounds.y + bounds.height - δ }, // bottom-right + ] + for (const corner of corners) { + pushIfFree(corner.x, corner.y, z) + } + + // Top edge (y = bounds.y + δ) + const topY = bounds.y + δ + const topCovering = blockers + .filter(b => b.y <= topY && b.y + b.height >= topY) + .map(b => ({ start: Math.max(bounds.x, b.x), end: Math.min(bounds.x + bounds.width, b.x + b.width) })) + // Find uncovered segments that are large enough to potentially fill + const topUncovered = computeUncoveredSegments(bounds.x + δ, bounds.x + bounds.width - δ, topCovering, minSize * 0.5) + for (const seg of topUncovered) { + const segLen = seg.end - seg.start + if (segLen >= minSize) { + // Seed center and a few strategic points + pushIfFree(seg.center, topY, z) + if (segLen > minSize * 1.5) { + pushIfFree(seg.start + minSize * 0.4, topY, z) + pushIfFree(seg.end - minSize * 0.4, topY, z) + } + } + } + + // Bottom edge (y = bounds.y + bounds.height - δ) + const bottomY = bounds.y + bounds.height - δ + const bottomCovering = blockers + .filter(b => b.y <= bottomY && b.y + b.height >= bottomY) + .map(b => ({ start: Math.max(bounds.x, b.x), end: Math.min(bounds.x + bounds.width, b.x + b.width) })) + const bottomUncovered = computeUncoveredSegments(bounds.x + δ, bounds.x + bounds.width - δ, bottomCovering, minSize * 0.5) + for (const seg of bottomUncovered) { + const segLen = seg.end - seg.start + if (segLen >= minSize) { + pushIfFree(seg.center, bottomY, z) + if (segLen > minSize * 1.5) { + pushIfFree(seg.start + minSize * 0.4, bottomY, z) + pushIfFree(seg.end - minSize * 0.4, bottomY, z) + } + } + } + + // Left edge (x = bounds.x + δ) + const leftX = bounds.x + δ + const leftCovering = blockers + .filter(b => b.x <= leftX && b.x + b.width >= leftX) + .map(b => ({ start: Math.max(bounds.y, b.y), end: Math.min(bounds.y + bounds.height, b.y + b.height) })) + const leftUncovered = computeUncoveredSegments(bounds.y + δ, bounds.y + bounds.height - δ, leftCovering, minSize * 0.5) + for (const seg of leftUncovered) { + const segLen = seg.end - seg.start + if (segLen >= minSize) { + pushIfFree(leftX, seg.center, z) + if (segLen > minSize * 1.5) { + pushIfFree(leftX, seg.start + minSize * 0.4, z) + pushIfFree(leftX, seg.end - minSize * 0.4, z) + } + } + } + + // Right edge (x = bounds.x + bounds.width - δ) + const rightX = bounds.x + bounds.width - δ + const rightCovering = blockers + .filter(b => b.x <= rightX && b.x + b.width >= rightX) + .map(b => ({ start: Math.max(bounds.y, b.y), end: Math.min(bounds.y + bounds.height, b.y + b.height) })) + const rightUncovered = computeUncoveredSegments(bounds.y + δ, bounds.y + bounds.height - δ, rightCovering, minSize * 0.5) + for (const seg of rightUncovered) { + const segLen = seg.end - seg.start + if (segLen >= minSize) { + pushIfFree(rightX, seg.center, z) + if (segLen > minSize * 1.5) { + pushIfFree(rightX, seg.start + minSize * 0.4, z) + pushIfFree(rightX, seg.end - minSize * 0.4, z) + } + } + } + + // 2) Around every obstacle and placed rect edge — find exact uncovered segments + for (const b of blockers) { + // Left edge of blocker (x = b.x - δ) + const obLeftX = b.x - δ + if (obLeftX > bounds.x + EPS && obLeftX < bounds.x + bounds.width - EPS) { + const obLeftCovering = blockers + .filter(bl => bl !== b && bl.x <= obLeftX && bl.x + bl.width >= obLeftX) + .map(bl => ({ start: Math.max(b.y, bl.y), end: Math.min(b.y + b.height, bl.y + bl.height) })) + const obLeftUncovered = computeUncoveredSegments(b.y, b.y + b.height, obLeftCovering, minSize * 0.5) + for (const seg of obLeftUncovered) { + pushIfFree(obLeftX, seg.center, z) + } + } + + // Right edge of blocker (x = b.x + b.width + δ) + const obRightX = b.x + b.width + δ + if (obRightX > bounds.x + EPS && obRightX < bounds.x + bounds.width - EPS) { + const obRightCovering = blockers + .filter(bl => bl !== b && bl.x <= obRightX && bl.x + bl.width >= obRightX) + .map(bl => ({ start: Math.max(b.y, bl.y), end: Math.min(b.y + b.height, bl.y + bl.height) })) + const obRightUncovered = computeUncoveredSegments(b.y, b.y + b.height, obRightCovering, minSize * 0.5) + for (const seg of obRightUncovered) { + pushIfFree(obRightX, seg.center, z) + } + } + + // Top edge of blocker (y = b.y - δ) + const obTopY = b.y - δ + if (obTopY > bounds.y + EPS && obTopY < bounds.y + bounds.height - EPS) { + const obTopCovering = blockers + .filter(bl => bl !== b && bl.y <= obTopY && bl.y + bl.height >= obTopY) + .map(bl => ({ start: Math.max(b.x, bl.x), end: Math.min(b.x + b.width, bl.x + bl.width) })) + const obTopUncovered = computeUncoveredSegments(b.x, b.x + b.width, obTopCovering, minSize * 0.5) + for (const seg of obTopUncovered) { + pushIfFree(seg.center, obTopY, z) + } + } + + // Bottom edge of blocker (y = b.y + b.height + δ) + const obBottomY = b.y + b.height + δ + if (obBottomY > bounds.y + EPS && obBottomY < bounds.y + bounds.height - EPS) { + const obBottomCovering = blockers + .filter(bl => bl !== b && bl.y <= obBottomY && bl.y + bl.height >= obBottomY) + .map(bl => ({ start: Math.max(b.x, bl.x), end: Math.min(b.x + b.width, bl.x + bl.width) })) + const obBottomUncovered = computeUncoveredSegments(b.x, b.x + b.width, obBottomCovering, minSize * 0.5) + for (const seg of obBottomUncovered) { + pushIfFree(seg.center, obBottomY, z) + } + } + } + } + + // Strong multi-layer preference then distance. + out.sort((a, b) => (b.zSpanLen! - a.zSpanLen!) || (b.distance - a.distance)) + return out +} diff --git a/lib/solvers/rectdiff/engine.ts b/lib/solvers/rectdiff/engine.ts new file mode 100644 index 0000000..da442e2 --- /dev/null +++ b/lib/solvers/rectdiff/engine.ts @@ -0,0 +1,355 @@ +// lib/solvers/rectdiff/engine.ts +import type { GridFill3DOptions, Placed3D, Rect3d, RectDiffState, XYRect } from "./types" +import type { SimpleRouteJson } from "../../types/srj-types" +import { + computeCandidates3D, + computeDefaultGridSizes, + computeEdgeCandidates3D, + longestFreeSpanAroundZ, +} from "./candidates" +import { EPS, containsPoint, expandRectFromSeed, overlaps, subtractRect2D } from "./geometry" +import { buildZIndexMap, obstacleToXYRect, obstacleZs } from "./layers" + +export function initState(srj: SimpleRouteJson, opts: Partial): RectDiffState { + const { layerNames, zIndexByName } = buildZIndexMap(srj) + const layerCount = Math.max(1, layerNames.length, srj.layerCount || 1) + + const bounds: XYRect = { + x: srj.bounds.minX, + y: srj.bounds.minY, + width: srj.bounds.maxX - srj.bounds.minX, + height: srj.bounds.maxY - srj.bounds.minY, + } + + // Obstacles per layer + const obstaclesByLayer: XYRect[][] = Array.from({ length: layerCount }, () => []) + for (const ob of srj.obstacles ?? []) { + const r = obstacleToXYRect(ob) + if (!r) continue + const zs = obstacleZs(ob, zIndexByName) + for (const z of zs) if (z >= 0 && z < layerCount) obstaclesByLayer[z]!.push(r) + } + + const trace = Math.max(0.01, srj.minTraceWidth || 0.15) + const defaults: Required> & { + gridSizes: number[] + maxMultiLayerSpan: number | undefined + } = { + gridSizes: computeDefaultGridSizes(bounds), + initialCellRatio: 0.2, + maxAspectRatio: 3, + minSingle: { width: 2 * trace, height: 2 * trace }, + minMulti: { + width: 4 * trace, + height: 4 * trace, + minLayers: Math.min(2, Math.max(1, srj.layerCount || 1)), + }, + preferMultiLayer: true, + maxMultiLayerSpan: undefined, + } + + const options = { ...defaults, ...opts, gridSizes: opts.gridSizes ?? defaults.gridSizes } + + const placedByLayer: XYRect[][] = Array.from({ length: layerCount }, () => []) + + // Begin at the **first** grid level; candidates computed lazily on first step + return { + srj, + layerNames, + layerCount, + bounds, + options, + obstaclesByLayer, + phase: "GRID", + gridIndex: 0, + candidates: [], + placed: [], + placedByLayer, + expansionIndex: 0, + edgeAnalysisDone: false, + totalSeedsThisGrid: 0, + consumedSeedsThisGrid: 0, + } +} + +// Build per-layer list of "hard" placed rects = nodes spanning all layers +function buildHardPlacedByLayer(state: RectDiffState): XYRect[][] { + const out: XYRect[][] = Array.from({ length: state.layerCount }, () => []) + for (const p of state.placed) { + if (p.zLayers.length >= state.layerCount) { + for (const z of p.zLayers) out[z]!.push(p.rect) + } + } + return out +} + +function isFullyOccupiedAtPoint(state: RectDiffState, x: number, y: number): boolean { + for (let z = 0; z < state.layerCount; z++) { + const obs = state.obstaclesByLayer[z] ?? [] + const placed = state.placedByLayer[z] ?? [] + const occ = + obs.some((b) => containsPoint(b, x, y)) || + placed.some((b) => containsPoint(b, x, y)) + if (!occ) return false + } + return true +} + +/** Shrink/split any *soft* (non-full-stack) nodes overlapped by `newIndex` */ +function resizeSoftOverlaps(state: RectDiffState, newIndex: number) { + const newcomer = state.placed[newIndex]! + const { rect: newR, zLayers: newZs } = newcomer + const layerCount = state.layerCount + + const removeIdx: number[] = [] + const toAdd: typeof state.placed = [] + + for (let i = 0; i < state.placed.length; i++) { + if (i === newIndex) continue + const old = state.placed[i]! + // Protect full-stack nodes + if (old.zLayers.length >= layerCount) continue + + const sharedZ = old.zLayers.filter((z) => newZs.includes(z)) + if (sharedZ.length === 0) continue + if (!overlaps(old.rect, newR)) continue + + // Carve the overlap on the shared layers + const parts = subtractRect2D(old.rect, newR) + + // We will replace `old` entirely; re-add unaffected layers (same rect object). + 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 + const minW = Math.min(state.options.minSingle.width, state.options.minMulti.width) + const minH = Math.min(state.options.minSingle.height, state.options.minMulti.height) + for (const p of parts) { + if (p.width + EPS >= minW && p.height + EPS >= minH) { + toAdd.push({ rect: p, zLayers: sharedZ.slice() }) + } + } + } + + // Remove (and clear placedByLayer) + removeIdx.sort((a, b) => b - a).forEach((idx) => { + const rem = state.placed.splice(idx, 1)[0]! + for (const z of rem.zLayers) { + const arr = state.placedByLayer[z]! + const j = arr.findIndex((r) => r === rem.rect) + if (j >= 0) arr.splice(j, 1) + } + }) + + // Add replacements + for (const p of toAdd) { + state.placed.push(p) + for (const z of p.zLayers) state.placedByLayer[z]!.push(p.rect) + } +} + +/** One micro-step during the GRID phase: handle (or fetch) exactly one candidate */ +export function stepGrid(state: RectDiffState): void { + const { + gridSizes, + initialCellRatio, + maxAspectRatio, + minSingle, + minMulti, + preferMultiLayer, + maxMultiLayerSpan, + } = state.options + const grid = gridSizes[state.gridIndex]! + + // Build hard-placed map once per micro-step (cheap) + const hardPlacedByLayer = buildHardPlacedByLayer(state) + + // Ensure candidates exist for this grid + if (state.candidates.length === 0 && state.consumedSeedsThisGrid === 0) { + state.candidates = computeCandidates3D( + state.bounds, + grid, + state.layerCount, + state.obstaclesByLayer, + state.placedByLayer, // all nodes (soft + hard) for fully-occupied test + hardPlacedByLayer, // hard blockers for ranking/span + ) + state.totalSeedsThisGrid = state.candidates.length + state.consumedSeedsThisGrid = 0 + } + + // If no candidates remain, advance grid or run edge pass or switch phase + if (state.candidates.length === 0) { + if (state.gridIndex + 1 < gridSizes.length) { + state.gridIndex += 1 + state.totalSeedsThisGrid = 0 + state.consumedSeedsThisGrid = 0 + return + } else { + if (!state.edgeAnalysisDone) { + const minSize = Math.min(minSingle.width, minSingle.height) + state.candidates = computeEdgeCandidates3D( + state.bounds, + minSize, + state.layerCount, + state.obstaclesByLayer, + state.placedByLayer, // for fully-occupied test + hardPlacedByLayer, + ) + state.edgeAnalysisDone = true + state.totalSeedsThisGrid = state.candidates.length + state.consumedSeedsThisGrid = 0 + return + } + state.phase = "EXPANSION" + state.expansionIndex = 0 + return + } + } + + // Consume exactly one candidate + const cand = state.candidates.shift()! + state.consumedSeedsThisGrid += 1 + + // Evaluate attempts — multi-layer span first (computed ignoring soft nodes) + const span = longestFreeSpanAroundZ( + cand.x, + cand.y, + cand.z, + state.layerCount, + minMulti.minLayers, + maxMultiLayerSpan, + state.obstaclesByLayer, + hardPlacedByLayer, // ignore soft nodes for span + ) + + const attempts: Array<{ + kind: "multi" | "single" + layers: number[] + minReq: { width: number; height: number } + }> = [] + + if (span.length >= minMulti.minLayers) { + attempts.push({ kind: "multi", layers: span, minReq: { width: minMulti.width, height: minMulti.height } }) + } + attempts.push({ kind: "single", layers: [cand.z], minReq: { width: minSingle.width, height: minSingle.height } }) + + const ordered = preferMultiLayer ? attempts : attempts.reverse() + + for (const attempt of ordered) { + // HARD blockers only: obstacles on those layers + full-stack nodes + const hardBlockers: XYRect[] = [] + for (const z of attempt.layers) { + if (state.obstaclesByLayer[z]) hardBlockers.push(...state.obstaclesByLayer[z]!) + if (hardPlacedByLayer[z]) hardBlockers.push(...hardPlacedByLayer[z]!) + } + + const rect = expandRectFromSeed( + cand.x, + cand.y, + grid, + state.bounds, + hardBlockers, // soft nodes DO NOT block expansion + initialCellRatio, + maxAspectRatio, + attempt.minReq, + ) + if (!rect) continue + + // Place the new node + const placed: Placed3D = { rect, zLayers: [...attempt.layers] } + const newIndex = state.placed.push(placed) - 1 + for (const z of attempt.layers) state.placedByLayer[z]!.push(rect) + + // New: carve overlapped soft nodes + resizeSoftOverlaps(state, newIndex) + + // New: relax candidate culling — only drop seeds that became fully occupied + state.candidates = state.candidates.filter((c) => !isFullyOccupiedAtPoint(state, c.x, c.y)) + + return // processed one candidate + } + + // Neither attempt worked; drop this candidate for now. +} + +/** One micro-step during the EXPANSION phase: expand exactly one placed rect */ +export function stepExpansion(state: RectDiffState): void { + if (state.expansionIndex >= state.placed.length) { + state.phase = "DONE" + return + } + + const idx = state.expansionIndex + const p = state.placed[idx]! + const lastGrid = state.options.gridSizes[state.options.gridSizes.length - 1]! + + const hardPlacedByLayer = buildHardPlacedByLayer(state) + + // HARD blockers only: obstacles on p.zLayers + full-stack nodes + const hardBlockers: XYRect[] = [] + for (const z of p.zLayers) { + hardBlockers.push(...(state.obstaclesByLayer[z] ?? [])) + hardBlockers.push(...(hardPlacedByLayer[z] ?? [])) + } + + const oldRect = p.rect + const expanded = expandRectFromSeed( + p.rect.x + p.rect.width / 2, + p.rect.y + p.rect.height / 2, + lastGrid, + state.bounds, + hardBlockers, + 0, // seed bias off + null, // no aspect cap in expansion pass + { width: p.rect.width, height: p.rect.height }, + ) + + if (expanded) { + // Update placement + per-layer index (replace old rect object) + state.placed[idx] = { rect: expanded, zLayers: p.zLayers } + for (const z of p.zLayers) { + const arr = state.placedByLayer[z]! + const j = arr.findIndex((r) => r === oldRect) + if (j >= 0) arr[j] = expanded + } + + // Carve overlapped soft neighbors (respect full-stack nodes) + resizeSoftOverlaps(state, idx) + } + + state.expansionIndex += 1 +} + +export function finalizeRects(state: RectDiffState): Rect3d[] { + return state.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), + })) +} + +/** Optional: rough progress number for BaseSolver.progress */ +export function computeProgress(state: RectDiffState): number { + const grids = state.options.gridSizes.length + if (state.phase === "GRID") { + const g = state.gridIndex + const base = g / (grids + 1) // reserve final slice for expansion + const denom = Math.max(1, state.totalSeedsThisGrid) + const frac = denom ? state.consumedSeedsThisGrid / denom : 1 + return Math.min(0.999, base + frac * (1 / (grids + 1))) + } + if (state.phase === "EXPANSION") { + const base = grids / (grids + 1) + const denom = Math.max(1, state.placed.length) + const frac = denom ? state.expansionIndex / denom : 1 + return Math.min(0.999, base + frac * (1 / (grids + 1))) + } + return 1 +} diff --git a/lib/solvers/rectdiff/geometry.ts b/lib/solvers/rectdiff/geometry.ts new file mode 100644 index 0000000..45c88eb --- /dev/null +++ b/lib/solvers/rectdiff/geometry.ts @@ -0,0 +1,284 @@ +// lib/solvers/rectdiff/geometry.ts +import type { XYRect } from "./types" + +export const EPS = 1e-9 +export const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v)) +export const gt = (a: number, b: number) => a > b + EPS +export const gte = (a: number, b: number) => a > b - EPS +export const lt = (a: number, b: number) => a < b - EPS +export const lte = (a: number, b: number) => a < b + EPS + +export function overlaps(a: XYRect, b: XYRect) { + return !( + a.x + a.width <= b.x + EPS || + b.x + b.width <= a.x + EPS || + a.y + a.height <= b.y + EPS || + b.y + b.height <= a.y + EPS + ) +} + +export function containsPoint(r: XYRect, x: number, y: number) { + return ( + x >= r.x - EPS && x <= r.x + r.width + EPS && + y >= r.y - EPS && y <= r.y + r.height + EPS + ) +} + +export function distancePointToRectEdges(px: number, py: number, r: XYRect) { + const edges: [number, number, number, number][] = [ + [r.x, r.y, r.x + r.width, r.y], + [r.x + r.width, r.y, r.x + r.width, r.y + r.height], + [r.x + r.width, r.y + r.height, r.x, r.y + r.height], + [r.x, r.y + r.height, r.x, r.y], + ] + let best = Infinity + for (const [x1, y1, x2, y2] of edges) { + const A = px - x1, B = py - y1, C = x2 - x1, D = y2 - y1 + const dot = A * C + B * D + const lenSq = C * C + D * D + let t = lenSq !== 0 ? dot / lenSq : 0 + t = clamp(t, 0, 1) + const xx = x1 + t * C + const yy = y1 + t * D + best = Math.min(best, Math.hypot(px - xx, py - yy)) + } + return best +} + +// --- directional expansion caps (respect board + blockers + aspect) --- + +function maxExpandRight( + r: XYRect, bounds: XYRect, blockers: XYRect[], maxAspect: number | null | undefined +) { + // Start with board boundary + let maxWidth = bounds.x + bounds.width - r.x + + // Check all blockers that could limit rightward expansion + for (const b of blockers) { + // Only consider blockers that vertically overlap with current rect + const verticallyOverlaps = r.y + r.height > b.y + EPS && b.y + b.height > r.y + EPS + if (verticallyOverlaps) { + // Blocker is to the right - limits how far we can expand + if (gte(b.x, r.x + r.width)) { + maxWidth = Math.min(maxWidth, b.x - r.x) + } + // Blocker overlaps current position - can't expand at all + else if (b.x + b.width > r.x + r.width - EPS && b.x < r.x + r.width + EPS) { + return 0 + } + } + } + + let e = Math.max(0, maxWidth - r.width) + if (e <= 0) return 0 + + // Apply aspect ratio constraint + if (maxAspect != null) { + const w = r.width, h = r.height + if (w >= h) e = Math.min(e, maxAspect * h - w) + } + return Math.max(0, e) +} + +function maxExpandDown( + r: XYRect, bounds: XYRect, blockers: XYRect[], maxAspect: number | null | undefined +) { + // Start with board boundary + let maxHeight = bounds.y + bounds.height - r.y + + // Check all blockers that could limit downward expansion + for (const b of blockers) { + // Only consider blockers that horizontally overlap with current rect + const horizOverlaps = r.x + r.width > b.x + EPS && b.x + b.width > r.x + EPS + if (horizOverlaps) { + // Blocker is below - limits how far we can expand + if (gte(b.y, r.y + r.height)) { + maxHeight = Math.min(maxHeight, b.y - r.y) + } + // Blocker overlaps current position - can't expand at all + else if (b.y + b.height > r.y + r.height - EPS && b.y < r.y + r.height + EPS) { + return 0 + } + } + } + + let e = Math.max(0, maxHeight - r.height) + if (e <= 0) return 0 + + // Apply aspect ratio constraint + if (maxAspect != null) { + const w = r.width, h = r.height + if (h >= w) e = Math.min(e, maxAspect * w - h) + } + return Math.max(0, e) +} + +function maxExpandLeft( + r: XYRect, bounds: XYRect, blockers: XYRect[], maxAspect: number | null | undefined +) { + // Start with board boundary + let minX = bounds.x + + // Check all blockers that could limit leftward expansion + for (const b of blockers) { + // Only consider blockers that vertically overlap with current rect + const verticallyOverlaps = r.y + r.height > b.y + EPS && b.y + b.height > r.y + EPS + if (verticallyOverlaps) { + // Blocker is to the left - limits how far we can expand + if (lte(b.x + b.width, r.x)) { + minX = Math.max(minX, b.x + b.width) + } + // Blocker overlaps current position - can't expand at all + else if (b.x < r.x + EPS && b.x + b.width > r.x - EPS) { + return 0 + } + } + } + + let e = Math.max(0, r.x - minX) + if (e <= 0) return 0 + + // Apply aspect ratio constraint + if (maxAspect != null) { + const w = r.width, h = r.height + if (w >= h) e = Math.min(e, maxAspect * h - w) + } + return Math.max(0, e) +} + +function maxExpandUp( + r: XYRect, bounds: XYRect, blockers: XYRect[], maxAspect: number | null | undefined +) { + // Start with board boundary + let minY = bounds.y + + // Check all blockers that could limit upward expansion + for (const b of blockers) { + // Only consider blockers that horizontally overlap with current rect + const horizOverlaps = r.x + r.width > b.x + EPS && b.x + b.width > r.x + EPS + if (horizOverlaps) { + // Blocker is above - limits how far we can expand + if (lte(b.y + b.height, r.y)) { + minY = Math.max(minY, b.y + b.height) + } + // Blocker overlaps current position - can't expand at all + else if (b.y < r.y + EPS && b.y + b.height > r.y - EPS) { + return 0 + } + } + } + + let e = Math.max(0, r.y - minY) + if (e <= 0) return 0 + + // Apply aspect ratio constraint + if (maxAspect != null) { + const w = r.width, h = r.height + if (h >= w) e = Math.min(e, maxAspect * w - h) + } + return Math.max(0, e) +} + +/** Grow a rect around (startX,startY), honoring bounds/blockers/aspect/min sizes */ +export function expandRectFromSeed( + startX: number, + startY: number, + gridSize: number, + bounds: XYRect, + blockers: XYRect[], + initialCellRatio: number, + maxAspectRatio: number | null | undefined, + minReq: { width: number; height: number }, +): XYRect | null { + const minSide = Math.max(1e-9, gridSize * initialCellRatio) + const initialW = Math.max(minSide, minReq.width) + const initialH = Math.max(minSide, minReq.height) + + const strategies = [ + { ox: 0, oy: 0 }, + { ox: -initialW, oy: 0 }, + { ox: 0, oy: -initialH }, + { ox: -initialW, oy: -initialH }, + { ox: -initialW / 2, oy: -initialH / 2 }, + ] + + let best: XYRect | null = null + let bestArea = 0 + + STRATS: for (const s of strategies) { + let r: XYRect = { x: startX + s.ox, y: startY + s.oy, width: initialW, height: initialH } + + // keep initial inside board + if (lt(r.x, bounds.x) || lt(r.y, bounds.y) || + gt(r.x + r.width, bounds.x + bounds.width) || + gt(r.y + r.height, bounds.y + bounds.height)) { + continue + } + + // no initial overlap + for (const b of blockers) if (overlaps(r, b)) continue STRATS + + // greedy expansions in 4 directions + let improved = true + while (improved) { + improved = false + const eR = maxExpandRight(r, bounds, blockers, maxAspectRatio) + if (eR > 0) { r = { ...r, width: r.width + eR }; improved = true } + + const eD = maxExpandDown(r, bounds, blockers, maxAspectRatio) + if (eD > 0) { r = { ...r, height: r.height + eD }; improved = true } + + const eL = maxExpandLeft(r, bounds, blockers, maxAspectRatio) + if (eL > 0) { r = { x: r.x - eL, y: r.y, width: r.width + eL, height: r.height }; improved = true } + + const eU = maxExpandUp(r, bounds, blockers, maxAspectRatio) + if (eU > 0) { r = { x: r.x, y: r.y - eU, width: r.width, height: r.height + eU }; improved = true } + } + + if (r.width + EPS >= minReq.width && r.height + EPS >= minReq.height) { + const area = r.width * r.height + if (area > bestArea) { best = r; bestArea = area } + } + } + + return best +} + +export function intersect1D(a0: number, a1: number, b0: number, b1: number) { + const lo = Math.max(a0, b0) + const hi = Math.min(a1, b1) + return hi > lo + EPS ? [lo, hi] as const : null +} + +/** Return A \ B as up to 4 non-overlapping rectangles (or [A] if no overlap). */ +export function subtractRect2D(A: XYRect, B: XYRect): XYRect[] { + if (!overlaps(A, B)) return [A] + + const Xi = intersect1D(A.x, A.x + A.width, B.x, B.x + B.width) + const Yi = intersect1D(A.y, A.y + A.height, B.y, B.y + B.height) + if (!Xi || !Yi) return [A] + + const [X0, X1] = Xi + const [Y0, Y1] = Yi + const out: XYRect[] = [] + + // Left strip + if (X0 > A.x + EPS) { + out.push({ x: A.x, y: A.y, width: X0 - A.x, height: A.height }) + } + // Right strip + if (A.x + A.width > X1 + EPS) { + out.push({ x: X1, y: A.y, width: (A.x + A.width) - X1, height: A.height }) + } + // Top wedge in the middle band + const midW = Math.max(0, X1 - X0) + if (midW > EPS && Y0 > A.y + EPS) { + out.push({ x: X0, y: A.y, width: midW, height: Y0 - A.y }) + } + // Bottom wedge in the middle band + if (midW > EPS && A.y + A.height > Y1 + EPS) { + out.push({ x: X0, y: Y1, width: midW, height: (A.y + A.height) - Y1 }) + } + + return out.filter((r) => r.width > EPS && r.height > EPS) +} diff --git a/lib/solvers/rectdiff/layers.ts b/lib/solvers/rectdiff/layers.ts new file mode 100644 index 0000000..d34b006 --- /dev/null +++ b/lib/solvers/rectdiff/layers.ts @@ -0,0 +1,48 @@ +// lib/solvers/rectdiff/layers.ts +import type { SimpleRouteJson, Obstacle } from "../../types/srj-types" +import type { XYRect } from "./types" + +function layerSortKey(n: string) { + const L = n.toLowerCase() + if (L === "top") return -1_000_000 + if (L === "bottom") return 1_000_000 + const m = /^inner(\d+)$/i.exec(L) + if (m) return parseInt(m[1]!, 10) || 0 + return 100 + L.charCodeAt(0) +} + +export function canonicalizeLayerOrder(names: string[]) { + return Array.from(new Set(names)).sort((a, b) => { + const ka = layerSortKey(a) + const kb = layerSortKey(b) + if (ka !== kb) return ka - kb + return a.localeCompare(b) + }) +} + +export function buildZIndexMap(srj: SimpleRouteJson) { + const names = canonicalizeLayerOrder((srj.obstacles ?? []).flatMap((o) => o.layers ?? [])) + const fallback = Array.from( + { length: Math.max(1, srj.layerCount || 1) }, + (_, i) => (i === 0 ? "top" : i === (srj.layerCount || 1) - 1 ? "bottom" : `inner${i}`), + ) + const layerNames = names.length ? names : fallback + const map = new Map() + layerNames.forEach((n, i) => map.set(n, i)) + return { layerNames, zIndexByName: map } +} + +export function obstacleZs(ob: Obstacle, zIndexByName: Map) { + if (ob.zLayers?.length) return Array.from(new Set(ob.zLayers)).sort((a, b) => a - b) + const fromNames = (ob.layers ?? []) + .map((n) => zIndexByName.get(n)) + .filter((v): v is number => typeof v === "number") + return Array.from(new Set(fromNames)).sort((a, b) => a - b) +} + +export function obstacleToXYRect(ob: Obstacle): XYRect | null { + const w = ob.width as any + const h = ob.height as any + if (typeof w !== "number" || typeof h !== "number") return null + return { x: ob.center.x - w / 2, y: ob.center.y - h / 2, width: w, height: h } +} diff --git a/lib/solvers/rectdiff/rectsToMeshNodes.ts b/lib/solvers/rectdiff/rectsToMeshNodes.ts new file mode 100644 index 0000000..5ead92e --- /dev/null +++ b/lib/solvers/rectdiff/rectsToMeshNodes.ts @@ -0,0 +1,22 @@ +// lib/solvers/rectdiff/rectsToMeshNodes.ts +import type { Rect3d } from "./types" +import type { CapacityMeshNode } from "../../types/capacity-mesh-types" + +export function rectsToMeshNodes(rects: Rect3d[]): CapacityMeshNode[] { + let id = 0 + const out: CapacityMeshNode[] = [] + for (const r of rects) { + const w = Math.max(0, r.maxX - r.minX) + const h = Math.max(0, r.maxY - r.minY) + if (w <= 0 || h <= 0 || r.zLayers.length === 0) continue + out.push({ + capacityMeshNodeId: `cmn_${id++}`, + center: { x: (r.minX + r.maxX) / 2, y: (r.minY + r.maxY) / 2 }, + width: w, + height: h, + layer: "top", + availableZ: r.zLayers.slice(), + }) + } + return out +} diff --git a/lib/solvers/rectdiff/types.ts b/lib/solvers/rectdiff/types.ts new file mode 100644 index 0000000..1d76605 --- /dev/null +++ b/lib/solvers/rectdiff/types.ts @@ -0,0 +1,63 @@ +// lib/solvers/rectdiff/types.ts +import type { SimpleRouteJson } from "../../types/srj-types" + +export type XYRect = { x: number; y: number; width: number; height: number } + +export type Rect3d = { + minX: number + minY: number + maxX: number + maxY: number + zLayers: number[] // sorted contiguous integers +} + +export type GridFill3DOptions = { + gridSizes?: number[] + initialCellRatio?: number + maxAspectRatio?: number | null + minSingle: { width: number; height: number } + minMulti: { width: number; height: number; minLayers: number } + preferMultiLayer?: boolean + maxMultiLayerSpan?: number +} + +export type Candidate3D = { + x: number + y: number + z: number + distance: number + /** Larger values mean more multi-layer potential at this seed. */ + zSpanLen?: number + /** Marked when the seed came from the edge analysis pass. */ + isEdgeSeed?: boolean +} +export type Placed3D = { rect: XYRect; zLayers: number[] } + +export type Phase = "GRID" | "EXPANSION" | "DONE" + +export type RectDiffState = { + // static + srj: SimpleRouteJson + layerNames: string[] + layerCount: number + bounds: XYRect + options: Required> & { + gridSizes: number[] + maxMultiLayerSpan: number | undefined + } + obstaclesByLayer: XYRect[][] + + // evolving + phase: Phase + gridIndex: number // index in gridSizes + candidates: Candidate3D[] + placed: Placed3D[] + placedByLayer: XYRect[][] + expansionIndex: number + /** Whether we've already run the edge-analysis seeding pass. */ + edgeAnalysisDone: boolean + + // progress bookkeeping + totalSeedsThisGrid: number + consumedSeedsThisGrid: number +} diff --git a/tests/examples/__snapshots__/example01.snap.svg b/tests/examples/__snapshots__/example01.snap.svg index f342d02..7252583 100644 --- a/tests/examples/__snapshots__/example01.snap.svg +++ b/tests/examples/__snapshots__/example01.snap.svg @@ -1,324 +1,81 @@ -