diff --git a/lib/solvers/RectDiffSeedingSolver/RectDiffSeedingSolver.ts b/lib/solvers/RectDiffSeedingSolver/RectDiffSeedingSolver.ts index a35b207..99bfe90 100644 --- a/lib/solvers/RectDiffSeedingSolver/RectDiffSeedingSolver.ts +++ b/lib/solvers/RectDiffSeedingSolver/RectDiffSeedingSolver.ts @@ -15,7 +15,6 @@ import { computeDefaultGridSizes } from "./computeDefaultGridSizes" import { computeCandidates3D } from "./computeCandidates3D" import { computeEdgeCandidates3D } from "./computeEdgeCandidates3D" import { longestFreeSpanAroundZ } from "./longestFreeSpanAroundZ" -import { allLayerNode } from "../../utils/buildHardPlacedByLayer" import { isFullyOccupiedAtPoint } from "../../utils/isFullyOccupiedAtPoint" import { resizeSoftOverlaps } from "../../utils/resizeSoftOverlaps" import { getColorForZLayer } from "../../utils/getColorForZLayer" @@ -292,10 +291,13 @@ export class RectDiffSeedingSolver extends BaseSolver { }, newIndex, ) - this.hardPlacedByLayer = allLayerNode({ - layerCount: this.layerCount, - placed: this.placed, - }) + // Full-stack nodes are protected from soft-overlap carving, so their + // layer lists only grow during seeding. + if (placed.zLayers.length >= this.layerCount) { + for (const z of placed.zLayers) { + this.hardPlacedByLayer[z]!.push(placed.rect) + } + } return // processed one candidate } diff --git a/lib/solvers/RectDiffSeedingSolver/computeEdgeCandidates3D.ts b/lib/solvers/RectDiffSeedingSolver/computeEdgeCandidates3D.ts index f106d59..132439d 100644 --- a/lib/solvers/RectDiffSeedingSolver/computeEdgeCandidates3D.ts +++ b/lib/solvers/RectDiffSeedingSolver/computeEdgeCandidates3D.ts @@ -3,6 +3,7 @@ import { EPS, distancePointToRectEdges } from "../../utils/rectdiff-geometry" import { isFullyOccupiedAtPoint } from "../../utils/isFullyOccupiedAtPoint" import { longestFreeSpanAroundZ } from "./longestFreeSpanAroundZ" import type RBush from "rbush" +import Flatbush from "flatbush" import type { RTreeRect } from "../../types/capacity-mesh-types" const quantize = (value: number, precision = 1e-6) => Math.round(value / precision) * precision @@ -17,81 +18,122 @@ const toRect = (rect: XYRect | RTreeRect): XYRect => } : rect +type ProjectedBlocker = { + start: number + end: number + sourceIndex: number +} + +type EdgeBounds = { + lineStart: number + lineEnd: number + clipStart: number + clipEnd: number + sourceIndex?: number +} + /** - * Compute exact uncovered segments along a 1D line. + * Project sorted blocker intervals onto an edge, preserving the original + * normalization of clipped intervals outside that edge. */ function computeUncoveredSegments(params: { - lineStart: number - lineEnd: number - coveringIntervals: Array<{ start: number; end: number }> + segment: EdgeBounds + coveringIntervals: ProjectedBlocker[] + hasReversedIntervals: boolean minSegmentLength: number }): Array<{ start: number; end: number; center: number }> { - const { lineStart, lineEnd, coveringIntervals, minSegmentLength } = params - const lineStartQ = quantize(lineStart) - const lineEndQ = quantize(lineEnd) - const normalizedIntervals = coveringIntervals - .map((i) => { - const s = quantize(i.start) - const e = quantize(i.end) - return { start: Math.min(s, e), end: Math.max(s, e) } - }) - .filter((i) => i.end > i.start + EPS) + const { segment, coveringIntervals, hasReversedIntervals, minSegmentLength } = + params + const lineStartQ = quantize(segment.lineStart) + const lineEndQ = quantize(segment.lineEnd) + const clipStartQ = quantize(segment.clipStart) + const clipEndQ = quantize(segment.clipEnd) + let normalizedIntervals: Array<{ start: number; end: number }> = [] + + if (hasReversedIntervals || clipStartQ > clipEndQ) { + // Reversed extents have no monotonic projection; retain the general + // normalization and ordering used by the exhaustive implementation. + normalizedIntervals = coveringIntervals + .filter((interval) => interval.sourceIndex !== segment.sourceIndex) + .map((interval) => { + const start = Math.max(clipStartQ, interval.start) + const end = Math.min(clipEndQ, interval.end) + return { start: Math.min(start, end), end: Math.max(start, end) } + }) + .filter((interval) => interval.end > interval.start + EPS) + .sort((a, b) => a.start - b.start) + } else { + let beforeStart = Infinity + let afterEnd = -Infinity + for (const interval of coveringIntervals) { + if (interval.sourceIndex === segment.sourceIndex) continue + const start = Math.max(clipStartQ, interval.start) + const end = Math.min(clipEndQ, interval.end) + if (end < start) { + // A disjoint clipped interval is reversed by the original algorithm. + // All intervals before the edge share clipStartQ as their end; + // all intervals after it share clipEndQ as their start. + if (interval.end < clipStartQ) { + beforeStart = Math.min(beforeStart, end) + } else { + afterEnd = Math.max(afterEnd, start) + } + } else if (end > start + EPS) { + // Clipping preserves start order for overlapping intervals. + normalizedIntervals.push({ start, end }) + } + } + if (clipStartQ > beforeStart + EPS) { + normalizedIntervals.unshift({ start: beforeStart, end: clipStartQ }) + } + if (afterEnd > clipEndQ + EPS) { + normalizedIntervals.push({ start: clipEndQ, end: afterEnd }) + } + } if (normalizedIntervals.length === 0) { const center = (lineStartQ + lineEndQ) / 2 return [{ start: lineStartQ, end: lineEndQ, center }] } - // Sort intervals by start position - const sorted = [...normalizedIntervals].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 > lineStartQ + EPS) { - const start = lineStartQ - const end = merged[0]!.start - if (end - start >= minSegmentLength) { - uncovered.push({ start, end, center: (start + end) / 2 }) - } + const currentStart = normalizedIntervals[0]!.start + let currentEnd = normalizedIntervals[0]!.end + if ( + currentStart > lineStartQ + EPS && + currentStart - lineStartQ >= minSegmentLength + ) { + uncovered.push({ + start: lineStartQ, + end: currentStart, + center: (lineStartQ + currentStart) / 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 }) + for (let i = 1; i < normalizedIntervals.length; i++) { + const interval = normalizedIntervals[i]! + if (interval.start <= currentEnd + EPS) { + currentEnd = Math.max(currentEnd, interval.end) + } else { + if (interval.start - currentEnd >= minSegmentLength) { + uncovered.push({ + start: currentEnd, + end: interval.start, + center: (currentEnd + interval.start) / 2, + }) + } + currentEnd = interval.end } } - - // Check gap after last interval - if (merged[merged.length - 1]!.end < lineEndQ - EPS) { - const start = merged[merged.length - 1]!.end - const end = lineEndQ - if (end - start >= minSegmentLength) { - uncovered.push({ start, end, center: (start + end) / 2 }) - } + if ( + currentEnd < lineEndQ - EPS && + lineEndQ - currentEnd >= minSegmentLength + ) { + uncovered.push({ + start: currentEnd, + end: lineEndQ, + center: (currentEnd + lineEndQ) / 2, + }) } - return uncovered } @@ -118,13 +160,38 @@ export function computeEdgeCandidates3D(params: { const out: Candidate3D[] = [] // Use small inset from edges for placement const δ = Math.max(minSize * 0.15, EPS * 3) - const dedup = new Set() + const edgesPerAxis = 2 + const edgesPerRectangle = 4 + let nextOrder = 0 + const acceptedSeeds = new Map< + string, + { x: number; y: number; z: number; order: number; pointOrder: number } + >() const hardRectsByLayer = Array.from({ length: layerCount }, (_, z) => [ ...(obstacleIndexByLayer[z]?.all() ?? []), ...(hardPlacedByLayer[z] ?? []), ].map(toRect), ) + // Ranking measures the original rectangle edges, before line-test rounding. + const hardRectIndexByLayer = hardRectsByLayer.map((rects) => { + if (rects.length === 0) return null + const index = new Flatbush(rects.length) + for (const rect of rects) { + const endX = rect.x + rect.width + const endY = rect.y + rect.height + // The index needs ordered bounds, including for reversed extents. + // Exact ranking still uses the original rectangle and distance helper. + index.add( + Math.min(rect.x, endX), + Math.min(rect.y, endY), + Math.max(rect.x, endX), + Math.max(rect.y, endY), + ) + } + index.finish() + return index + }) const key = (p: { x: number; y: number; z: number }) => `${p.z}|${p.x.toFixed(6)}|${p.y.toFixed(6)}` @@ -137,7 +204,13 @@ export function computeEdgeCandidates3D(params: { }) } - function pushIfFree(p: { x: number; y: number; z: number }) { + function pushIfFree(p: { + x: number + y: number + z: number + order: number + pointOrder: number + }) { const qx = quantize(p.x) const qy = quantize(p.y) const { z } = p @@ -150,39 +223,18 @@ export function computeEdgeCandidates3D(params: { 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 = hardRectsByLayer[z] ?? [] - let d = distancePointToRectEdges({ x, y }, bounds) - for (const blocker of hard) { - d = Math.min(d, distancePointToRectEdges({ x, y }, blocker)) - } - const distance = quantize(d) - 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, - minSpan: 1, - maxSpan: undefined, - obstacleIndexByLayer, - additionalBlockersByLayer: hardPlacedByLayer, - }) - out.push({ - x, - y, - z, - distance, - zSpanLen: span.length, - isEdgeSeed: true, - }) + const previous = acceptedSeeds.get(k) + if ( + previous && + (previous.order < p.order || + (previous.order === p.order && previous.pointOrder <= p.pointOrder)) + ) + return + if (fullyOcc({ x, y })) return + // Grouped lines run out of source order. Keep the first accepted point in + // the original order, including its exact quantized float representation. + acceptedSeeds.set(k, { x, y, z, order: p.order, pointOrder: p.pointOrder }) } for (let z = 0; z < layerCount; z++) { @@ -193,218 +245,247 @@ export function computeEdgeCandidates3D(params: { height: quantize(b.height), })) - // 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({ x: corner.x, y: 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({ - lineStart: bounds.x + δ, - lineEnd: bounds.x + bounds.width - δ, - coveringIntervals: topCovering, - minSegmentLength: 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({ x: seg.center, y: topY, z }) - if (segLen > minSize * 1.5) { - pushIfFree({ x: seg.start + minSize * 0.4, y: topY, z }) - pushIfFree({ x: seg.end - minSize * 0.4, y: topY, z }) - } + // Query quantized blockers on the full line. Clipping a disjoint + // interval can reverse its endpoints, so the query must remain unbounded. + const blockerIndex = + blockers.length > 0 ? new Flatbush(blockers.length) : null + if (blockerIndex) { + for (const blocker of blockers) { + blockerIndex.add( + blocker.x, + blocker.y, + blocker.x + blocker.width, + blocker.y + blocker.height, + ) } + blockerIndex.finish() } - // 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({ - lineStart: bounds.x + δ, - lineEnd: bounds.x + bounds.width - δ, - coveringIntervals: bottomCovering, - minSegmentLength: minSize * 0.5, - }) - for (const seg of bottomUncovered) { - const segLen = seg.end - seg.start - if (segLen >= minSize) { - pushIfFree({ x: seg.center, y: bottomY, z }) - if (segLen > minSize * 1.5) { - pushIfFree({ x: seg.start + minSize * 0.4, y: bottomY, z }) - pushIfFree({ x: seg.end - minSize * 0.4, y: bottomY, z }) - } - } + // Group only query descriptions, not their hits: retained memory is linear + // in the number of edges, even when every line crosses every blocker. + const verticalLines = new Map() + const horizontalLines = new Map() + const addLine = (params: { + vertical: boolean + coordinate: number + edgeOrdinal: number + }) => { + const lines = params.vertical ? verticalLines : horizontalLines + const segments = lines.get(params.coordinate) + if (segments) segments.push(params.edgeOrdinal) + else lines.set(params.coordinate, [params.edgeOrdinal]) } - // 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({ - lineStart: bounds.y + δ, - lineEnd: bounds.y + bounds.height - δ, - coveringIntervals: leftCovering, - minSegmentLength: minSize * 0.5, - }) - for (const seg of leftUncovered) { - const segLen = seg.end - seg.start - if (segLen >= minSize) { - pushIfFree({ x: leftX, y: seg.center, z }) - if (segLen > minSize * 1.5) { - pushIfFree({ x: leftX, y: seg.start + minSize * 0.4, z }) - pushIfFree({ x: leftX, y: seg.end - minSize * 0.4, z }) - } - } + // These ordinals follow the original walk: four corners, four board + // edges, then left/right/top/bottom for each blocker. Reserve each edge's + // slot even when it lies outside the board, so its source remains derivable. + const layerOrder = nextOrder + let nextEdgeOrdinal = 0 + + const corners = [ + { x: bounds.x + δ, y: bounds.y + δ }, + { x: bounds.x + bounds.width - δ, y: bounds.y + δ }, + { x: bounds.x + δ, y: bounds.y + bounds.height - δ }, + { x: bounds.x + bounds.width - δ, y: bounds.y + bounds.height - δ }, + ] + for (const corner of corners) { + pushIfFree({ + ...corner, + z, + order: layerOrder + nextEdgeOrdinal++, + pointOrder: 0, + }) } - // 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({ - lineStart: bounds.y + δ, - lineEnd: bounds.y + bounds.height - δ, - coveringIntervals: rightCovering, - minSegmentLength: minSize * 0.5, - }) - for (const seg of rightUncovered) { - const segLen = seg.end - seg.start - if (segLen >= minSize) { - pushIfFree({ x: rightX, y: seg.center, z }) - if (segLen > minSize * 1.5) { - pushIfFree({ x: rightX, y: seg.start + minSize * 0.4, z }) - pushIfFree({ x: rightX, y: seg.end - minSize * 0.4, z }) - } - } + const firstBoardEdgeOrdinal = nextEdgeOrdinal + for (const coordinate of [bounds.y + δ, bounds.y + bounds.height - δ]) { + addLine({ + vertical: false, + coordinate, + edgeOrdinal: nextEdgeOrdinal++, + }) + } + for (const coordinate of [bounds.x + δ, bounds.x + bounds.width - δ]) { + addLine({ + vertical: true, + coordinate, + edgeOrdinal: nextEdgeOrdinal++, + }) } - // 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, + const firstBlockerEdgeOrdinal = nextEdgeOrdinal + for (let sourceIndex = 0; sourceIndex < blockers.length; sourceIndex++) { + const blocker = blockers[sourceIndex]! + for (const coordinate of [blocker.x - δ, blocker.x + blocker.width + δ]) { + const edgeOrdinal = nextEdgeOrdinal++ + if ( + !( + coordinate > bounds.x + EPS && + coordinate < bounds.x + bounds.width - EPS ) - .map((bl) => ({ - start: Math.max(b.y, bl.y), - end: Math.min(b.y + b.height, bl.y + bl.height), - })) - const obLeftUncovered = computeUncoveredSegments({ - lineStart: b.y, - lineEnd: b.y + b.height, - coveringIntervals: obLeftCovering, - minSegmentLength: minSize * 0.5, + ) + continue + addLine({ + vertical: true, + coordinate, + edgeOrdinal, }) - for (const seg of obLeftUncovered) { - pushIfFree({ x: obLeftX, y: 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, + for (const coordinate of [ + blocker.y - δ, + blocker.y + blocker.height + δ, + ]) { + const edgeOrdinal = nextEdgeOrdinal++ + if ( + !( + coordinate > bounds.y + EPS && + coordinate < bounds.y + bounds.height - EPS ) - .map((bl) => ({ - start: Math.max(b.y, bl.y), - end: Math.min(b.y + b.height, bl.y + bl.height), - })) - const obRightUncovered = computeUncoveredSegments({ - lineStart: b.y, - lineEnd: b.y + b.height, - coveringIntervals: obRightCovering, - minSegmentLength: minSize * 0.5, + ) + continue + addLine({ + vertical: false, + coordinate, + edgeOrdinal, }) - for (const seg of obRightUncovered) { - pushIfFree({ x: obRightX, y: 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, + nextOrder = layerOrder + nextEdgeOrdinal + for (const vertical of [true, false]) { + const lines = vertical ? verticalLines : horizontalLines + for (const [coordinate, segments] of lines) { + const indices = blockerIndex + ? vertical + ? blockerIndex.search(coordinate, -Infinity, coordinate, Infinity) + : blockerIndex.search(-Infinity, coordinate, Infinity, coordinate) + : [] + const coveringIntervals: ProjectedBlocker[] = [] + let hasReversedIntervals = false + for (const sourceIndex of indices) { + const blocker = blockers[sourceIndex]! + if ( + vertical + ? blocker.x > coordinate || blocker.x + blocker.width < coordinate + : blocker.y > coordinate || + blocker.y + blocker.height < coordinate ) - .map((bl) => ({ - start: Math.max(b.x, bl.x), - end: Math.min(b.x + b.width, bl.x + bl.width), - })) - const obTopUncovered = computeUncoveredSegments({ - lineStart: b.x, - lineEnd: b.x + b.width, - coveringIntervals: obTopCovering, - minSegmentLength: minSize * 0.5, - }) - for (const seg of obTopUncovered) { - pushIfFree({ x: seg.center, y: obTopY, z }) + continue + const start = quantize(vertical ? blocker.y : blocker.x) + const end = quantize( + vertical ? blocker.y + blocker.height : blocker.x + blocker.width, + ) + coveringIntervals.push({ start, end, sourceIndex }) + if (end < start) hasReversedIntervals = true + } + coveringIntervals.sort((a, b) => a.start - b.start) + for (const edgeOrdinal of segments) { + const isBoardEdge = edgeOrdinal < firstBlockerEdgeOrdinal + const blockerEdgeOffset = edgeOrdinal - firstBlockerEdgeOrdinal + const sourceIndex = isBoardEdge + ? undefined + : Math.floor(blockerEdgeOffset / edgesPerRectangle) + const source = isBoardEdge ? bounds : blockers[sourceIndex!]! + const leading = isBoardEdge + ? edgeOrdinal === firstBoardEdgeOrdinal || + edgeOrdinal === firstBoardEdgeOrdinal + edgesPerAxis + : blockerEdgeOffset % edgesPerRectangle === 0 || + blockerEdgeOffset % edgesPerRectangle === edgesPerAxis + const start = vertical ? source.x : source.y + const extent = vertical ? source.width : source.height + // Recompute the original expression instead of emitting the group's + // Map key: numerically equal keys can have different signed zeros. + const exactCoordinate = isBoardEdge + ? leading + ? start + δ + : start + extent - δ + : leading + ? start - δ + : start + extent + δ + const clipStart = vertical ? source.y : source.x + const clipEnd = vertical + ? source.y + source.height + : source.x + source.width + const uncovered = computeUncoveredSegments({ + segment: { + lineStart: isBoardEdge ? clipStart + δ : clipStart, + lineEnd: isBoardEdge ? clipEnd - δ : clipEnd, + clipStart, + clipEnd, + sourceIndex, + }, + coveringIntervals, + hasReversedIntervals, + minSegmentLength: minSize * 0.5, + }) + let pointOrder = 0 + for (const gap of uncovered) { + const pushAt = (position: number) => + pushIfFree({ + ...(vertical + ? { x: exactCoordinate, y: position, z } + : { x: position, y: exactCoordinate, z }), + order: layerOrder + edgeOrdinal, + pointOrder: pointOrder++, + }) + const length = gap.end - gap.start + if (isBoardEdge) { + if (length < minSize) continue + pushAt(gap.center) + if (length > minSize * 1.5) { + pushAt(gap.start + minSize * 0.4) + pushAt(gap.end - minSize * 0.4) + } + } else { + pushAt(gap.center) + } + } } } + } + } - // 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({ - lineStart: b.x, - lineEnd: b.x + b.width, - coveringIntervals: obBottomCovering, - minSegmentLength: minSize * 0.5, - }) - for (const seg of obBottomUncovered) { - pushIfFree({ x: seg.center, y: obBottomY, z }) - } + // Rank each accepted point once, in the same order as the exhaustive walk. + const seeds = [...acceptedSeeds.values()].sort( + (a, b) => a.order - b.order || a.pointOrder - b.pointOrder, + ) + for (const { x, y, z } of seeds) { + // Distance uses obstacles + hard nodes (soft nodes ignored for ranking) + const hard = hardRectsByLayer[z] ?? [] + let d = distancePointToRectEdges({ x, y }, bounds) + const hardRectIndex = hardRectIndexByLayer[z] + if (hardRectIndex) { + // A nearest box supplies an upper bound, not necessarily the nearest + // edge: several rectangles may contain the point with different gaps. + const nearestIndex = hardRectIndex.neighbors(x, y, 1)[0]! + d = Math.min(d, distancePointToRectEdges({ x, y }, hard[nearestIndex]!)) + // Any edge closer than d belongs to a rectangle intersecting this square. + const nearbyIndices = hardRectIndex.search(x - d, y - d, x + d, y + d) + for (const index of nearbyIndices) { + d = Math.min(d, distancePointToRectEdges({ x, y }, hard[index]!)) } } + const distance = quantize(d) + + // Approximate z-span strength at this z (ignoring soft nodes) + const span = longestFreeSpanAroundZ({ + x, + y, + z, + layerCount, + minSpan: 1, + maxSpan: undefined, + obstacleIndexByLayer, + additionalBlockersByLayer: hardPlacedByLayer, + }) + out.push({ + x, + y, + z, + distance, + zSpanLen: span.length, + isEdgeSeed: true, + }) } // Strong multi-layer preference then distance. diff --git a/lib/utils/expandRectFromSeed.ts b/lib/utils/expandRectFromSeed.ts index 9285330..b1fe946 100644 --- a/lib/utils/expandRectFromSeed.ts +++ b/lib/utils/expandRectFromSeed.ts @@ -226,12 +226,31 @@ export function expandRectFromSeed(params: { const blockers: XYRect[] = [] const seen = new Set() const totalLayers = placedIndexByLayer.length + const queriedBounds: Array<{ + minX: number + minY: number + maxX: number + maxY: number + }> = [] // Ignore the existing placement we are expanding so it doesn't self-block. const collectBlockers = (searchRect: XYRect) => { const query = toQueryRect({ bounds, rect: searchRect }) if (!query) return + // The indexes stay unchanged throughout this expansion, and blockers are + // shared by all strategies. A completed containing query has already + // collected every blocker this query could return. + for (const previous of queriedBounds) { + if ( + previous.minX <= query.minX && + previous.minY <= query.minY && + previous.maxX >= query.maxX && + previous.maxY >= query.maxY + ) { + return + } + } for (const z of params.zLayers) { const blockersIndex = obsticalIndexByLayer[z] if (blockersIndex) { @@ -258,6 +277,19 @@ export function expandRectFromSeed(params: { } } } + // Retain only maximal query boxes to keep later containment checks small. + for (let i = queriedBounds.length - 1; i >= 0; i--) { + const previous = queriedBounds[i]! + if ( + query.minX <= previous.minX && + query.minY <= previous.minY && + query.maxX >= previous.maxX && + query.maxY >= previous.maxY + ) { + queriedBounds.splice(i, 1) + } + } + queriedBounds.push(query) } const strategies = [ diff --git a/lib/utils/resizeSoftOverlaps.ts b/lib/utils/resizeSoftOverlaps.ts index 87a19e5..b5400d9 100644 --- a/lib/utils/resizeSoftOverlaps.ts +++ b/lib/utils/resizeSoftOverlaps.ts @@ -26,9 +26,9 @@ export function resizeSoftOverlaps( // Protect full-stack nodes if (old.zLayers.length >= layerCount) continue + if (!overlaps(old.rect, newR)) 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) diff --git a/tests/__snapshots__/compute-edge-candidates-index-snapshot.snap.svg b/tests/__snapshots__/compute-edge-candidates-index-snapshot.snap.svg new file mode 100644 index 0000000..787da6f --- /dev/null +++ b/tests/__snapshots__/compute-edge-candidates-index-snapshot.snap.svg @@ -0,0 +1,44 @@ +Exhaustive reference / z04 seeds; same positions and orderingCorner ranking distance: 0.100000Indexed queries / z04 seeds; same positions and orderingCorner ranking distance: 0.100000Exhaustive reference / z120 seeds; same positions and orderingDuplicate blockers: four unique edge seedsIndexed queries / z120 seeds; same positions and orderingDuplicate blockers: four unique edge seeds \ No newline at end of file diff --git a/tests/__snapshots__/compute-edge-candidates-query-snapshot.snap.svg b/tests/__snapshots__/compute-edge-candidates-query-snapshot.snap.svg new file mode 100644 index 0000000..d0a27f0 --- /dev/null +++ b/tests/__snapshots__/compute-edge-candidates-query-snapshot.snap.svg @@ -0,0 +1,44 @@ +BEFORE: scan every rectangle12 rectangles checked for this lineOne obstacle-edge query at x = 4.85sourceobstacleOrange: checked Gray: skippedGreen: free gaps Blue: seed pointsSame 3 seeds on this edgeEvery edge repeats the full scanAFTER: query the spatial index2 rectangles checked for this lineOne obstacle-edge query at x = 4.85sourceobstacleOrange: checked Gray: skippedGreen: free gaps Blue: seed pointsSame 3 seeds on this edgeIndex built once, reused for every edge \ No newline at end of file diff --git a/tests/compute-edge-candidates-index-snapshot.test.ts b/tests/compute-edge-candidates-index-snapshot.test.ts new file mode 100644 index 0000000..8f6e435 --- /dev/null +++ b/tests/compute-edge-candidates-index-snapshot.test.ts @@ -0,0 +1,74 @@ +import { expect, test } from "bun:test" +import { getSvgFromGraphicsObject } from "graphics-debug" +import { computeEdgeCandidates3D } from "../lib/solvers/RectDiffSeedingSolver/computeEdgeCandidates3D" +import { containsPoint } from "../lib/utils/rectdiff-geometry" +import { computeEdgeCandidates3DReference } from "./fixtures/computeEdgeCandidates3DReference" +import { + edgeCandidateObstaclesByLayer, + makeEdgeCandidateComparisonGraphics, + makeEdgeCandidateSnapshotParams, +} from "./fixtures/indexedEdgeCandidateSnapshot" + +test("indexed edge queries preserve seed geometry, raw ranking and deduplication", async (): Promise => { + const params = makeEdgeCandidateSnapshotParams(edgeCandidateObstaclesByLayer) + const reference = computeEdgeCandidates3DReference(params) + const indexed = computeEdgeCandidates3D(params) + + // This is a performance fix: the indexed and exhaustive drawings must match, + // including candidate order and layer spans, rather than changing the mesh. + expect(indexed).toEqual(reference) + expect(indexed.filter((candidate) => candidate.z === 0)).toHaveLength(4) + expect(indexed.filter((candidate) => candidate.z === 1)).toHaveLength(20) + + // Both rectangles contain this corner. Box distance would incorrectly rank + // it as zero; using rounded line geometry would give 0.099999, not 0.100000. + const corner = indexed.find( + (candidate) => + candidate.z === 0 && candidate.x === 0.15 && candidate.y === 0.15, + )! + expect(corner.distance).toBeCloseTo(0.1, 12) + expect(corner.zSpanLen).toBe(2) + + // The center is occupied on every layer, so it contributes no candidates. + const centerBlocker = edgeCandidateObstaclesByLayer[1]![0]! + expect( + indexed.some((candidate) => containsPoint(centerBlocker, candidate)), + ).toBe(false) + const edgeSeeds = indexed + .filter( + (candidate) => + candidate.z === 1 && + candidate.x > 0.5 && + candidate.x < 1.5 && + candidate.y > 0.5 && + candidate.y < 1.5, + ) + .map((candidate) => `${candidate.x.toFixed(2)},${candidate.y.toFixed(2)}`) + expect(edgeSeeds.sort()).toEqual([ + "0.65,1.00", + "1.00,0.65", + "1.00,1.35", + "1.35,1.00", + ]) + expect( + new Set( + indexed.map( + (candidate) => `${candidate.z}|${candidate.x}|${candidate.y}`, + ), + ).size, + ).toBe(indexed.length) + expect(indexed).toEqual( + computeEdgeCandidates3D( + makeEdgeCandidateSnapshotParams([ + edgeCandidateObstaclesByLayer[0]!, + [centerBlocker], + ]), + ), + ) + + const svg = getSvgFromGraphicsObject( + makeEdgeCandidateComparisonGraphics(reference, indexed), + { backgroundColor: "white", svgWidth: 1200, svgHeight: 1200 }, + ) + await expect(svg).toMatchSvgSnapshot(import.meta.path) +}) diff --git a/tests/compute-edge-candidates-index.test.ts b/tests/compute-edge-candidates-index.test.ts new file mode 100644 index 0000000..e2e4461 --- /dev/null +++ b/tests/compute-edge-candidates-index.test.ts @@ -0,0 +1,513 @@ +import { expect, spyOn, test } from "bun:test" +import Flatbush from "flatbush" +import RBush from "rbush" +import type { XYRect } from "../lib/rectdiff-types" +import { computeEdgeCandidates3D } from "../lib/solvers/RectDiffSeedingSolver/computeEdgeCandidates3D" +import type { RTreeRect } from "../lib/types/capacity-mesh-types" +import { computeEdgeCandidates3DReference } from "./fixtures/computeEdgeCandidates3DReference" + +type Layer = { + obstacles?: XYRect[] + hard?: XYRect[] + soft?: XYRect[] +} + +type Scenario = { + name: string + bounds: XYRect + minSize: number + layerCount: number + layers: Array +} + +function makeIndex(rects: XYRect[], z: number): RBush { + return new RBush().load( + rects.map((r) => ({ + ...r, + minX: r.x, + minY: r.y, + maxX: r.x + r.width, + maxY: r.y + r.height, + zLayers: [z], + })), + ) +} + +function makeParams(scenario: Scenario) { + return { + bounds: scenario.bounds, + minSize: scenario.minSize, + layerCount: scenario.layerCount, + obstacleIndexByLayer: scenario.layers.map((layer, z) => + layer ? makeIndex(layer.obstacles ?? [], z) : undefined, + ), + placedIndexByLayer: scenario.layers.map((layer, z) => + layer + ? makeIndex([...(layer.hard ?? []), ...(layer.soft ?? [])], z) + : undefined, + ), + hardPlacedByLayer: scenario.layers.map((layer) => layer?.hard ?? []), + } +} + +function makeRandomScenarios(): Scenario[] { + let state = 0x109bcafe + const random = () => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0 + return state / 0x100000000 + } + const integer = (max: number) => Math.floor(random() * max) + const scenarios: Scenario[] = [] + + for (let caseIndex = 0; caseIndex < 80; caseIndex++) { + const layerCount = 1 + integer(6) + const bounds = { + x: -15 + random() * 20, + y: -15 + random() * 20, + width: 2 + random() * 35, + height: 2 + random() * 35, + } + const layers: Array = [] + // Some boards have truncated layer arrays as well as explicit missing layers. + const suppliedLayerCount = + caseIndex % 7 === 0 ? Math.max(0, layerCount - 1) : layerCount + for (let z = 0; z < suppliedLayerCount; z++) { + if (random() < 0.15) { + layers.push(undefined) + continue + } + const layer: Required = { obstacles: [], hard: [], soft: [] } + const count = integer(38) + for (let i = 0; i < count; i++) { + const jitter = (integer(3) - 1) * 0.00000049 + const r = { + x: bounds.x + (random() * 1.6 - 0.3) * bounds.width + jitter, + y: bounds.y + (random() * 1.6 - 0.3) * bounds.height - jitter, + width: i % 9 === 0 ? 0.00000049 : random() * bounds.width * 0.6, + height: i % 11 === 0 ? 0.00000151 : random() * bounds.height * 0.6, + } + const destination = [layer.obstacles, layer.hard, layer.soft][ + integer(3) + ]! + destination.push(r) + if (i % 13 === 0) destination.push({ ...r }) + } + layers.push(layer) + } + scenarios.push({ + name: `seeded board ${caseIndex}`, + bounds, + minSize: [0.00000001, 0.05, 0.2, 1, 3][caseIndex % 5]!, + layerCount, + layers, + }) + } + return scenarios +} + +test("indexed edge candidates exactly match exhaustive analysis", () => { + const duplicate = { x: 4, y: 4, width: 2, height: 2 } + const scenarios: Scenario[] = [ + { + name: "empty board with missing and empty layer indexes", + bounds: { x: 0, y: 0, width: 12, height: 10 }, + minSize: 1, + layerCount: 4, + layers: [{}, undefined, {}], + }, + { + name: "obstacles, hard placements, soft placements, and duplicate geometry", + bounds: { x: 0, y: 0, width: 12, height: 10 }, + minSize: 0.5, + layerCount: 4, + layers: [ + { + obstacles: [ + duplicate, + { ...duplicate }, + { x: 1, y: 1, width: 2, height: 3 }, + ], + hard: [{ ...duplicate }, { x: 8, y: 2, width: 1, height: 5 }], + soft: [{ x: 0, y: 0, width: 4, height: 10 }], + }, + { + hard: [{ x: 0, y: 0, width: 4, height: 10 }], + soft: [{ x: 7, y: 0, width: 5, height: 10 }], + }, + { + obstacles: [{ x: 0, y: 0, width: 4, height: 10 }], + soft: [{ x: 7, y: 0, width: 5, height: 10 }], + }, + { soft: [{ x: 0, y: 0, width: 12, height: 10 }] }, + ], + }, + { + name: "fully occupied across obstacles and soft placements", + bounds: { x: -4, y: -3, width: 12, height: 10 }, + minSize: 0.5, + layerCount: 3, + layers: [ + { obstacles: [{ x: -4, y: -3, width: 12, height: 10 }] }, + { hard: [{ x: -4, y: -3, width: 12, height: 10 }] }, + { soft: [{ x: -4, y: -3, width: 12, height: 10 }] }, + ], + }, + { + name: "quantization boundaries and subprecision widths", + bounds: { + x: -0.00000049, + y: 0.00000051, + width: 12.00000049, + height: 10.00000051, + }, + minSize: 0.2, + layerCount: 2, + layers: [ + { + obstacles: [ + { + x: 2.00000049, + y: 1.99999951, + width: 0.99999951, + height: 2.00000049, + }, + { + x: 4.00000051, + y: 3.00000049, + width: 0.00000049, + height: 1.00000051, + }, + { + x: 6.99999949, + y: 2.00000051, + width: 0.00000051, + height: 3.99999949, + }, + { x: 0.03, y: 0.03, width: 1.00000049, height: 0.00000149 }, + ], + hard: [ + { + x: 9.00000051, + y: 6.99999951, + width: 1.00000051, + height: 0.00000151, + }, + ], + }, + { soft: [{ x: 0, y: 0, width: 1, height: 10 }] }, + ], + }, + { + name: "disjoint clipped intervals retain their original normalization", + bounds: { x: 0, y: 0, width: 12, height: 10 }, + minSize: 1, + layerCount: 2, + layers: [ + { + obstacles: [ + { x: 4, y: 4, width: 2, height: 2 }, + { x: 3, y: -5, width: 1, height: 2 }, + { x: 3, y: 8, width: 1, height: 3 }, + { x: -5, y: 3, width: 2, height: 1 }, + { x: 9, y: 3, width: 5, height: 1 }, + { x: -8, y: -1, width: 2, height: 3 }, + { x: 14, y: -1, width: 2, height: 3 }, + { x: -1, y: 14, width: 3, height: 2 }, + { x: -1, y: -6, width: 3, height: 2 }, + ], + }, + {}, + ], + }, + { + name: "touching edges, narrow gaps, and zero-size geometry", + bounds: { x: 0, y: 0, width: 12, height: 10 }, + minSize: 0.2, + layerCount: 3, + layers: [ + { + obstacles: [ + { x: 1, y: 1, width: 2, height: 2 }, + { x: 3, y: 1, width: 2, height: 2 }, + { x: 5.00000049, y: 1, width: 2, height: 2 }, + { x: 7.00000151, y: 1, width: 2, height: 2 }, + { x: 0, y: 0.03, width: 12, height: 0 }, + { x: 0.03, y: 0, width: 0, height: 10 }, + { x: 3, y: 3, width: 0, height: 0 }, + ], + }, + { hard: [{ x: 1, y: 3.03, width: 8, height: 0.01 }] }, + {}, + ], + }, + { + name: "long thin rectangles and perpendicular offboard intervals", + bounds: { x: -1000000, y: -2, width: 2000000, height: 4 }, + minSize: 0.01, + layerCount: 2, + layers: [ + { + obstacles: [ + { x: -2000000, y: -0.2, width: 4000000, height: 0.1 }, + { x: -0.1, y: -1000000, width: 0.2, height: 2000000 }, + { x: 100, y: 1.5, width: 100000, height: 0.00000049 }, + { x: -100000, y: -1.5, width: 100, height: 0.00000151 }, + ], + hard: [{ x: -500000, y: -1, width: 1000000, height: 0.01 }], + }, + {}, + ], + }, + { + name: "overlapping containing boxes rank by nearest edge rather than box distance", + bounds: { x: 0, y: 0, width: 100, height: 100 }, + minSize: 0.5, + layerCount: 3, + layers: [ + { + obstacles: [ + { x: -5, y: -5, width: 110, height: 110 }, + { x: 10, y: 10, width: 40, height: 40 }, + { x: 20, y: 20, width: 5, height: 5 }, + { x: 18, y: 18, width: 15, height: 15 }, + ], + hard: [ + { x: 19, y: 19, width: 7, height: 7 }, + { x: 20, y: 20, width: 5, height: 5 }, + ], + }, + { + hard: [ + { x: 45, y: 45, width: 20, height: 20 }, + { x: 40, y: 40, width: 40, height: 40 }, + ], + }, + {}, + ], + }, + { + name: "raw edge distances retain subprecision offsets and duplicate seeds", + bounds: { x: -2, y: -2, width: 16, height: 14 }, + minSize: 0.000001, + layerCount: 2, + layers: [ + { + obstacles: [ + { + x: 2.00000049, + y: 2.00000049, + width: 4.00000049, + height: 3.00000049, + }, + { + x: 2.00000051, + y: 2.00000051, + width: 3.99999951, + height: 2.99999951, + }, + { + x: 6.00000049, + y: 3.00000051, + width: 0.00000049, + height: 0.00000051, + }, + { + x: 6.00000049, + y: 3.00000051, + width: 0.00000049, + height: 0.00000051, + }, + ], + hard: [ + { + x: 1.00000049, + y: 1.00000051, + width: 8.00000049, + height: 7.99999951, + }, + { + x: 2.00000049, + y: 2.00000049, + width: 4.00000049, + height: 3.00000049, + }, + ], + }, + {}, + ], + }, + { + name: "large translated coordinates preserve raw ranking distances", + bounds: { x: 1e12, y: -1e12, width: 8000, height: 6000 }, + minSize: 0.5, + layerCount: 2, + layers: [ + { + obstacles: [ + { x: 1e12 - 1, y: -1e12 - 1, width: 8002, height: 6002 }, + { + x: 1e12 + 100.00049, + y: -1e12 + 200.00051, + width: 2000.00049, + height: 3000.00051, + }, + { + x: 1e12 + 150.00051, + y: -1e12 + 250.00049, + width: 100.00051, + height: 200.00049, + }, + { + x: 1e12 + 150.00051, + y: -1e12 + 250.00049, + width: 100.00051, + height: 200.00049, + }, + ], + hard: [ + { + x: 1e12 + 4000.00049, + y: -1e12 + 3000.00051, + width: 0, + height: 1200.00049, + }, + ], + }, + {}, + ], + }, + { + name: "grouped queries preserve the first accepted float representation", + bounds: { x: -10, y: -10, width: 20, height: 20 }, + minSize: 0.00000001, + layerCount: 3, + layers: [ + {}, + { + obstacles: [ + { + x: -3.00000049, + y: 13, + width: 3.0000000001, + height: 0.00000049, + }, + { + x: 0.0000000001, + y: -1.00000049, + width: 0, + height: 0.00000051, + }, + ], + }, + {}, + ], + }, + { + name: "EPS inset dominates a subprecision minimum size", + bounds: { x: -1, y: -1, width: 2, height: 2 }, + minSize: 0.00000001, + layerCount: 1, + layers: [ + { + obstacles: [ + { x: 0, y: -1, width: 0.00000049, height: 2 }, + { x: -1, y: 0, width: 2, height: 0.00000051 }, + ], + }, + ], + }, + ...makeRandomScenarios(), + ] + + for (const scenario of scenarios) { + const params = makeParams(scenario) + expect({ + scenario: scenario.name, + candidates: computeEdgeCandidates3D(params), + }).toEqual({ + scenario: scenario.name, + candidates: computeEdgeCandidates3DReference(params), + }) + } +}) + +test("raw ranking includes reversed rectangles beside a containing box", () => { + for (const rect of [ + { x: 0.2, y: 0, width: -3, height: 1 }, + { x: 0, y: 0.2, width: 1, height: -3 }, + { x: 0.2, y: 0.2, width: -3, height: -3 }, + ]) { + for (const destination of ["obstacles", "hard"] as const) { + const params = makeParams({ + name: `reversed ${destination} ranking`, + bounds: { x: 0, y: 0, width: 10, height: 10 }, + minSize: 1, + layerCount: 2, + layers: [ + { + [destination]: [{ x: 0, y: 0, width: 1, height: 1 }, rect], + }, + {}, + ], + }) + const result = computeEdgeCandidates3D(params) + expect(result).toEqual(computeEdgeCandidates3DReference(params)) + const corner = result.find( + (candidate) => + candidate.x === 0.15 && candidate.y === 0.15 && candidate.z === 0, + )! + expect(corner.distance).toBeCloseTo( + rect.width < 0 && rect.height < 0 ? 0.070711 : 0.05, + 6, + ) + } + } +}) + +test("aligned edge queries share one line search and preserve disjoint and reversed intervals", () => { + const params = makeParams({ + name: "aligned sources with clipped and reversed projections", + bounds: { x: 0, y: 0, width: 12, height: 10 }, + minSize: 1, + layerCount: 2, + layers: [ + { + obstacles: [ + { x: 5, y: 2, width: 2, height: 2 }, + { x: 5, y: 6, width: 2, height: 2 }, + { x: 5, y: 2, width: 2, height: 2 }, + { x: 4.5, y: 0, width: 1, height: 1 }, + { x: 4.5, y: 9, width: 1, height: 1 }, + { x: 4.5, y: 4, width: 1, height: 1 }, + { x: 4.5, y: 5, width: 1, height: -1 }, + ], + }, + {}, + ], + }) + const reference = computeEdgeCandidates3DReference(params) + const originalSearch = Flatbush.prototype.search + let sharedLineSearches = 0 + const searchSpy = spyOn(Flatbush.prototype, "search").mockImplementation( + function ( + this: Flatbush, + ...args: Parameters + ): number[] { + if ( + args[0] === 4.85 && + args[1] === -Infinity && + args[2] === 4.85 && + args[3] === Infinity + ) { + sharedLineSearches++ + } + return originalSearch.apply(this, args) + }, + ) + try { + expect(computeEdgeCandidates3D(params)).toEqual(reference) + } finally { + searchSpy.mockRestore() + } + expect(sharedLineSearches).toBe(1) +}) diff --git a/tests/compute-edge-candidates-query-snapshot.test.ts b/tests/compute-edge-candidates-query-snapshot.test.ts new file mode 100644 index 0000000..9df4672 --- /dev/null +++ b/tests/compute-edge-candidates-query-snapshot.test.ts @@ -0,0 +1,88 @@ +import { expect, spyOn, test } from "bun:test" +import Flatbush from "flatbush" +import { getSvgFromGraphicsObject } from "graphics-debug" +import type { Candidate3D, XYRect } from "../lib/rectdiff-types" +import { computeEdgeCandidates3D } from "../lib/solvers/RectDiffSeedingSolver/computeEdgeCandidates3D" +import { computeEdgeCandidates3DReference } from "./fixtures/computeEdgeCandidates3DReference" +import { + makeQueryWorkComparisonGraphics, + makeQueryWorkSnapshotParams, + queryX, +} from "./fixtures/indexedEdgeQuerySnapshot" + +test("indexed edge query returns two blockers while preserving the exhaustive seeds", async (): Promise => { + const params = makeQueryWorkSnapshotParams() + const sourceBlockers: XYRect[] = params.obstacleIndexByLayer[0]!.all().map( + (rect) => ({ + x: rect.minX, + y: rect.minY, + width: rect.maxX - rect.minX, + height: rect.maxY - rect.minY, + }), + ) + const reference = computeEdgeCandidates3DReference(params) + const capturedSearches: number[][] = [] + const originalSearch = Flatbush.prototype.search + const searchSpy = spyOn(Flatbush.prototype, "search").mockImplementation( + function ( + this: Flatbush, + ...args: Parameters + ): number[] { + const indices = originalSearch.apply(this, args) + if ( + args[0] === queryX && + args[1] === -Infinity && + args[2] === queryX && + args[3] === Infinity + ) { + capturedSearches.push([...indices]) + } + return indices + }, + ) + let indexed: Candidate3D[] + try { + indexed = computeEdgeCandidates3D(params) + } finally { + searchSpy.mockRestore() + } + + expect(indexed).toEqual(reference) + expect(sourceBlockers).toHaveLength(12) + expect(capturedSearches).toHaveLength(1) + const queriedBlockers = capturedSearches[0]!.map( + (index) => sourceBlockers[index]!, + ) + expect(queriedBlockers).toHaveLength(2) + expect([...queriedBlockers].sort((left, right) => left.y - right.y)).toEqual([ + { x: 4.5, y: 3, width: 1, height: 1 }, + { x: 4.5, y: 6, width: 1, height: 1 }, + ]) + + const referenceSeeds = reference.filter((candidate) => candidate.x === queryX) + const indexedSeeds = indexed.filter((candidate) => candidate.x === queryX) + expect(indexedSeeds).toEqual(referenceSeeds) + expect(indexedSeeds.map(({ x, y, z }) => ({ x, y, z }))).toEqual([ + { x: queryX, y: 2.5, z: 0 }, + { x: queryX, y: 5, z: 0 }, + { x: queryX, y: 7.5, z: 0 }, + ]) + expect( + indexedSeeds.some((seed) => + queriedBlockers.some( + (blocker) => + seed.y >= blocker.y && seed.y <= blocker.y + blocker.height, + ), + ), + ).toBe(false) + + const svg = getSvgFromGraphicsObject( + makeQueryWorkComparisonGraphics({ + reference: referenceSeeds, + indexed: indexedSeeds, + queriedBlockers, + }), + { backgroundColor: "white", svgWidth: 1600, svgHeight: 1000 }, + ) + await expect(svg).toMatchSvgSnapshot(import.meta.path) +}) diff --git a/tests/expand-rect-from-seed-query-reuse.test.ts b/tests/expand-rect-from-seed-query-reuse.test.ts new file mode 100644 index 0000000..ae6b4d1 --- /dev/null +++ b/tests/expand-rect-from-seed-query-reuse.test.ts @@ -0,0 +1,54 @@ +import { expect, spyOn, test } from "bun:test" +import RBush from "rbush" +import type { RTreeRect } from "../lib/types/capacity-mesh-types" +import { expandRectFromSeed } from "../lib/utils/expandRectFromSeed" +import { rectToTree } from "../lib/utils/rectToTree" + +test("expansion reuses blockers across strategies without changing the rectangle", () => { + const obstacles = [ + { x: 0, y: 0, width: 10, height: 2 }, + { x: 0, y: 8, width: 10, height: 2 }, + { x: 0, y: 2, width: 2, height: 6 }, + { x: 8, y: 2, width: 2, height: 6 }, + ] + const obstacleIndexByLayer = Array.from({ length: 2 }, () => + new RBush().load( + obstacles.map((rect) => rectToTree(rect, { zLayers: [0, 1] })), + ), + ) + const search = spyOn(obstacleIndexByLayer[0]!, "search") + + const rectangle = expandRectFromSeed({ + startX: 5, + startY: 5, + gridSize: 2, + bounds: { x: 0, y: 0, width: 10, height: 10 }, + obsticalIndexByLayer: obstacleIndexByLayer, + placedIndexByLayer: [new RBush(), new RBush()], + initialCellRatio: 0.2, + maxAspectRatio: 3, + minReq: { width: 1, height: 1 }, + zLayers: [0, 1], + }) + + expect(rectangle).toEqual({ x: 2, y: 2, width: 6, height: 6 }) + // The same five strategies previously performed 80 searches per layer. + expect(search.mock.calls.length).toBeLessThan(15) + + // Every search must add a region not covered by a completed query. + const queries = search.mock.calls.map(([query]) => query) + for (let i = 0; i < queries.length; i++) { + const query = queries[i]! + expect( + queries + .slice(0, i) + .some( + (previous) => + previous.minX <= query.minX && + previous.minY <= query.minY && + previous.maxX >= query.maxX && + previous.maxY >= query.maxY, + ), + ).toBe(false) + } +}) diff --git a/tests/fixtures/computeEdgeCandidates3DReference.ts b/tests/fixtures/computeEdgeCandidates3DReference.ts new file mode 100644 index 0000000..454897d --- /dev/null +++ b/tests/fixtures/computeEdgeCandidates3DReference.ts @@ -0,0 +1,426 @@ +// Frozen exhaustive implementation from af6e487dc7c08333d97de6d6ac49a6bc62ee174a. +// Keep its interval clipping, quantization, ordering, and deduplication behavior +// independent of the optimized production implementation. +import type { Candidate3D, XYRect } from "../../lib/rectdiff-types" +import { + EPS, + distancePointToRectEdges, +} from "../../lib/utils/rectdiff-geometry" +import { isFullyOccupiedAtPoint } from "../../lib/utils/isFullyOccupiedAtPoint" +import { longestFreeSpanAroundZ } from "../../lib/solvers/RectDiffSeedingSolver/longestFreeSpanAroundZ" +import type RBush from "rbush" +import type { RTreeRect } from "../../lib/types/capacity-mesh-types" +const quantize = (value: number, precision = 1e-6) => + Math.round(value / precision) * precision + +const toRect = (rect: XYRect | RTreeRect): XYRect => + "minX" in rect + ? { + x: rect.minX, + y: rect.minY, + width: rect.maxX - rect.minX, + height: rect.maxY - rect.minY, + } + : rect + +/** + * Compute exact uncovered segments along a 1D line. + */ +function computeUncoveredSegments(params: { + lineStart: number + lineEnd: number + coveringIntervals: Array<{ start: number; end: number }> + minSegmentLength: number +}): Array<{ start: number; end: number; center: number }> { + const { lineStart, lineEnd, coveringIntervals, minSegmentLength } = params + const lineStartQ = quantize(lineStart) + const lineEndQ = quantize(lineEnd) + const normalizedIntervals = coveringIntervals + .map((i) => { + const s = quantize(i.start) + const e = quantize(i.end) + return { start: Math.min(s, e), end: Math.max(s, e) } + }) + .filter((i) => i.end > i.start + EPS) + + if (normalizedIntervals.length === 0) { + const center = (lineStartQ + lineEndQ) / 2 + return [{ start: lineStartQ, end: lineEndQ, center }] + } + + // Sort intervals by start position + const sorted = [...normalizedIntervals].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 > lineStartQ + EPS) { + const start = lineStartQ + 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 < lineEndQ - EPS) { + const start = merged[merged.length - 1]!.end + const end = lineEndQ + if (end - start >= minSegmentLength) { + uncovered.push({ start, end, center: (start + end) / 2 }) + } + } + + return uncovered +} + +/** + * Compute edge candidates using exact edge analysis. + */ +export function computeEdgeCandidates3DReference(params: { + bounds: XYRect + minSize: number + layerCount: number + obstacleIndexByLayer: Array | undefined> + placedIndexByLayer: Array | undefined> + hardPlacedByLayer: XYRect[][] +}): Candidate3D[] { + const { + bounds, + minSize, + layerCount, + obstacleIndexByLayer, + placedIndexByLayer, + hardPlacedByLayer, + } = params + + const out: Candidate3D[] = [] + // Use small inset from edges for placement + const δ = Math.max(minSize * 0.15, EPS * 3) + const dedup = new Set() + const hardRectsByLayer = Array.from({ length: layerCount }, (_, z) => + [ + ...(obstacleIndexByLayer[z]?.all() ?? []), + ...(hardPlacedByLayer[z] ?? []), + ].map(toRect), + ) + const key = (p: { x: number; y: number; z: number }) => + `${p.z}|${p.x.toFixed(6)}|${p.y.toFixed(6)}` + + function fullyOcc(p: { x: number; y: number }) { + return isFullyOccupiedAtPoint({ + layerCount, + obstacleIndexByLayer, + placedIndexByLayer, + point: p, + }) + } + + function pushIfFree(p: { x: number; y: number; z: number }) { + const qx = quantize(p.x) + const qy = quantize(p.y) + const { z } = p + const x = qx + const y = qy + 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 = hardRectsByLayer[z] ?? [] + let d = distancePointToRectEdges({ x, y }, bounds) + for (const blocker of hard) { + d = Math.min(d, distancePointToRectEdges({ x, y }, blocker)) + } + const distance = quantize(d) + + 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, + minSpan: 1, + maxSpan: undefined, + obstacleIndexByLayer, + additionalBlockersByLayer: hardPlacedByLayer, + }) + out.push({ + x, + y, + z, + distance, + zSpanLen: span.length, + isEdgeSeed: true, + }) + } + + for (let z = 0; z < layerCount; z++) { + const blockers = (hardRectsByLayer[z] ?? []).map((b) => ({ + x: quantize(b.x), + y: quantize(b.y), + width: quantize(b.width), + height: quantize(b.height), + })) + + // 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({ x: corner.x, y: 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({ + lineStart: bounds.x + δ, + lineEnd: bounds.x + bounds.width - δ, + coveringIntervals: topCovering, + minSegmentLength: 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({ x: seg.center, y: topY, z }) + if (segLen > minSize * 1.5) { + pushIfFree({ x: seg.start + minSize * 0.4, y: topY, z }) + pushIfFree({ x: seg.end - minSize * 0.4, y: 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({ + lineStart: bounds.x + δ, + lineEnd: bounds.x + bounds.width - δ, + coveringIntervals: bottomCovering, + minSegmentLength: minSize * 0.5, + }) + for (const seg of bottomUncovered) { + const segLen = seg.end - seg.start + if (segLen >= minSize) { + pushIfFree({ x: seg.center, y: bottomY, z }) + if (segLen > minSize * 1.5) { + pushIfFree({ x: seg.start + minSize * 0.4, y: bottomY, z }) + pushIfFree({ x: seg.end - minSize * 0.4, y: 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({ + lineStart: bounds.y + δ, + lineEnd: bounds.y + bounds.height - δ, + coveringIntervals: leftCovering, + minSegmentLength: minSize * 0.5, + }) + for (const seg of leftUncovered) { + const segLen = seg.end - seg.start + if (segLen >= minSize) { + pushIfFree({ x: leftX, y: seg.center, z }) + if (segLen > minSize * 1.5) { + pushIfFree({ x: leftX, y: seg.start + minSize * 0.4, z }) + pushIfFree({ x: leftX, y: 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({ + lineStart: bounds.y + δ, + lineEnd: bounds.y + bounds.height - δ, + coveringIntervals: rightCovering, + minSegmentLength: minSize * 0.5, + }) + for (const seg of rightUncovered) { + const segLen = seg.end - seg.start + if (segLen >= minSize) { + pushIfFree({ x: rightX, y: seg.center, z }) + if (segLen > minSize * 1.5) { + pushIfFree({ x: rightX, y: seg.start + minSize * 0.4, z }) + pushIfFree({ x: rightX, y: 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({ + lineStart: b.y, + lineEnd: b.y + b.height, + coveringIntervals: obLeftCovering, + minSegmentLength: minSize * 0.5, + }) + for (const seg of obLeftUncovered) { + pushIfFree({ x: obLeftX, y: 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({ + lineStart: b.y, + lineEnd: b.y + b.height, + coveringIntervals: obRightCovering, + minSegmentLength: minSize * 0.5, + }) + for (const seg of obRightUncovered) { + pushIfFree({ x: obRightX, y: 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({ + lineStart: b.x, + lineEnd: b.x + b.width, + coveringIntervals: obTopCovering, + minSegmentLength: minSize * 0.5, + }) + for (const seg of obTopUncovered) { + pushIfFree({ x: seg.center, y: 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({ + lineStart: b.x, + lineEnd: b.x + b.width, + coveringIntervals: obBottomCovering, + minSegmentLength: minSize * 0.5, + }) + for (const seg of obBottomUncovered) { + pushIfFree({ x: seg.center, y: obBottomY, z }) + } + } + } + } + + // Strong multi-layer preference then distance. + out.sort( + (a, b) => + b.zSpanLen! - a.zSpanLen! || + b.distance - a.distance || + a.z - b.z || + a.x - b.x || + a.y - b.y, + ) + return out +} diff --git a/tests/fixtures/indexedEdgeCandidateSnapshot.ts b/tests/fixtures/indexedEdgeCandidateSnapshot.ts new file mode 100644 index 0000000..e8dcb81 --- /dev/null +++ b/tests/fixtures/indexedEdgeCandidateSnapshot.ts @@ -0,0 +1,127 @@ +import { createGraphicsGrid, type GraphicsObject } from "graphics-debug" +import RBush from "rbush" +import type { Candidate3D, XYRect } from "../../lib/rectdiff-types" +import type { computeEdgeCandidates3D } from "../../lib/solvers/RectDiffSeedingSolver/computeEdgeCandidates3D" +import type { RTreeRect } from "../../lib/types/capacity-mesh-types" +import { rectToTree } from "../../lib/utils/rectToTree" + +const bounds: XYRect = { x: 0, y: 0, width: 2, height: 2 } +const centerBlocker: XYRect = { x: 0.8, y: 0.8, width: 0.4, height: 0.4 } + +export const edgeCandidateObstaclesByLayer: XYRect[][] = [ + [ + { x: -1, y: -1, width: 4, height: 4 }, + { x: 0.05000049, y: 0.05, width: 0.19999949, height: 0.2 }, + ], + [centerBlocker, { ...centerBlocker }], +] + +export function makeEdgeCandidateSnapshotParams( + obstaclesByLayer: XYRect[][], +): Parameters[0] { + return { + bounds, + minSize: 1, + layerCount: obstaclesByLayer.length, + obstacleIndexByLayer: obstaclesByLayer.map((rects, z) => + new RBush().load( + rects.map((rect) => rectToTree(rect, { zLayers: [z] })), + ), + ), + placedIndexByLayer: obstaclesByLayer.map(() => new RBush()), + hardPlacedByLayer: obstaclesByLayer.map(() => []), + } +} + +export function makeEdgeCandidateComparisonGraphics( + reference: Candidate3D[], + indexed: Candidate3D[], +): GraphicsObject { + function panel({ + candidates, + title, + z, + }: { + candidates: Candidate3D[] + title: string + z: number + }): GraphicsObject { + const onLayer = candidates.filter((candidate) => candidate.z === z) + const corner = onLayer.find( + (candidate) => candidate.x === 0.15 && candidate.y === 0.15, + )! + return { + rects: [ + // Equal viewports keep the containing rectangle visible on both rows. + { + center: { x: 1, y: 1 }, + width: 4, + height: 5.6, + fill: "none", + stroke: "none", + }, + ...edgeCandidateObstaclesByLayer[z]!.map((rect) => ({ + center: { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }, + width: rect.width, + height: rect.height, + fill: "rgba(239,68,68,0.12)", + stroke: "#dc2626", + })), + { + center: { x: 1, y: 1 }, + width: bounds.width, + height: bounds.height, + fill: "none", + stroke: "#475569", + }, + ], + circles: onLayer.map((candidate) => ({ + center: { x: candidate.x, y: candidate.y }, + radius: 0.035, + fill: "#2563eb", + })), + texts: [ + { x: 1, y: 3.5, text: `${title} / z${z}`, fontSize: 0.18 }, + { + x: 1, + y: 3.2, + text: `${onLayer.length} seeds; same positions and ordering`, + fontSize: 0.12, + }, + { + x: 1, + y: -1.3, + text: + z === 0 + ? `Corner ranking distance: ${corner.distance.toFixed(6)}` + : "Duplicate blockers: four unique edge seeds", + fontSize: 0.12, + }, + ], + lines: + z === 0 + ? [ + { + points: [ + { x: 0.15, y: -0.7 }, + { x: corner.x, y: corner.y }, + ], + strokeColor: "#15803d", + strokeWidth: 0.015, + }, + ] + : [], + } + } + + return { + ...createGraphicsGrid( + [0, 1].map((z) => [ + panel({ candidates: reference, title: "Exhaustive reference", z }), + panel({ candidates: indexed, title: "Indexed queries", z }), + ]), + { cellWidth: 4, cellHeight: 5.6, gap: 0.4 }, + ), + coordinateSystem: "cartesian", + } +} diff --git a/tests/fixtures/indexedEdgeQuerySnapshot.ts b/tests/fixtures/indexedEdgeQuerySnapshot.ts new file mode 100644 index 0000000..953d1bb --- /dev/null +++ b/tests/fixtures/indexedEdgeQuerySnapshot.ts @@ -0,0 +1,209 @@ +import { createGraphicsGrid, type GraphicsObject } from "graphics-debug" +import RBush from "rbush" +import type { Candidate3D, XYRect } from "../../lib/rectdiff-types" +import type { computeEdgeCandidates3D } from "../../lib/solvers/RectDiffSeedingSolver/computeEdgeCandidates3D" +import type { RTreeRect } from "../../lib/types/capacity-mesh-types" +import { rectToTree } from "../../lib/utils/rectToTree" + +export const queryX = 4.85 // Left edge of the source obstacle minus the 0.15 inset. +const bounds: XYRect = { x: 0, y: 0, width: 12, height: 10 } +const source: XYRect = { x: 5, y: 2, width: 2, height: 6 } +const obstacles: XYRect[] = [ + source, + { x: 4.5, y: 3, width: 1, height: 1 }, + { x: 4.5, y: 6, width: 1, height: 1 }, + { x: 0.6, y: 0.6, width: 1, height: 0.7 }, + { x: 0.6, y: 3.8, width: 1, height: 0.8 }, + { x: 0.6, y: 7.5, width: 1, height: 0.8 }, + { x: 2.5, y: 1.2, width: 0.8, height: 0.8 }, + { x: 2.5, y: 5, width: 0.8, height: 0.8 }, + { x: 2.5, y: 8.2, width: 0.8, height: 0.8 }, + { x: 9, y: 0.8, width: 1, height: 0.8 }, + { x: 9, y: 4.5, width: 1, height: 0.8 }, + { x: 9, y: 8, width: 1, height: 0.8 }, +] + +export function makeQueryWorkSnapshotParams(): Parameters< + typeof computeEdgeCandidates3D +>[0] { + return { + bounds, + minSize: 1, + layerCount: 1, + obstacleIndexByLayer: [ + new RBush().load( + obstacles.map((rect) => rectToTree(rect, { zLayers: [0] })), + ), + ], + placedIndexByLayer: [new RBush()], + hardPlacedByLayer: [[]], + } +} + +export function makeQueryWorkComparisonGraphics({ + reference, + indexed, + queriedBlockers, +}: { + reference: Candidate3D[] + indexed: Candidate3D[] + queriedBlockers: XYRect[] +}): GraphicsObject { + function panel(candidates: Candidate3D[], useIndex: boolean): GraphicsObject { + const seeds = candidates.filter( + (candidate) => + candidate.z === 0 && + Math.abs(candidate.x - queryX) < 1e-9 && + candidate.y >= source.y && + candidate.y <= source.y + source.height, + ) + const checked = useIndex ? queriedBlockers : obstacles + const isChecked = (rect: XYRect) => + checked.some( + (other) => + Math.abs(rect.x - other.x) < 1e-9 && + Math.abs(rect.y - other.y) < 1e-9 && + Math.abs(rect.width - other.width) < 1e-9 && + Math.abs(rect.height - other.height) < 1e-9, + ) + + return { + rects: [ + // An equal viewport keeps both columns at the same scale. + { + center: { x: 6, y: 5 }, + width: 14, + height: 16, + fill: "none", + stroke: "none", + }, + { + center: { x: 6, y: 5 }, + width: bounds.width, + height: bounds.height, + fill: "none", + stroke: "#94a3b8", + }, + ...obstacles.map((rect) => ({ + center: { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }, + width: rect.width, + height: rect.height, + fill: isChecked(rect) ? "#fed7aa" : "#e2e8f0", + stroke: isChecked(rect) ? "#c2410c" : "#cbd5e1", + })), + ], + lines: [ + { + points: [ + { x: 0, y: 0 }, + { x: 12, y: 0 }, + { x: 12, y: 10 }, + { x: 0, y: 10 }, + { x: 0, y: 0 }, + ], + strokeColor: "#94a3b8", + strokeWidth: 0.025, + }, + { + points: [ + { x: queryX, y: 0 }, + { x: queryX, y: 10 }, + ], + strokeColor: "#7c3aed", + strokeWidth: 0.035, + strokeDash: "6 6", + }, + // The expected gaps are checked against actual production seed output + // in the test. No other edge's candidates are drawn in this close-up. + ...[ + [2, 3], + [4, 6], + [7, 8], + ].map(([start, end]) => ({ + points: [ + { x: queryX, y: start! }, + { x: queryX, y: end! }, + ], + strokeColor: "#16a34a", + strokeWidth: 0.12, + })), + ...queriedBlockers.map((rect) => ({ + points: [ + { x: queryX, y: rect.y }, + { x: queryX, y: rect.y + rect.height }, + ], + strokeColor: "#c2410c", + strokeWidth: 0.12, + })), + ], + circles: seeds.map((seed) => ({ + center: { x: seed.x, y: seed.y }, + radius: 0.13, + fill: "#1d4ed8", + stroke: "#ffffff", + })), + texts: [ + { + x: 6, + y: 12.3, + text: useIndex + ? "AFTER: query the spatial index" + : "BEFORE: scan every rectangle", + fontSize: 0.48, + }, + { + x: 6, + y: 11.55, + text: `${checked.length} rectangles checked for this line`, + fontSize: 0.4, + color: "#c2410c", + }, + { + x: 6, + y: 10.7, + text: "One obstacle-edge query at x = 4.85", + fontSize: 0.31, + color: "#6d28d9", + }, + { x: 6.25, y: 5.25, text: "source", fontSize: 0.28 }, + { x: 6.25, y: 4.8, text: "obstacle", fontSize: 0.28 }, + { + x: 6, + y: -0.65, + text: "Orange: checked Gray: skipped", + fontSize: 0.34, + }, + { + x: 6, + y: -1.3, + text: "Green: free gaps Blue: seed points", + fontSize: 0.34, + }, + { + x: 6, + y: -2.2, + text: `Same ${seeds.length} seeds on this edge`, + fontSize: 0.43, + color: "#1d4ed8", + }, + { + x: 6, + y: -2.8, + text: useIndex + ? "Index built once, reused for every edge" + : "Every edge repeats the full scan", + fontSize: 0.28, + }, + ], + } + } + + return { + ...createGraphicsGrid([[panel(reference, false), panel(indexed, true)]], { + cellWidth: 14, + cellHeight: 16, + gap: 0.8, + }), + coordinateSystem: "cartesian", + } +}