@tscircuit/rectdiff 0.0.12 → 0.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/index.d.ts +169 -27
  2. package/dist/index.js +2012 -1672
  3. package/lib/RectDiffPipeline.ts +18 -17
  4. package/lib/{solvers/rectdiff/types.ts → rectdiff-types.ts} +0 -34
  5. package/lib/{solvers/rectdiff/visualization.ts → rectdiff-visualization.ts} +2 -1
  6. package/lib/solvers/GapFillSolver/FindSegmentsWithAdjacentEmptySpaceSolver.ts +9 -12
  7. package/lib/solvers/GapFillSolver/visuallyOffsetLine.ts +5 -2
  8. package/lib/solvers/RectDiffExpansionSolver/RectDiffExpansionSolver.ts +252 -0
  9. package/lib/solvers/{rectdiff → RectDiffExpansionSolver}/rectsToMeshNodes.ts +1 -2
  10. package/lib/solvers/RectDiffGridSolverPipeline/RectDiffGridSolverPipeline.ts +106 -0
  11. package/lib/solvers/RectDiffGridSolverPipeline/buildObstacleIndexes.ts +70 -0
  12. package/lib/solvers/RectDiffSeedingSolver/RectDiffSeedingSolver.ts +487 -0
  13. package/lib/solvers/RectDiffSeedingSolver/computeCandidates3D.ts +109 -0
  14. package/lib/solvers/RectDiffSeedingSolver/computeDefaultGridSizes.ts +9 -0
  15. package/lib/solvers/{rectdiff/candidates.ts → RectDiffSeedingSolver/computeEdgeCandidates3D.ts} +44 -225
  16. package/lib/solvers/{rectdiff/geometry → RectDiffSeedingSolver}/computeInverseRects.ts +6 -2
  17. package/lib/solvers/{rectdiff → RectDiffSeedingSolver}/layers.ts +4 -3
  18. package/lib/solvers/RectDiffSeedingSolver/longestFreeSpanAroundZ.ts +60 -0
  19. package/lib/types/capacity-mesh-types.ts +9 -0
  20. package/lib/utils/buildHardPlacedByLayer.ts +14 -0
  21. package/lib/{solvers/rectdiff/geometry.ts → utils/expandRectFromSeed.ts} +21 -120
  22. package/lib/utils/finalizeRects.ts +54 -0
  23. package/lib/utils/isFullyOccupiedAtPoint.ts +28 -0
  24. package/lib/utils/rectToTree.ts +10 -0
  25. package/lib/utils/rectdiff-geometry.ts +94 -0
  26. package/lib/utils/resizeSoftOverlaps.ts +103 -0
  27. package/lib/utils/sameTreeRect.ts +7 -0
  28. package/package.json +1 -1
  29. package/tests/board-outline.test.ts +2 -1
  30. package/tests/examples/example01.test.tsx +18 -1
  31. package/tests/obstacle-extra-layers.test.ts +1 -1
  32. package/tests/obstacle-zlayers.test.ts +1 -1
  33. package/utils/rectsEqual.ts +2 -2
  34. package/utils/rectsOverlap.ts +2 -2
  35. package/lib/solvers/RectDiffSolver.ts +0 -231
  36. package/lib/solvers/rectdiff/engine.ts +0 -481
  37. /package/lib/solvers/{rectdiff/geometry → RectDiffSeedingSolver}/isPointInPolygon.ts +0 -0
