From 91701a237691df65703b1730a7d93469b9bdd578 Mon Sep 17 00:00:00 2001 From: Rishabh Gupta Date: Mon, 19 Oct 2026 07:00:51 +0000 Subject: [PATCH 1/8] perf: index quantized blockers during edge seeding --- .../computeEdgeCandidates3D.ts | 60 ++- tests/compute-edge-candidates-index.test.ts | 254 +++++++++++ .../computeEdgeCandidates3DReference.ts | 426 ++++++++++++++++++ 3 files changed, 732 insertions(+), 8 deletions(-) create mode 100644 tests/compute-edge-candidates-index.test.ts create mode 100644 tests/fixtures/computeEdgeCandidates3DReference.ts diff --git a/lib/solvers/RectDiffSeedingSolver/computeEdgeCandidates3D.ts b/lib/solvers/RectDiffSeedingSolver/computeEdgeCandidates3D.ts index f106d594..4ba048ed 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 @@ -193,6 +194,34 @@ export function computeEdgeCandidates3D(params: { height: quantize(b.height), })) + // Query the same quantized coordinates used by the original line tests. + // Keep source order because uncovered interval processing is stable. + // Search the full line; interval clipping still happens after the query. + 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() + } + const queryBlockers = ( + minX: number, + minY: number, + maxX: number, + maxY: number, + ): XYRect[] => { + if (!blockerIndex) return [] + const indices = blockerIndex.search(minX, minY, maxX, maxY) + indices.sort((a, b) => a - b) + return indices.map((index) => blockers[index]!) + } + // 1) Board edges — find exact uncovered segments along each edge // First, check corners explicitly @@ -208,7 +237,7 @@ export function computeEdgeCandidates3D(params: { // Top edge (y = bounds.y + δ) const topY = bounds.y + δ - const topCovering = blockers + const topCovering = queryBlockers(-Infinity, topY, Infinity, topY) .filter((b) => b.y <= topY && b.y + b.height >= topY) .map((b) => ({ start: Math.max(bounds.x, b.x), @@ -235,7 +264,7 @@ export function computeEdgeCandidates3D(params: { // Bottom edge (y = bounds.y + bounds.height - δ) const bottomY = bounds.y + bounds.height - δ - const bottomCovering = blockers + const bottomCovering = queryBlockers(-Infinity, bottomY, Infinity, bottomY) .filter((b) => b.y <= bottomY && b.y + b.height >= bottomY) .map((b) => ({ start: Math.max(bounds.x, b.x), @@ -260,7 +289,7 @@ export function computeEdgeCandidates3D(params: { // Left edge (x = bounds.x + δ) const leftX = bounds.x + δ - const leftCovering = blockers + const leftCovering = queryBlockers(leftX, -Infinity, leftX, Infinity) .filter((b) => b.x <= leftX && b.x + b.width >= leftX) .map((b) => ({ start: Math.max(bounds.y, b.y), @@ -285,7 +314,7 @@ export function computeEdgeCandidates3D(params: { // Right edge (x = bounds.x + bounds.width - δ) const rightX = bounds.x + bounds.width - δ - const rightCovering = blockers + const rightCovering = queryBlockers(rightX, -Infinity, rightX, Infinity) .filter((b) => b.x <= rightX && b.x + b.width >= rightX) .map((b) => ({ start: Math.max(bounds.y, b.y), @@ -313,7 +342,12 @@ export function computeEdgeCandidates3D(params: { // 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 + const obLeftCovering = queryBlockers( + obLeftX, + -Infinity, + obLeftX, + Infinity, + ) .filter( (bl) => bl !== b && bl.x <= obLeftX && bl.x + bl.width >= obLeftX, ) @@ -338,7 +372,12 @@ export function computeEdgeCandidates3D(params: { obRightX > bounds.x + EPS && obRightX < bounds.x + bounds.width - EPS ) { - const obRightCovering = blockers + const obRightCovering = queryBlockers( + obRightX, + -Infinity, + obRightX, + Infinity, + ) .filter( (bl) => bl !== b && bl.x <= obRightX && bl.x + bl.width >= obRightX, ) @@ -360,7 +399,7 @@ export function computeEdgeCandidates3D(params: { // 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 + const obTopCovering = queryBlockers(-Infinity, obTopY, Infinity, obTopY) .filter( (bl) => bl !== b && bl.y <= obTopY && bl.y + bl.height >= obTopY, ) @@ -385,7 +424,12 @@ export function computeEdgeCandidates3D(params: { obBottomY > bounds.y + EPS && obBottomY < bounds.y + bounds.height - EPS ) { - const obBottomCovering = blockers + const obBottomCovering = queryBlockers( + -Infinity, + obBottomY, + Infinity, + obBottomY, + ) .filter( (bl) => bl !== b && bl.y <= obBottomY && bl.y + bl.height >= obBottomY, diff --git a/tests/compute-edge-candidates-index.test.ts b/tests/compute-edge-candidates-index.test.ts new file mode 100644 index 00000000..69b31a2d --- /dev/null +++ b/tests/compute-edge-candidates-index.test.ts @@ -0,0 +1,254 @@ +import { expect, test } from "bun:test" +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 +} + +const rect = (x: number, y: number, width: number, height: number): XYRect => ({ + x, + y, + width, + height, +}) + +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 = rect( + -15 + random() * 20, + -15 + random() * 20, + 2 + random() * 35, + 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 = rect( + bounds.x + (random() * 1.6 - 0.3) * bounds.width + jitter, + bounds.y + (random() * 1.6 - 0.3) * bounds.height - jitter, + i % 9 === 0 ? 0.00000049 : random() * bounds.width * 0.6, + 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 = rect(4, 4, 2, 2) + const scenarios: Scenario[] = [ + { + name: "empty board with missing and empty layer indexes", + bounds: rect(0, 0, 12, 10), + minSize: 1, + layerCount: 4, + layers: [{}, undefined, {}], + }, + { + name: "obstacles, hard placements, soft placements, and duplicate geometry", + bounds: rect(0, 0, 12, 10), + minSize: 0.5, + layerCount: 4, + layers: [ + { + obstacles: [duplicate, { ...duplicate }, rect(1, 1, 2, 3)], + hard: [{ ...duplicate }, rect(8, 2, 1, 5)], + soft: [rect(0, 0, 4, 10)], + }, + { hard: [rect(0, 0, 4, 10)], soft: [rect(7, 0, 5, 10)] }, + { obstacles: [rect(0, 0, 4, 10)], soft: [rect(7, 0, 5, 10)] }, + { soft: [rect(0, 0, 12, 10)] }, + ], + }, + { + name: "fully occupied across obstacles and soft placements", + bounds: rect(-4, -3, 12, 10), + minSize: 0.5, + layerCount: 3, + layers: [ + { obstacles: [rect(-4, -3, 12, 10)] }, + { hard: [rect(-4, -3, 12, 10)] }, + { soft: [rect(-4, -3, 12, 10)] }, + ], + }, + { + name: "quantization boundaries and subprecision widths", + bounds: rect(-0.00000049, 0.00000051, 12.00000049, 10.00000051), + minSize: 0.2, + layerCount: 2, + layers: [ + { + obstacles: [ + rect(2.00000049, 1.99999951, 0.99999951, 2.00000049), + rect(4.00000051, 3.00000049, 0.00000049, 1.00000051), + rect(6.99999949, 2.00000051, 0.00000051, 3.99999949), + rect(0.03, 0.03, 1.00000049, 0.00000149), + ], + hard: [rect(9.00000051, 6.99999951, 1.00000051, 0.00000151)], + }, + { soft: [rect(0, 0, 1, 10)] }, + ], + }, + { + name: "disjoint clipped intervals retain their original normalization", + bounds: rect(0, 0, 12, 10), + minSize: 1, + layerCount: 2, + layers: [ + { + obstacles: [ + rect(4, 4, 2, 2), + rect(3, -5, 1, 2), + rect(3, 8, 1, 3), + rect(-5, 3, 2, 1), + rect(9, 3, 5, 1), + rect(-8, -1, 2, 3), + rect(14, -1, 2, 3), + rect(-1, 14, 3, 2), + rect(-1, -6, 3, 2), + ], + }, + {}, + ], + }, + { + name: "touching edges, narrow gaps, and zero-size geometry", + bounds: rect(0, 0, 12, 10), + minSize: 0.2, + layerCount: 3, + layers: [ + { + obstacles: [ + rect(1, 1, 2, 2), + rect(3, 1, 2, 2), + rect(5.00000049, 1, 2, 2), + rect(7.00000151, 1, 2, 2), + rect(0, 0.03, 12, 0), + rect(0.03, 0, 0, 10), + rect(3, 3, 0, 0), + ], + }, + { hard: [rect(1, 3.03, 8, 0.01)] }, + {}, + ], + }, + { + name: "long thin rectangles and perpendicular offboard intervals", + bounds: rect(-1000000, -2, 2000000, 4), + minSize: 0.01, + layerCount: 2, + layers: [ + { + obstacles: [ + rect(-2000000, -0.2, 4000000, 0.1), + rect(-0.1, -1000000, 0.2, 2000000), + rect(100, 1.5, 100000, 0.00000049), + rect(-100000, -1.5, 100, 0.00000151), + ], + hard: [rect(-500000, -1, 1000000, 0.01)], + }, + {}, + ], + }, + { + name: "EPS inset dominates a subprecision minimum size", + bounds: rect(-1, -1, 2, 2), + minSize: 0.00000001, + layerCount: 1, + layers: [ + { + obstacles: [rect(0, -1, 0.00000049, 2), rect(-1, 0, 2, 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), + }) + } +}) diff --git a/tests/fixtures/computeEdgeCandidates3DReference.ts b/tests/fixtures/computeEdgeCandidates3DReference.ts new file mode 100644 index 00000000..454897d7 --- /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 +} From e7546767a4bb3c302f51091537849f9fd230c1ac Mon Sep 17 00:00:00 2001 From: Rishabh Gupta Date: Mon, 19 Oct 2026 07:52:11 +0000 Subject: [PATCH 2/8] Index exact edge-distance ranking and skip duplicate work --- .../computeEdgeCandidates3D.ts | 28 +++++++-- tests/compute-edge-candidates-index.test.ts | 58 +++++++++++++++++++ 2 files changed, 82 insertions(+), 4 deletions(-) diff --git a/lib/solvers/RectDiffSeedingSolver/computeEdgeCandidates3D.ts b/lib/solvers/RectDiffSeedingSolver/computeEdgeCandidates3D.ts index 4ba048ed..85ed49f4 100644 --- a/lib/solvers/RectDiffSeedingSolver/computeEdgeCandidates3D.ts +++ b/lib/solvers/RectDiffSeedingSolver/computeEdgeCandidates3D.ts @@ -126,6 +126,16 @@ export function computeEdgeCandidates3D(params: { ...(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) { + index.add(rect.x, rect.y, rect.x + rect.width, rect.y + rect.height) + } + index.finish() + return index + }) const key = (p: { x: number; y: number; z: number }) => `${p.z}|${p.x.toFixed(6)}|${p.y.toFixed(6)}` @@ -153,16 +163,26 @@ export function computeEdgeCandidates3D(params: { return if (fullyOcc({ x, y })) return // new rule: only drop if truly impossible + const k = key({ x, y, z }) + if (dedup.has(k)) return + // 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 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) - const k = key({ x, y, z }) - if (dedup.has(k)) return dedup.add(k) // Approximate z-span strength at this z (ignoring soft nodes) diff --git a/tests/compute-edge-candidates-index.test.ts b/tests/compute-edge-candidates-index.test.ts index 69b31a2d..86504141 100644 --- a/tests/compute-edge-candidates-index.test.ts +++ b/tests/compute-edge-candidates-index.test.ts @@ -227,6 +227,64 @@ test("indexed edge candidates exactly match exhaustive analysis", () => { {}, ], }, + { + name: "overlapping containing boxes rank by nearest edge rather than box distance", + bounds: rect(0, 0, 100, 100), + minSize: 0.5, + layerCount: 3, + layers: [ + { + obstacles: [ + rect(-5, -5, 110, 110), + rect(10, 10, 40, 40), + rect(20, 20, 5, 5), + rect(18, 18, 15, 15), + ], + hard: [rect(19, 19, 7, 7), rect(20, 20, 5, 5)], + }, + { hard: [rect(45, 45, 20, 20), rect(40, 40, 40, 40)] }, + {}, + ], + }, + { + name: "raw edge distances retain subprecision offsets and duplicate seeds", + bounds: rect(-2, -2, 16, 14), + minSize: 0.000001, + layerCount: 2, + layers: [ + { + obstacles: [ + rect(2.00000049, 2.00000049, 4.00000049, 3.00000049), + rect(2.00000051, 2.00000051, 3.99999951, 2.99999951), + rect(6.00000049, 3.00000051, 0.00000049, 0.00000051), + rect(6.00000049, 3.00000051, 0.00000049, 0.00000051), + ], + hard: [ + rect(1.00000049, 1.00000051, 8.00000049, 7.99999951), + rect(2.00000049, 2.00000049, 4.00000049, 3.00000049), + ], + }, + {}, + ], + }, + { + name: "large translated coordinates preserve raw ranking distances", + bounds: rect(1e12, -1e12, 8000, 6000), + minSize: 0.5, + layerCount: 2, + layers: [ + { + obstacles: [ + rect(1e12 - 1, -1e12 - 1, 8002, 6002), + rect(1e12 + 100.00049, -1e12 + 200.00051, 2000.00049, 3000.00051), + rect(1e12 + 150.00051, -1e12 + 250.00049, 100.00051, 200.00049), + rect(1e12 + 150.00051, -1e12 + 250.00049, 100.00051, 200.00049), + ], + hard: [rect(1e12 + 4000.00049, -1e12 + 3000.00051, 0, 1200.00049)], + }, + {}, + ], + }, { name: "EPS inset dominates a subprecision minimum size", bounds: rect(-1, -1, 2, 2), From f9ebf3359833c98f639517ba81661a50df65c927 Mon Sep 17 00:00:00 2001 From: Rishabh Gupta Date: Mon, 19 Oct 2026 09:27:10 +0000 Subject: [PATCH 3/8] Add visual regression snapshot for indexed edge candidates --- ...te-edge-candidates-index-snapshot.snap.svg | 44 +++++++ ...ute-edge-candidates-index-snapshot.test.ts | 74 +++++++++++ .../fixtures/indexedEdgeCandidateSnapshot.ts | 123 ++++++++++++++++++ 3 files changed, 241 insertions(+) create mode 100644 tests/__snapshots__/compute-edge-candidates-index-snapshot.snap.svg create mode 100644 tests/compute-edge-candidates-index-snapshot.test.ts create mode 100644 tests/fixtures/indexedEdgeCandidateSnapshot.ts 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 00000000..787da6f5 --- /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/compute-edge-candidates-index-snapshot.test.ts b/tests/compute-edge-candidates-index-snapshot.test.ts new file mode 100644 index 00000000..8f6e4357 --- /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/fixtures/indexedEdgeCandidateSnapshot.ts b/tests/fixtures/indexedEdgeCandidateSnapshot.ts new file mode 100644 index 00000000..9b68cf6b --- /dev/null +++ b/tests/fixtures/indexedEdgeCandidateSnapshot.ts @@ -0,0 +1,123 @@ +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: 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(reference, "Exhaustive reference", z), + panel(indexed, "Indexed queries", z), + ]), + { cellWidth: 4, cellHeight: 5.6, gap: 0.4 }, + ), + coordinateSystem: "cartesian", + } +} From 4661e0a06982442280d00fd6b478eec2d6512943 Mon Sep 17 00:00:00 2001 From: Rishabh Gupta <38923768+imrishabh18@users.noreply.github.com> Date: Mon, 19 Oct 2026 15:50:07 +0530 Subject: [PATCH 4/8] Show reduced blocker-query work in a reviewable SVG snapshot --- ...te-edge-candidates-query-snapshot.snap.svg | 44 ++++ ...ute-edge-candidates-query-snapshot.test.ts | 88 ++++++++ tests/fixtures/indexedEdgeQuerySnapshot.ts | 205 ++++++++++++++++++ 3 files changed, 337 insertions(+) create mode 100644 tests/__snapshots__/compute-edge-candidates-query-snapshot.snap.svg create mode 100644 tests/compute-edge-candidates-query-snapshot.test.ts create mode 100644 tests/fixtures/indexedEdgeQuerySnapshot.ts 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 00000000..d0a27f05 --- /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-query-snapshot.test.ts b/tests/compute-edge-candidates-query-snapshot.test.ts new file mode 100644 index 00000000..4b9a5720 --- /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( + referenceSeeds, + indexedSeeds, + queriedBlockers, + ), + { backgroundColor: "white", svgWidth: 1600, svgHeight: 1000 }, + ) + await expect(svg).toMatchSvgSnapshot(import.meta.path) +}) diff --git a/tests/fixtures/indexedEdgeQuerySnapshot.ts b/tests/fixtures/indexedEdgeQuerySnapshot.ts new file mode 100644 index 00000000..b1ae357e --- /dev/null +++ b/tests/fixtures/indexedEdgeQuerySnapshot.ts @@ -0,0 +1,205 @@ +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: 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", + } +} From 9a2d185dd4c13ee1f88ecf620880db69803b3e16 Mon Sep 17 00:00:00 2001 From: Rishabh Gupta <38923768+imrishabh18@users.noreply.github.com> Date: Mon, 19 Oct 2026 23:57:06 +0530 Subject: [PATCH 5/8] Follow handbook two-parameter rule in edge query and test helpers --- .../computeEdgeCandidates3D.ts | 88 ++++-- tests/compute-edge-candidates-index.test.ts | 265 ++++++++++++------ ...ute-edge-candidates-query-snapshot.test.ts | 8 +- .../fixtures/indexedEdgeCandidateSnapshot.ts | 18 +- tests/fixtures/indexedEdgeQuerySnapshot.ts | 14 +- 5 files changed, 262 insertions(+), 131 deletions(-) diff --git a/lib/solvers/RectDiffSeedingSolver/computeEdgeCandidates3D.ts b/lib/solvers/RectDiffSeedingSolver/computeEdgeCandidates3D.ts index 85ed49f4..cf816a65 100644 --- a/lib/solvers/RectDiffSeedingSolver/computeEdgeCandidates3D.ts +++ b/lib/solvers/RectDiffSeedingSolver/computeEdgeCandidates3D.ts @@ -230,12 +230,17 @@ export function computeEdgeCandidates3D(params: { } blockerIndex.finish() } - const queryBlockers = ( - minX: number, - minY: number, - maxX: number, - maxY: number, - ): XYRect[] => { + const queryBlockers = ({ + minX, + minY, + maxX, + maxY, + }: { + minX: number + minY: number + maxX: number + maxY: number + }): XYRect[] => { if (!blockerIndex) return [] const indices = blockerIndex.search(minX, minY, maxX, maxY) indices.sort((a, b) => a - b) @@ -257,7 +262,12 @@ export function computeEdgeCandidates3D(params: { // Top edge (y = bounds.y + δ) const topY = bounds.y + δ - const topCovering = queryBlockers(-Infinity, topY, Infinity, topY) + const topCovering = queryBlockers({ + minX: -Infinity, + minY: topY, + maxX: Infinity, + maxY: topY, + }) .filter((b) => b.y <= topY && b.y + b.height >= topY) .map((b) => ({ start: Math.max(bounds.x, b.x), @@ -284,7 +294,12 @@ export function computeEdgeCandidates3D(params: { // Bottom edge (y = bounds.y + bounds.height - δ) const bottomY = bounds.y + bounds.height - δ - const bottomCovering = queryBlockers(-Infinity, bottomY, Infinity, bottomY) + const bottomCovering = queryBlockers({ + minX: -Infinity, + minY: bottomY, + maxX: Infinity, + maxY: bottomY, + }) .filter((b) => b.y <= bottomY && b.y + b.height >= bottomY) .map((b) => ({ start: Math.max(bounds.x, b.x), @@ -309,7 +324,12 @@ export function computeEdgeCandidates3D(params: { // Left edge (x = bounds.x + δ) const leftX = bounds.x + δ - const leftCovering = queryBlockers(leftX, -Infinity, leftX, Infinity) + const leftCovering = queryBlockers({ + minX: leftX, + minY: -Infinity, + maxX: leftX, + maxY: Infinity, + }) .filter((b) => b.x <= leftX && b.x + b.width >= leftX) .map((b) => ({ start: Math.max(bounds.y, b.y), @@ -334,7 +354,12 @@ export function computeEdgeCandidates3D(params: { // Right edge (x = bounds.x + bounds.width - δ) const rightX = bounds.x + bounds.width - δ - const rightCovering = queryBlockers(rightX, -Infinity, rightX, Infinity) + const rightCovering = queryBlockers({ + minX: rightX, + minY: -Infinity, + maxX: rightX, + maxY: Infinity, + }) .filter((b) => b.x <= rightX && b.x + b.width >= rightX) .map((b) => ({ start: Math.max(bounds.y, b.y), @@ -362,12 +387,12 @@ export function computeEdgeCandidates3D(params: { // Left edge of blocker (x = b.x - δ) const obLeftX = b.x - δ if (obLeftX > bounds.x + EPS && obLeftX < bounds.x + bounds.width - EPS) { - const obLeftCovering = queryBlockers( - obLeftX, - -Infinity, - obLeftX, - Infinity, - ) + const obLeftCovering = queryBlockers({ + minX: obLeftX, + minY: -Infinity, + maxX: obLeftX, + maxY: Infinity, + }) .filter( (bl) => bl !== b && bl.x <= obLeftX && bl.x + bl.width >= obLeftX, ) @@ -392,12 +417,12 @@ export function computeEdgeCandidates3D(params: { obRightX > bounds.x + EPS && obRightX < bounds.x + bounds.width - EPS ) { - const obRightCovering = queryBlockers( - obRightX, - -Infinity, - obRightX, - Infinity, - ) + const obRightCovering = queryBlockers({ + minX: obRightX, + minY: -Infinity, + maxX: obRightX, + maxY: Infinity, + }) .filter( (bl) => bl !== b && bl.x <= obRightX && bl.x + bl.width >= obRightX, ) @@ -419,7 +444,12 @@ export function computeEdgeCandidates3D(params: { // Top edge of blocker (y = b.y - δ) const obTopY = b.y - δ if (obTopY > bounds.y + EPS && obTopY < bounds.y + bounds.height - EPS) { - const obTopCovering = queryBlockers(-Infinity, obTopY, Infinity, obTopY) + const obTopCovering = queryBlockers({ + minX: -Infinity, + minY: obTopY, + maxX: Infinity, + maxY: obTopY, + }) .filter( (bl) => bl !== b && bl.y <= obTopY && bl.y + bl.height >= obTopY, ) @@ -444,12 +474,12 @@ export function computeEdgeCandidates3D(params: { obBottomY > bounds.y + EPS && obBottomY < bounds.y + bounds.height - EPS ) { - const obBottomCovering = queryBlockers( - -Infinity, - obBottomY, - Infinity, - obBottomY, - ) + const obBottomCovering = queryBlockers({ + minX: -Infinity, + minY: obBottomY, + maxX: Infinity, + maxY: obBottomY, + }) .filter( (bl) => bl !== b && bl.y <= obBottomY && bl.y + bl.height >= obBottomY, diff --git a/tests/compute-edge-candidates-index.test.ts b/tests/compute-edge-candidates-index.test.ts index 86504141..1fe48920 100644 --- a/tests/compute-edge-candidates-index.test.ts +++ b/tests/compute-edge-candidates-index.test.ts @@ -19,13 +19,6 @@ type Scenario = { layers: Array } -const rect = (x: number, y: number, width: number, height: number): XYRect => ({ - x, - y, - width, - height, -}) - function makeIndex(rects: XYRect[], z: number): RBush { return new RBush().load( rects.map((r) => ({ @@ -67,12 +60,12 @@ function makeRandomScenarios(): Scenario[] { for (let caseIndex = 0; caseIndex < 80; caseIndex++) { const layerCount = 1 + integer(6) - const bounds = rect( - -15 + random() * 20, - -15 + random() * 20, - 2 + random() * 35, - 2 + random() * 35, - ) + 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 = @@ -86,12 +79,12 @@ function makeRandomScenarios(): Scenario[] { const count = integer(38) for (let i = 0; i < count; i++) { const jitter = (integer(3) - 1) * 0.00000049 - const r = rect( - bounds.x + (random() * 1.6 - 0.3) * bounds.width + jitter, - bounds.y + (random() * 1.6 - 0.3) * bounds.height - jitter, - i % 9 === 0 ? 0.00000049 : random() * bounds.width * 0.6, - i % 11 === 0 ? 0.00000151 : random() * bounds.height * 0.6, - ) + 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) ]! @@ -112,77 +105,114 @@ function makeRandomScenarios(): Scenario[] { } test("indexed edge candidates exactly match exhaustive analysis", () => { - const duplicate = rect(4, 4, 2, 2) + const duplicate = { x: 4, y: 4, width: 2, height: 2 } const scenarios: Scenario[] = [ { name: "empty board with missing and empty layer indexes", - bounds: rect(0, 0, 12, 10), + 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: rect(0, 0, 12, 10), + bounds: { x: 0, y: 0, width: 12, height: 10 }, minSize: 0.5, layerCount: 4, layers: [ { - obstacles: [duplicate, { ...duplicate }, rect(1, 1, 2, 3)], - hard: [{ ...duplicate }, rect(8, 2, 1, 5)], - soft: [rect(0, 0, 4, 10)], + 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: [rect(0, 0, 4, 10)], soft: [rect(7, 0, 5, 10)] }, - { obstacles: [rect(0, 0, 4, 10)], soft: [rect(7, 0, 5, 10)] }, - { soft: [rect(0, 0, 12, 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: rect(-4, -3, 12, 10), + bounds: { x: -4, y: -3, width: 12, height: 10 }, minSize: 0.5, layerCount: 3, layers: [ - { obstacles: [rect(-4, -3, 12, 10)] }, - { hard: [rect(-4, -3, 12, 10)] }, - { soft: [rect(-4, -3, 12, 10)] }, + { 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: rect(-0.00000049, 0.00000051, 12.00000049, 10.00000051), + bounds: { + x: -0.00000049, + y: 0.00000051, + width: 12.00000049, + height: 10.00000051, + }, minSize: 0.2, layerCount: 2, layers: [ { obstacles: [ - rect(2.00000049, 1.99999951, 0.99999951, 2.00000049), - rect(4.00000051, 3.00000049, 0.00000049, 1.00000051), - rect(6.99999949, 2.00000051, 0.00000051, 3.99999949), - rect(0.03, 0.03, 1.00000049, 0.00000149), + { + 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, + }, ], - hard: [rect(9.00000051, 6.99999951, 1.00000051, 0.00000151)], }, - { soft: [rect(0, 0, 1, 10)] }, + { soft: [{ x: 0, y: 0, width: 1, height: 10 }] }, ], }, { name: "disjoint clipped intervals retain their original normalization", - bounds: rect(0, 0, 12, 10), + bounds: { x: 0, y: 0, width: 12, height: 10 }, minSize: 1, layerCount: 2, layers: [ { obstacles: [ - rect(4, 4, 2, 2), - rect(3, -5, 1, 2), - rect(3, 8, 1, 3), - rect(-5, 3, 2, 1), - rect(9, 3, 5, 1), - rect(-8, -1, 2, 3), - rect(14, -1, 2, 3), - rect(-1, 14, 3, 2), - rect(-1, -6, 3, 2), + { 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 }, ], }, {}, @@ -190,78 +220,116 @@ test("indexed edge candidates exactly match exhaustive analysis", () => { }, { name: "touching edges, narrow gaps, and zero-size geometry", - bounds: rect(0, 0, 12, 10), + bounds: { x: 0, y: 0, width: 12, height: 10 }, minSize: 0.2, layerCount: 3, layers: [ { obstacles: [ - rect(1, 1, 2, 2), - rect(3, 1, 2, 2), - rect(5.00000049, 1, 2, 2), - rect(7.00000151, 1, 2, 2), - rect(0, 0.03, 12, 0), - rect(0.03, 0, 0, 10), - rect(3, 3, 0, 0), + { 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: [rect(1, 3.03, 8, 0.01)] }, + { hard: [{ x: 1, y: 3.03, width: 8, height: 0.01 }] }, {}, ], }, { name: "long thin rectangles and perpendicular offboard intervals", - bounds: rect(-1000000, -2, 2000000, 4), + bounds: { x: -1000000, y: -2, width: 2000000, height: 4 }, minSize: 0.01, layerCount: 2, layers: [ { obstacles: [ - rect(-2000000, -0.2, 4000000, 0.1), - rect(-0.1, -1000000, 0.2, 2000000), - rect(100, 1.5, 100000, 0.00000049), - rect(-100000, -1.5, 100, 0.00000151), + { 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: [rect(-500000, -1, 1000000, 0.01)], + hard: [{ x: -500000, y: -1, width: 1000000, height: 0.01 }], }, {}, ], }, { name: "overlapping containing boxes rank by nearest edge rather than box distance", - bounds: rect(0, 0, 100, 100), + bounds: { x: 0, y: 0, width: 100, height: 100 }, minSize: 0.5, layerCount: 3, layers: [ { obstacles: [ - rect(-5, -5, 110, 110), - rect(10, 10, 40, 40), - rect(20, 20, 5, 5), - rect(18, 18, 15, 15), + { 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 }, ], - hard: [rect(19, 19, 7, 7), rect(20, 20, 5, 5)], }, - { hard: [rect(45, 45, 20, 20), rect(40, 40, 40, 40)] }, {}, ], }, { name: "raw edge distances retain subprecision offsets and duplicate seeds", - bounds: rect(-2, -2, 16, 14), + bounds: { x: -2, y: -2, width: 16, height: 14 }, minSize: 0.000001, layerCount: 2, layers: [ { obstacles: [ - rect(2.00000049, 2.00000049, 4.00000049, 3.00000049), - rect(2.00000051, 2.00000051, 3.99999951, 2.99999951), - rect(6.00000049, 3.00000051, 0.00000049, 0.00000051), - rect(6.00000049, 3.00000051, 0.00000049, 0.00000051), + { + 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: [ - rect(1.00000049, 1.00000051, 8.00000049, 7.99999951), - rect(2.00000049, 2.00000049, 4.00000049, 3.00000049), + { + x: 1.00000049, + y: 1.00000051, + width: 8.00000049, + height: 7.99999951, + }, + { + x: 2.00000049, + y: 2.00000049, + width: 4.00000049, + height: 3.00000049, + }, ], }, {}, @@ -269,30 +337,55 @@ test("indexed edge candidates exactly match exhaustive analysis", () => { }, { name: "large translated coordinates preserve raw ranking distances", - bounds: rect(1e12, -1e12, 8000, 6000), + bounds: { x: 1e12, y: -1e12, width: 8000, height: 6000 }, minSize: 0.5, layerCount: 2, layers: [ { obstacles: [ - rect(1e12 - 1, -1e12 - 1, 8002, 6002), - rect(1e12 + 100.00049, -1e12 + 200.00051, 2000.00049, 3000.00051), - rect(1e12 + 150.00051, -1e12 + 250.00049, 100.00051, 200.00049), - rect(1e12 + 150.00051, -1e12 + 250.00049, 100.00051, 200.00049), + { 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, + }, ], - hard: [rect(1e12 + 4000.00049, -1e12 + 3000.00051, 0, 1200.00049)], }, {}, ], }, { name: "EPS inset dominates a subprecision minimum size", - bounds: rect(-1, -1, 2, 2), + bounds: { x: -1, y: -1, width: 2, height: 2 }, minSize: 0.00000001, layerCount: 1, layers: [ { - obstacles: [rect(0, -1, 0.00000049, 2), rect(-1, 0, 2, 0.00000051)], + obstacles: [ + { x: 0, y: -1, width: 0.00000049, height: 2 }, + { x: -1, y: 0, width: 2, height: 0.00000051 }, + ], }, ], }, diff --git a/tests/compute-edge-candidates-query-snapshot.test.ts b/tests/compute-edge-candidates-query-snapshot.test.ts index 4b9a5720..9df4672d 100644 --- a/tests/compute-edge-candidates-query-snapshot.test.ts +++ b/tests/compute-edge-candidates-query-snapshot.test.ts @@ -77,11 +77,11 @@ test("indexed edge query returns two blockers while preserving the exhaustive se ).toBe(false) const svg = getSvgFromGraphicsObject( - makeQueryWorkComparisonGraphics( - referenceSeeds, - indexedSeeds, + makeQueryWorkComparisonGraphics({ + reference: referenceSeeds, + indexed: indexedSeeds, queriedBlockers, - ), + }), { backgroundColor: "white", svgWidth: 1600, svgHeight: 1000 }, ) await expect(svg).toMatchSvgSnapshot(import.meta.path) diff --git a/tests/fixtures/indexedEdgeCandidateSnapshot.ts b/tests/fixtures/indexedEdgeCandidateSnapshot.ts index 9b68cf6b..e8dcb815 100644 --- a/tests/fixtures/indexedEdgeCandidateSnapshot.ts +++ b/tests/fixtures/indexedEdgeCandidateSnapshot.ts @@ -37,11 +37,15 @@ export function makeEdgeCandidateComparisonGraphics( reference: Candidate3D[], indexed: Candidate3D[], ): GraphicsObject { - function panel( - candidates: Candidate3D[], - title: string, - z: number, - ): 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, @@ -113,8 +117,8 @@ export function makeEdgeCandidateComparisonGraphics( return { ...createGraphicsGrid( [0, 1].map((z) => [ - panel(reference, "Exhaustive reference", z), - panel(indexed, "Indexed queries", z), + panel({ candidates: reference, title: "Exhaustive reference", z }), + panel({ candidates: indexed, title: "Indexed queries", z }), ]), { cellWidth: 4, cellHeight: 5.6, gap: 0.4 }, ), diff --git a/tests/fixtures/indexedEdgeQuerySnapshot.ts b/tests/fixtures/indexedEdgeQuerySnapshot.ts index b1ae357e..953d1bb6 100644 --- a/tests/fixtures/indexedEdgeQuerySnapshot.ts +++ b/tests/fixtures/indexedEdgeQuerySnapshot.ts @@ -40,11 +40,15 @@ export function makeQueryWorkSnapshotParams(): Parameters< } } -export function makeQueryWorkComparisonGraphics( - reference: Candidate3D[], - indexed: Candidate3D[], - queriedBlockers: XYRect[], -): GraphicsObject { +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) => From c7168409c1bdd7ab85ea027137ca4cc3481a45d2 Mon Sep 17 00:00:00 2001 From: Rishabh Gupta <38923768+imrishabh18@users.noreply.github.com> Date: Tue, 20 Oct 2026 10:58:13 +0530 Subject: [PATCH 6/8] Reuse sorted edge projections and completed expansion searches --- .../RectDiffSeedingSolver.ts | 12 +- .../computeEdgeCandidates3D.ts | 653 ++++++++---------- lib/utils/expandRectFromSeed.ts | 32 + lib/utils/resizeSoftOverlaps.ts | 2 +- tests/compute-edge-candidates-index.test.ts | 77 ++- .../expand-rect-from-seed-query-reuse.test.ts | 54 ++ 6 files changed, 471 insertions(+), 359 deletions(-) create mode 100644 tests/expand-rect-from-seed-query-reuse.test.ts diff --git a/lib/solvers/RectDiffSeedingSolver/RectDiffSeedingSolver.ts b/lib/solvers/RectDiffSeedingSolver/RectDiffSeedingSolver.ts index a35b207f..99bfe909 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 cf816a65..acb99d12 100644 --- a/lib/solvers/RectDiffSeedingSolver/computeEdgeCandidates3D.ts +++ b/lib/solvers/RectDiffSeedingSolver/computeEdgeCandidates3D.ts @@ -18,81 +18,128 @@ const toRect = (rect: XYRect | RTreeRect): XYRect => } : rect +type ProjectedBlocker = { + start: number + end: number + sourceIndex: number +} + +type EdgeSegment = { + order: number + coordinate: 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 } @@ -119,7 +166,11 @@ 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() + 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() ?? []), @@ -148,7 +199,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 @@ -161,49 +218,18 @@ export function computeEdgeCandidates3D(params: { y > bounds.y + bounds.height - EPS ) return - if (fullyOcc({ x, y })) return // new rule: only drop if truly impossible - const k = key({ x, y, z }) - if (dedup.has(k)) return - - // 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) - - 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++) { @@ -214,9 +240,8 @@ export function computeEdgeCandidates3D(params: { height: quantize(b.height), })) - // Query the same quantized coordinates used by the original line tests. - // Keep source order because uncovered interval processing is stable. - // Search the full line; interval clipping still happens after the query. + // 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) { @@ -230,275 +255,199 @@ export function computeEdgeCandidates3D(params: { } blockerIndex.finish() } - const queryBlockers = ({ - minX, - minY, - maxX, - maxY, - }: { - minX: number - minY: number - maxX: number - maxY: number - }): XYRect[] => { - if (!blockerIndex) return [] - const indices = blockerIndex.search(minX, minY, maxX, maxY) - indices.sort((a, b) => a - b) - return indices.map((index) => blockers[index]!) - } - // 1) Board edges — find exact uncovered segments along each edge + // 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 + sourceIndex?: number + }) => { + const lines = params.vertical ? verticalLines : horizontalLines + const segment = { + sourceIndex: params.sourceIndex, + coordinate: params.coordinate, + order: nextOrder++, + } + const segments = lines.get(params.coordinate) + if (segments) segments.push(segment) + else lines.set(params.coordinate, [segment]) + } - // 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 + { 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({ x: corner.x, y: corner.y, z }) + pushIfFree({ ...corner, z, order: nextOrder++, pointOrder: 0 }) } - // Top edge (y = bounds.y + δ) - const topY = bounds.y + δ - const topCovering = queryBlockers({ - minX: -Infinity, - minY: topY, - maxX: Infinity, - maxY: topY, - }) - .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 }) - } - } + for (const coordinate of [bounds.y + δ, bounds.y + bounds.height - δ]) { + addLine({ + vertical: false, + coordinate, + }) } - - // Bottom edge (y = bounds.y + bounds.height - δ) - const bottomY = bounds.y + bounds.height - δ - const bottomCovering = queryBlockers({ - minX: -Infinity, - minY: bottomY, - maxX: Infinity, - maxY: bottomY, - }) - .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 = queryBlockers({ - minX: leftX, - minY: -Infinity, - maxX: leftX, - maxY: Infinity, - }) - .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 = queryBlockers({ - minX: rightX, - minY: -Infinity, - maxX: rightX, - maxY: Infinity, - }) - .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 }) - } - } + for (const coordinate of [bounds.x + δ, bounds.x + bounds.width - δ]) { + addLine({ + vertical: true, + coordinate, + }) } - // 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 = queryBlockers({ - minX: obLeftX, - minY: -Infinity, - maxX: obLeftX, - maxY: Infinity, - }) - .filter( - (bl) => bl !== b && bl.x <= obLeftX && bl.x + bl.width >= obLeftX, + for (let sourceIndex = 0; sourceIndex < blockers.length; sourceIndex++) { + const blocker = blockers[sourceIndex]! + for (const coordinate of [blocker.x - δ, blocker.x + blocker.width + δ]) { + 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, + sourceIndex, }) - 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 = queryBlockers({ - minX: obRightX, - minY: -Infinity, - maxX: obRightX, - maxY: Infinity, - }) - .filter( - (bl) => bl !== b && bl.x <= obRightX && bl.x + bl.width >= obRightX, + for (const coordinate of [ + blocker.y - δ, + blocker.y + blocker.height + δ, + ]) { + 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, + sourceIndex, }) - 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 = queryBlockers({ - minX: -Infinity, - minY: obTopY, - maxX: Infinity, - maxY: obTopY, - }) - .filter( - (bl) => bl !== b && bl.y <= obTopY && bl.y + bl.height >= obTopY, + 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 segment of segments) { + const isBoardEdge = segment.sourceIndex === undefined + const source = isBoardEdge ? bounds : blockers[segment.sourceIndex!]! + 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: segment.sourceIndex, + }, + coveringIntervals, + hasReversedIntervals, + minSegmentLength: minSize * 0.5, + }) + let pointOrder = 0 + for (const gap of uncovered) { + const pushAt = (position: number) => + pushIfFree({ + ...(vertical + ? { x: segment.coordinate, y: position, z } + : { x: position, y: segment.coordinate, z }), + order: segment.order, + 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 = queryBlockers({ - minX: -Infinity, - minY: obBottomY, - maxX: Infinity, - maxY: obBottomY, - }) - .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 9285330b..b1fe946d 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 87a19e5a..b5400d9c 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/compute-edge-candidates-index.test.ts b/tests/compute-edge-candidates-index.test.ts index 1fe48920..16d0d49c 100644 --- a/tests/compute-edge-candidates-index.test.ts +++ b/tests/compute-edge-candidates-index.test.ts @@ -1,4 +1,5 @@ -import { expect, test } from "bun:test" +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" @@ -375,6 +376,32 @@ test("indexed edge candidates exactly match exhaustive analysis", () => { {}, ], }, + { + 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 }, @@ -403,3 +430,51 @@ test("indexed edge candidates exactly match exhaustive analysis", () => { }) } }) + +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/expand-rect-from-seed-query-reuse.test.ts b/tests/expand-rect-from-seed-query-reuse.test.ts new file mode 100644 index 00000000..ae6b4d11 --- /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) + } +}) From 2e1edb4f684b8d46efb88bcc2cb3cbbae3170684 Mon Sep 17 00:00:00 2001 From: Rishabh Gupta <38923768+imrishabh18@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:23:33 +0530 Subject: [PATCH 7/8] Store shared edge tasks by traversal ordinal to reduce allocations --- .../computeEdgeCandidates3D.ts | 81 +++++++++++++------ 1 file changed, 55 insertions(+), 26 deletions(-) diff --git a/lib/solvers/RectDiffSeedingSolver/computeEdgeCandidates3D.ts b/lib/solvers/RectDiffSeedingSolver/computeEdgeCandidates3D.ts index acb99d12..883abfa7 100644 --- a/lib/solvers/RectDiffSeedingSolver/computeEdgeCandidates3D.ts +++ b/lib/solvers/RectDiffSeedingSolver/computeEdgeCandidates3D.ts @@ -24,12 +24,6 @@ type ProjectedBlocker = { sourceIndex: number } -type EdgeSegment = { - order: number - coordinate: number - sourceIndex?: number -} - type EdgeBounds = { lineStart: number lineEnd: number @@ -166,6 +160,8 @@ export function computeEdgeCandidates3D(params: { const out: Candidate3D[] = [] // Use small inset from edges for placement const δ = Math.max(minSize * 0.15, EPS * 3) + const edgesPerAxis = 2 + const edgesPerRectangle = 4 let nextOrder = 0 const acceptedSeeds = new Map< string, @@ -258,24 +254,25 @@ export function computeEdgeCandidates3D(params: { // 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 verticalLines = new Map() + const horizontalLines = new Map() const addLine = (params: { vertical: boolean coordinate: number - sourceIndex?: number + edgeOrdinal: number }) => { const lines = params.vertical ? verticalLines : horizontalLines - const segment = { - sourceIndex: params.sourceIndex, - coordinate: params.coordinate, - order: nextOrder++, - } const segments = lines.get(params.coordinate) - if (segments) segments.push(segment) - else lines.set(params.coordinate, [segment]) + if (segments) segments.push(params.edgeOrdinal) + else lines.set(params.coordinate, [params.edgeOrdinal]) } + // 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 + δ }, @@ -283,25 +280,35 @@ export function computeEdgeCandidates3D(params: { { x: bounds.x + bounds.width - δ, y: bounds.y + bounds.height - δ }, ] for (const corner of corners) { - pushIfFree({ ...corner, z, order: nextOrder++, pointOrder: 0 }) + pushIfFree({ + ...corner, + z, + order: layerOrder + nextEdgeOrdinal++, + pointOrder: 0, + }) } + 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++, }) } + 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 && @@ -312,13 +319,14 @@ export function computeEdgeCandidates3D(params: { addLine({ vertical: true, coordinate, - sourceIndex, + edgeOrdinal, }) } for (const coordinate of [ blocker.y - δ, blocker.y + blocker.height + δ, ]) { + const edgeOrdinal = nextEdgeOrdinal++ if ( !( coordinate > bounds.y + EPS && @@ -329,11 +337,12 @@ export function computeEdgeCandidates3D(params: { addLine({ vertical: false, coordinate, - sourceIndex, + edgeOrdinal, }) } } + nextOrder = layerOrder + nextEdgeOrdinal for (const vertical of [true, false]) { const lines = vertical ? verticalLines : horizontalLines for (const [coordinate, segments] of lines) { @@ -361,9 +370,29 @@ export function computeEdgeCandidates3D(params: { if (end < start) hasReversedIntervals = true } coveringIntervals.sort((a, b) => a.start - b.start) - for (const segment of segments) { - const isBoardEdge = segment.sourceIndex === undefined - const source = isBoardEdge ? bounds : blockers[segment.sourceIndex!]! + 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 @@ -374,7 +403,7 @@ export function computeEdgeCandidates3D(params: { lineEnd: isBoardEdge ? clipEnd - δ : clipEnd, clipStart, clipEnd, - sourceIndex: segment.sourceIndex, + sourceIndex, }, coveringIntervals, hasReversedIntervals, @@ -385,9 +414,9 @@ export function computeEdgeCandidates3D(params: { const pushAt = (position: number) => pushIfFree({ ...(vertical - ? { x: segment.coordinate, y: position, z } - : { x: position, y: segment.coordinate, z }), - order: segment.order, + ? { x: exactCoordinate, y: position, z } + : { x: position, y: exactCoordinate, z }), + order: layerOrder + edgeOrdinal, pointOrder: pointOrder++, }) const length = gap.end - gap.start From d3a8b095035f4e7d03cdb19f2fdb7a554c6c008a Mon Sep 17 00:00:00 2001 From: Rishabh Gupta <38923768+imrishabh18@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:50:02 +0530 Subject: [PATCH 8/8] Preserve raw edge ranking for reversed rectangle extents --- .../computeEdgeCandidates3D.ts | 11 ++++++- tests/compute-edge-candidates-index.test.ts | 33 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/lib/solvers/RectDiffSeedingSolver/computeEdgeCandidates3D.ts b/lib/solvers/RectDiffSeedingSolver/computeEdgeCandidates3D.ts index 883abfa7..132439d8 100644 --- a/lib/solvers/RectDiffSeedingSolver/computeEdgeCandidates3D.ts +++ b/lib/solvers/RectDiffSeedingSolver/computeEdgeCandidates3D.ts @@ -178,7 +178,16 @@ export function computeEdgeCandidates3D(params: { if (rects.length === 0) return null const index = new Flatbush(rects.length) for (const rect of rects) { - index.add(rect.x, rect.y, rect.x + rect.width, rect.y + rect.height) + 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 diff --git a/tests/compute-edge-candidates-index.test.ts b/tests/compute-edge-candidates-index.test.ts index 16d0d49c..e2e44615 100644 --- a/tests/compute-edge-candidates-index.test.ts +++ b/tests/compute-edge-candidates-index.test.ts @@ -431,6 +431,39 @@ test("indexed edge candidates exactly match exhaustive analysis", () => { } }) +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",