@tscircuit/rectdiff 0.0.11 → 0.0.13

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