@@ -0,0 +1,487 @@
1
+ import { BaseSolver } from "@tscircuit/solver-utils"
2
+ import type { SimpleRouteJson } from "../../types/srj-types"
3
+ import type { GraphicsObject } from "graphics-debug"
4
+ import type {
5
+ GridFill3DOptions,
6
+ Candidate3D,
7
+ Placed3D,
8
+ XYRect,
9
+ } from "../../rectdiff-types"
10
+ import { computeInverseRects } from "./computeInverseRects"
11
+ import { buildZIndexMap, obstacleToXYRect, obstacleZs } from "./layers"
12
+ import { overlaps } from "../../utils/rectdiff-geometry"
13
+ import { expandRectFromSeed } from "../../utils/expandRectFromSeed"
14
+ import { computeDefaultGridSizes } from "./computeDefaultGridSizes"
15
+ import { computeCandidates3D } from "./computeCandidates3D"
16
+ import { computeEdgeCandidates3D } from "./computeEdgeCandidates3D"
17
+ import { longestFreeSpanAroundZ } from "./longestFreeSpanAroundZ"
18
+ import { allLayerNode } from "../../utils/buildHardPlacedByLayer"
19
+ import { isFullyOccupiedAtPoint } from "lib/utils/isFullyOccupiedAtPoint"
20
+ import { resizeSoftOverlaps } from "../../utils/resizeSoftOverlaps"
21
+ import RBush from "rbush"
22
+ import type { RTreeRect } from "lib/types/capacity-mesh-types"
23
+
24
+ export type RectDiffSeedingSolverInput = {
25
+ simpleRouteJson: SimpleRouteJson
26
+ obstacleIndexByLayer: Array<RBush<RTreeRect>>
27
+ gridOptions?: Partial<GridFill3DOptions>
28
+ boardVoidRects?: XYRect[]
29
+ }
30
+
31
+ /**
32
+ * First phase of RectDiff: grid-based seeding and placement.
33
+ *
34
+ * This solver is responsible for walking all grid sizes and producing
35
+ * an initial set of placed rectangles.
36
+ */
37
+ export class RectDiffSeedingSolver extends BaseSolver {
38
+ // Engine fields (mirrors initState / engine.ts)
39
+
40
+ private srj!: SimpleRouteJson
41
+ private layerNames!: string[]
42
+ private layerCount!: number
43
+ private bounds!: XYRect
44
+ private options!: Required<
45
+ Omit<GridFill3DOptions, "gridSizes" | "maxMultiLayerSpan">
46
+ > & {
47
+ gridSizes: number[]
48
+ maxMultiLayerSpan: number | undefined
49
+ }
50
+ private boardVoidRects?: XYRect[]
51
+ private gridIndex!: number
52
+ private candidates!: Candidate3D[]
53
+ private placed!: Placed3D[]
54
+ private placedIndexByLayer!: Array<RBush<RTreeRect>>
55
+ private expansionIndex!: number
56
+ private edgeAnalysisDone!: boolean
57
+ private totalSeedsThisGrid!: number
58
+ private consumedSeedsThisGrid!: number
59
+
60
+ constructor(private input: RectDiffSeedingSolverInput) {
61
+ super()
62
+ }
63
+
64
+ override _setup() {
65
+ const srj = this.input.simpleRouteJson
66
+ const opts = this.input.gridOptions ?? {}
67
+
68
+ const { layerNames, zIndexByName } = buildZIndexMap(srj)
69
+ const layerCount = Math.max(1, layerNames.length, srj.layerCount || 1)
70
+
71
+ const bounds: XYRect = {
72
+ x: srj.bounds.minX,
73
+ y: srj.bounds.minY,
74
+ width: srj.bounds.maxX - srj.bounds.minX,
75
+ height: srj.bounds.maxY - srj.bounds.minY,
76
+ }
77
+
78
+ const trace = Math.max(0.01, srj.minTraceWidth || 0.15)
79
+ const defaults: Required<
80
+ Omit<GridFill3DOptions, "gridSizes" | "maxMultiLayerSpan">
81
+ > & {
82
+ gridSizes: number[]
83
+ maxMultiLayerSpan: number | undefined
84
+ } = {
85
+ gridSizes: [],
86
+ initialCellRatio: 0.2,
87
+ maxAspectRatio: 3,
88
+ minSingle: { width: 2 * trace, height: 2 * trace },
89
+ minMulti: {
90
+ width: 4 * trace,
91
+ height: 4 * trace,
92
+ minLayers: Math.min(2, Math.max(1, srj.layerCount || 1)),
93
+ },
94
+ preferMultiLayer: true,
95
+ maxMultiLayerSpan: undefined,
96
+ }
97
+
98
+ const options = {
99
+ ...defaults,
100
+ ...opts,
101
+ gridSizes:
102
+ (opts.gridSizes as number[] | undefined) ??
103
+ // re-use the helper that was previously in engine
104
+ computeDefaultGridSizes(bounds),
105
+ }
106
+
107
+ this.srj = srj
108
+ this.layerNames = layerNames
109
+ this.layerCount = layerCount
110
+ this.bounds = bounds
111
+ this.options = options
112
+ this.boardVoidRects = this.input.boardVoidRects
113
+ this.gridIndex = 0
114
+ this.candidates = []
115
+ this.placed = []
116
+ this.placedIndexByLayer = Array.from(
117
+ { length: layerCount },
118
+ () => new RBush<RTreeRect>(),
119
+ )
120
+ this.expansionIndex = 0
121
+ this.edgeAnalysisDone = false
122
+ this.totalSeedsThisGrid = 0
123
+ this.consumedSeedsThisGrid = 0
124
+
125
+ this.stats = {
126
+ gridIndex: this.gridIndex,
127
+ }
128
+ }
129
+
130
+ /** Exactly ONE grid candidate step per call. */
131
+ override _step() {
132
+ this._stepGrid()
133
+
134
+ this.stats.gridIndex = this.gridIndex
135
+ this.stats.placed = this.placed.length
136
+ }
137
+
138
+ /**
139
+ * One micro-step during the GRID phase: handle exactly one candidate.
140
+ */
141
+ private _stepGrid(): void {
142
+ const {
143
+ gridSizes,
144
+ initialCellRatio,
145
+ maxAspectRatio,
146
+ minSingle,
147
+ minMulti,
148
+ preferMultiLayer,
149
+ maxMultiLayerSpan,
150
+ } = this.options
151
+ const grid = gridSizes[this.gridIndex]!
152
+
153
+ // Build hard-placed map once per micro-step (cheap)
154
+ const hardPlacedByLayer = allLayerNode({
155
+ layerCount: this.layerCount,
156
+ placed: this.placed,
157
+ })
158
+
159
+ // Ensure candidates exist for this grid
160
+ if (this.candidates.length === 0 && this.consumedSeedsThisGrid === 0) {
161
+ this.candidates = computeCandidates3D({
162
+ bounds: this.bounds,
163
+ gridSize: grid,
164
+ layerCount: this.layerCount,
165
+ hardPlacedByLayer,
166
+ obstacleIndexByLayer: this.input.obstacleIndexByLayer,
167
+ placedIndexByLayer: this.placedIndexByLayer,
168
+ })
169
+ this.totalSeedsThisGrid = this.candidates.length
170
+ this.consumedSeedsThisGrid = 0
171
+ }
172
+
173
+ // If no candidates remain, advance grid or run edge pass or switch phase
174
+ if (this.candidates.length === 0) {
175
+ if (this.gridIndex + 1 < gridSizes.length) {
176
+ this.gridIndex += 1
177
+ this.totalSeedsThisGrid = 0
178
+ this.consumedSeedsThisGrid = 0
179
+ return
180
+ } else {
181
+ if (!this.edgeAnalysisDone) {
182
+ const minSize = Math.min(minSingle.width, minSingle.height)
183
+ this.candidates = computeEdgeCandidates3D({
184
+ bounds: this.bounds,
185
+ minSize,
186
+ layerCount: this.layerCount,
187
+ obstacleIndexByLayer: this.input.obstacleIndexByLayer,
188
+ placedIndexByLayer: this.placedIndexByLayer,
189
+ hardPlacedByLayer,
190
+ })
191
+ this.edgeAnalysisDone = true
192
+ this.totalSeedsThisGrid = this.candidates.length
193
+ this.consumedSeedsThisGrid = 0
194
+ return
195
+ }
196
+ this.solved = true
197
+ this.expansionIndex = 0
198
+ return
199
+ }
200
+ }
201
+
202
+ // Consume exactly one candidate
203
+ const cand = this.candidates.shift()!
204
+ this.consumedSeedsThisGrid += 1
205
+
206
+ // Evaluate attempts — multi-layer span first (computed ignoring soft nodes)
207
+ const span = longestFreeSpanAroundZ({
208
+ x: cand.x,
209
+ y: cand.y,
210
+ z: cand.z,
211
+ layerCount: this.layerCount,
212
+ minSpan: minMulti.minLayers,
213
+ maxSpan: maxMultiLayerSpan,
214
+ obstacleIndexByLayer: this.input.obstacleIndexByLayer,
215
+ additionalBlockersByLayer: hardPlacedByLayer,
216
+ })
217
+
218
+ const attempts: Array<{
219
+ kind: "multi" | "single"
220
+ layers: number[]
221
+ minReq: { width: number; height: number }
222
+ }> = []
223
+
224
+ if (span.length >= minMulti.minLayers) {
225
+ attempts.push({
226
+ kind: "multi",
227
+ layers: span,
228
+ minReq: { width: minMulti.width, height: minMulti.height },
229
+ })
230
+ }
231
+ attempts.push({
232
+ kind: "single",
233
+ layers: [cand.z],
234
+ minReq: { width: minSingle.width, height: minSingle.height },
235
+ })
236
+
237
+ const ordered = preferMultiLayer ? attempts : attempts.reverse()
238
+
239
+ for (const attempt of ordered) {
240
+ // HARD blockers only: obstacles on those layers + full-stack nodes
241
+ const hardBlockers: XYRect[] = []
242
+ for (const z of attempt.layers) {
243
+ const obstacleLayer = this.input.obstacleIndexByLayer[z]
244
+ if (obstacleLayer) hardBlockers.push(...obstacleLayer.all())
245
+ if (hardPlacedByLayer[z]) hardBlockers.push(...hardPlacedByLayer[z]!)
246
+ }
247
+
248
+ const rect = expandRectFromSeed({
249
+ startX: cand.x,
250
+ startY: cand.y,
251
+ gridSize: grid,
252
+ bounds: this.bounds,
253
+ blockers: hardBlockers,
254
+ initialCellRatio,
255
+ maxAspectRatio,
256
+ minReq: attempt.minReq,
257
+ })
258
+ if (!rect) continue
259
+
260
+ // Place the new node
261
+ const placed: Placed3D = { rect, zLayers: [...attempt.layers] }
262
+ const newIndex = this.placed.push(placed) - 1
263
+ for (const z of attempt.layers) {
264
+ const idx = this.placedIndexByLayer[z]
265
+ if (idx) {
266
+ idx.insert({
267
+ ...rect,
268
+ minX: rect.x,
269
+ minY: rect.y,
270
+ maxX: rect.x + rect.width,
271
+ maxY: rect.y + rect.height,
272
+ })
273
+ }
274
+ }
275
+
276
+ // New: carve overlapped soft nodes
277
+ resizeSoftOverlaps(
278
+ {
279
+ layerCount: this.layerCount,
280
+ placed: this.placed,
281
+ options: this.options,
282
+ placedIndexByLayer: this.placedIndexByLayer,
283
+ },
284
+ newIndex,
285
+ )
286
+
287
+ // New: relax candidate culling — only drop seeds that became fully occupied
288
+ this.candidates = this.candidates.filter(
289
+ (c) =>
290
+ !isFullyOccupiedAtPoint({
291
+ layerCount: this.layerCount,
292
+ obstacleIndexByLayer: this.input.obstacleIndexByLayer,
293
+ placedIndexByLayer: this.placedIndexByLayer,
294
+ point: { x: c.x, y: c.y },
295
+ }),
296
+ )
297
+
298
+ return // processed one candidate
299
+ }
300
+
301
+ // Neither attempt worked; drop this candidate for now.
302
+ }
303
+
304
+ /** Compute solver progress (0 to 1) during GRID phase. */
305
+ computeProgress(): number {
306
+ if (this.solved) {
307
+ return 1
308
+ }
309
+ const grids = this.options.gridSizes.length
310
+ const g = this.gridIndex
311
+ const base = g / (grids + 1) // reserve final slice for expansion
312
+ const denom = Math.max(1, this.totalSeedsThisGrid)
313
+ const frac = denom ? this.consumedSeedsThisGrid / denom : 1
314
+ return Math.min(0.999, base + frac * (1 / (grids + 1)))
315
+ }
316
+
317
+ /**
318
+ * Output the intermediate RectDiff engine data to feed into the
319
+ * expansion phase solver.
320
+ */
321
+ override getOutput() {
322
+ return {
323
+ srj: this.srj,
324
+ layerNames: this.layerNames,
325
+ layerCount: this.layerCount,
326
+ bounds: this.bounds,
327
+ options: this.options,
328
+ boardVoidRects: this.boardVoidRects,
329
+ gridIndex: this.gridIndex,
330
+ candidates: this.candidates,
331
+ placed: this.placed,
332
+ expansionIndex: this.expansionIndex,
333
+ edgeAnalysisDone: this.edgeAnalysisDone,
334
+ totalSeedsThisGrid: this.totalSeedsThisGrid,
335
+ consumedSeedsThisGrid: this.consumedSeedsThisGrid,
336
+ }
337
+ }
338
+
339
+ /** Get color based on z layer for visualization. */
340
+ private getColorForZLayer(zLayers: number[]): {
341
+ fill: string
342
+ stroke: string
343
+ } {
344
+ const minZ = Math.min(...zLayers)
345
+ const colors = [
346
+ { fill: "#dbeafe", stroke: "#3b82f6" },
347
+ { fill: "#fef3c7", stroke: "#f59e0b" },
348
+ { fill: "#d1fae5", stroke: "#10b981" },
349
+ { fill: "#e9d5ff", stroke: "#a855f7" },
350
+ { fill: "#fed7aa", stroke: "#f97316" },
351
+ { fill: "#fecaca", stroke: "#ef4444" },
352
+ ] as const
353
+ return colors[minZ % colors.length]!
354
+ }
355
+
356
+ /** Visualization focused on the grid seeding phase. */
357
+ override visualize(): GraphicsObject {
358
+ const rects: NonNullable<GraphicsObject["rects"]> = []
359
+ const points: NonNullable<GraphicsObject["points"]> = []
360
+ const lines: NonNullable<GraphicsObject["lines"]> = []
361
+
362
+ const srj = this.srj ?? this.input.simpleRouteJson
363
+
364
+ // Board bounds - use srj bounds which is always available
365
+ const boardBounds = {
366
+ minX: srj.bounds.minX,
367
+ maxX: srj.bounds.maxX,
368
+ minY: srj.bounds.minY,
369
+ maxY: srj.bounds.maxY,
370
+ }
371
+
372
+ // board or outline
373
+ if (srj.outline && srj.outline.length > 1) {
374
+ lines.push({
375
+ points: [...srj.outline, srj.outline[0] as { x: number; y: number }],
376
+ strokeColor: "#111827",
377
+ strokeWidth: 0.01,
378
+ label: "outline",
379
+ })
380
+ } else {
381
+ rects.push({
382
+ center: {
383
+ x: (boardBounds.minX + boardBounds.maxX) / 2,
384
+ y: (boardBounds.minY + boardBounds.maxY) / 2,
385
+ },
386
+ width: boardBounds.maxX - boardBounds.minX,
387
+ height: boardBounds.maxY - boardBounds.minY,
388
+ fill: "none",
389
+ stroke: "#111827",
390
+ label: "board",
391
+ })
392
+ }
393
+
394
+ // obstacles (rect & oval as bounding boxes)
395
+ for (const obstacle of srj.obstacles ?? []) {
396
+ if (obstacle.type === "rect" || obstacle.type === "oval") {
397
+ rects.push({
398
+ center: { x: obstacle.center.x, y: obstacle.center.y },
399
+ width: obstacle.width,
400
+ height: obstacle.height,
401
+ fill: "#fee2e2",
402
+ stroke: "#ef4444",
403
+ layer: "obstacle",
404
+ label: "obstacle",
405
+ })
406
+ }
407
+ }
408
+
409
+ // board void rects (early visualization of mask)
410
+ if (this.boardVoidRects) {
411
+ let outlineBBox: {
412
+ x: number
413
+ y: number
414
+ width: number
415
+ height: number
416
+ } | null = null
417
+
418
+ if (srj.outline && srj.outline.length > 0) {
419
+ const xs = srj.outline.map((p: { x: number; y: number }) => p.x)
420
+ const ys = srj.outline.map((p: { x: number; y: number }) => p.y)
421
+ const minX = Math.min(...xs)
422
+ const minY = Math.min(...ys)
423
+ outlineBBox = {
424
+ x: minX,
425
+ y: minY,
426
+ width: Math.max(...xs) - minX,
427
+ height: Math.max(...ys) - minY,
428
+ }
429
+ }
430
+
431
+ for (const r of this.boardVoidRects) {
432
+ if (outlineBBox && !overlaps(r, outlineBBox)) {
433
+ continue
434
+ }
435
+
436
+ rects.push({
437
+ center: { x: r.x + r.width / 2, y: r.y + r.height / 2 },
438
+ width: r.width,
439
+ height: r.height,
440
+ fill: "rgba(0, 0, 0, 0.5)",
441
+ stroke: "none",
442
+ label: "void",
443
+ })
444
+ }
445
+ }
446
+
447
+ // candidate positions (where expansion will later start from)
448
+ if (this.candidates?.length) {
449
+ for (const cand of this.candidates) {
450
+ points.push({
451
+ x: cand.x,
452
+ y: cand.y,
453
+ fill: "#9333ea",
454
+ stroke: "#6b21a8",
455
+ label: `z:${cand.z}`,
456
+ } as any)
457
+ }
458
+ }
459
+
460
+ // current placements (streaming) during grid fill
461
+ if (this.placed?.length) {
462
+ for (const placement of this.placed) {
463
+ const colors = this.getColorForZLayer(placement.zLayers)
464
+ rects.push({
465
+ center: {
466
+ x: placement.rect.x + placement.rect.width / 2,
467
+ y: placement.rect.y + placement.rect.height / 2,
468
+ },
469
+ width: placement.rect.width,
470
+ height: placement.rect.height,
471
+ fill: colors.fill,
472
+ stroke: colors.stroke,
473
+ layer: `z${placement.zLayers.join(",")}`,
474
+ label: `free\nz:${placement.zLayers.join(",")}`,
475
+ })
476
+ }
477
+ }
478
+
479
+ return {
480
+ title: "RectDiff Grid",
481
+ coordinateSystem: "cartesian",
482
+ rects,
483
+ points,
484
+ lines,
485
+ }
486
+ }
487
+ }
@@ -0,0 +1,109 @@
1
+ import type { Candidate3D, XYRect } from "../../rectdiff-types"
2
+ import { EPS, distancePointToRectEdges } from "../../utils/rectdiff-geometry"
3
+ import { isFullyOccupiedAtPoint } from "../../utils/isFullyOccupiedAtPoint"
4
+ import { longestFreeSpanAroundZ } from "./longestFreeSpanAroundZ"
5
+ import type RBush from "rbush"
6
+ import type { RTreeRect } from "lib/types/capacity-mesh-types"
7
+
8
+ /**
9
+ * Compute candidate seed points for a given grid size.
10
+ */
11
+ export function computeCandidates3D(params: {
12
+ bounds: XYRect
13
+ gridSize: number
14
+ layerCount: number
15
+ obstacleIndexByLayer: Array<RBush<RTreeRect> | undefined>
16
+ placedIndexByLayer: Array<RBush<RTreeRect> | undefined>
17
+ hardPlacedByLayer: XYRect[][]
18
+ }): Candidate3D[] {
19
+ const {
20
+ bounds,
21
+ gridSize,
22
+ layerCount,
23
+ obstacleIndexByLayer,
24
+ placedIndexByLayer,
25
+ hardPlacedByLayer,
26
+ } = params
27
+ const out = new Map<string, Candidate3D>() // key by (x,y)
28
+
29
+ for (let x = bounds.x; x < bounds.x + bounds.width; x += gridSize) {
30
+ for (let y = bounds.y; y < bounds.y + bounds.height; y += gridSize) {
31
+ // Skip outermost row/col (stable with prior behavior)
32
+ if (
33
+ Math.abs(x - bounds.x) < EPS ||
34
+ Math.abs(y - bounds.y) < EPS ||
35
+ x > bounds.x + bounds.width - gridSize - EPS ||
36
+ y > bounds.y + bounds.height - gridSize - EPS
37
+ ) {
38
+ continue
39
+ }
40
+
41
+ // New rule: Only drop if EVERY layer is occupied (by obstacle or node)
42
+ if (
43
+ isFullyOccupiedAtPoint({
44
+ layerCount,
45
+ obstacleIndexByLayer,
46
+ placedIndexByLayer,
47
+ point: { x, y },
48
+ })
49
+ )
50
+ continue
51
+
52
+ // Find the best (longest) free contiguous Z span at (x,y) ignoring soft nodes.
53
+ let bestSpan: number[] = []
54
+ let bestZ = 0
55
+ for (let z = 0; z < layerCount; z++) {
56
+ const s = longestFreeSpanAroundZ({
57
+ x,
58
+ y,
59
+ z,
60
+ layerCount,
61
+ minSpan: 1,
62
+ maxSpan: undefined,
63
+ obstacleIndexByLayer,
64
+ additionalBlockersByLayer: hardPlacedByLayer,
65
+ })
66
+ if (s.length > bestSpan.length) {
67
+ bestSpan = s
68
+ bestZ = z
69
+ }
70
+ }
71
+ const anchorZ = bestSpan.length
72
+ ? bestSpan[Math.floor(bestSpan.length / 2)]!
73
+ : bestZ
74
+
75
+ // Distance heuristic against hard blockers only (obstacles + full-stack)
76
+ const hardAtZ = [
77
+ ...(obstacleIndexByLayer[anchorZ]?.all() ?? []),
78
+ ...(hardPlacedByLayer[anchorZ] ?? []),
79
+ ]
80
+ const d = Math.min(
81
+ distancePointToRectEdges({ x, y }, bounds),
82
+ ...(hardAtZ.length
83
+ ? hardAtZ.map((b) => distancePointToRectEdges({ x, y }, b))
84
+ : [Infinity]),
85
+ )
86
+
87
+ const k = `${x.toFixed(6)}|${y.toFixed(6)}`
88
+ const cand: Candidate3D = {
89
+ x,
90
+ y,
91
+ z: anchorZ,
92
+ distance: d,
93
+ zSpanLen: bestSpan.length,
94
+ }
95
+ const prev = out.get(k)
96
+ if (
97
+ !prev ||
98
+ cand.zSpanLen! > (prev.zSpanLen ?? 0) ||
99
+ (cand.zSpanLen === prev.zSpanLen && cand.distance > prev.distance)
100
+ ) {
101
+ out.set(k, cand)
102
+ }
103
+ }
104
+ }
105
+
106
+ const arr = Array.from(out.values())
107
+ arr.sort((a, b) => b.zSpanLen! - a.zSpanLen! || b.distance - a.distance)
108
+ return arr
109
+ }
@@ -0,0 +1,9 @@
1
+ import type { XYRect } from "../../rectdiff-types"
2
+
3
+ /**
4
+ * Compute default grid sizes based on bounds.
5
+ */
6
+ export function computeDefaultGridSizes(bounds: XYRect): number[] {
7
+ const ref = Math.max(bounds.width, bounds.height)
8
+ return [ref / 8, ref / 16, ref / 32]
9
+ }