@tscircuit/rectdiff 0.0.1 → 0.0.2

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.
@@ -25,8 +25,8 @@ export function computeCandidates3D(
25
25
  gridSize: number,
26
26
  layerCount: number,
27
27
  obstaclesByLayer: XYRect[][],
28
- placedByLayer: XYRect[][], // all current nodes (soft + hard)
29
- hardPlacedByLayer: XYRect[][], // only full-stack nodes, treated as hard
28
+ placedByLayer: XYRect[][], // all current nodes (soft + hard)
29
+ hardPlacedByLayer: XYRect[][], // only full-stack nodes, treated as hard
30
30
  ): Candidate3D[] {
31
31
  const out = new Map<string, Candidate3D>() // key by (x,y)
32
32
 
@@ -43,7 +43,16 @@ export function computeCandidates3D(
43
43
  }
44
44
 
45
45
  // New rule: Only drop if EVERY layer is occupied (by obstacle or node)
46
- if (isFullyOccupiedAllLayers(x, y, layerCount, obstaclesByLayer, placedByLayer)) continue
46
+ if (
47
+ isFullyOccupiedAllLayers(
48
+ x,
49
+ y,
50
+ layerCount,
51
+ obstaclesByLayer,
52
+ placedByLayer,
53
+ )
54
+ )
55
+ continue
47
56
 
48
57
  // Find the best (longest) free contiguous Z span at (x,y) ignoring soft nodes.
49
58
  let bestSpan: number[] = []
@@ -55,9 +64,9 @@ export function computeCandidates3D(
55
64
  z,
56
65
  layerCount,
57
66
  1,
58
- undefined, // no cap here
67
+ undefined, // no cap here
59
68
  obstaclesByLayer,
60
- hardPlacedByLayer, // IMPORTANT: ignore soft nodes
69
+ hardPlacedByLayer, // IMPORTANT: ignore soft nodes
61
70
  )
62
71
  if (s.length > bestSpan.length) {
63
72
  bestSpan = s
@@ -100,7 +109,7 @@ export function computeCandidates3D(
100
109
  }
101
110
 
102
111
  const arr = Array.from(out.values())
103
- arr.sort((a, b) => (b.zSpanLen! - a.zSpanLen!) || (b.distance - a.distance))
112
+ arr.sort((a, b) => b.zSpanLen! - a.zSpanLen! || b.distance - a.distance)
104
113
  return arr
105
114
  }
106
115
 
@@ -116,7 +125,10 @@ export function longestFreeSpanAroundZ(
116
125
  placedByLayer: XYRect[][],
117
126
  ): number[] {
118
127
  const isFreeAt = (layer: number) => {
119
- const blockers = [...(obstaclesByLayer[layer] ?? []), ...(placedByLayer[layer] ?? [])]
128
+ const blockers = [
129
+ ...(obstaclesByLayer[layer] ?? []),
130
+ ...(placedByLayer[layer] ?? []),
131
+ ]
120
132
  return !blockers.some((b) => containsPoint(b, x, y))
121
133
  }
122
134
  let lo = z
@@ -214,24 +226,34 @@ export function computeEdgeCandidates3D(
214
226
  minSize: number,
215
227
  layerCount: number,
216
228
  obstaclesByLayer: XYRect[][],
217
- placedByLayer: XYRect[][], // all nodes
229
+ placedByLayer: XYRect[][], // all nodes
218
230
  hardPlacedByLayer: XYRect[][], // full-stack nodes
219
231
  ): Candidate3D[] {
220
232
  const out: Candidate3D[] = []
221
233
  // Use small inset from edges for placement
222
234
  const δ = Math.max(minSize * 0.15, EPS * 3)
223
235
  const dedup = new Set<string>()
224
- const key = (x: number, y: number, z: number) => `${z}|${x.toFixed(6)}|${y.toFixed(6)}`
236
+ const key = (x: number, y: number, z: number) =>
237
+ `${z}|${x.toFixed(6)}|${y.toFixed(6)}`
225
238
 
226
239
  function fullyOcc(x: number, y: number) {
227
- return isFullyOccupiedAllLayers(x, y, layerCount, obstaclesByLayer, placedByLayer)
240
+ return isFullyOccupiedAllLayers(
241
+ x,
242
+ y,
243
+ layerCount,
244
+ obstaclesByLayer,
245
+ placedByLayer,
246
+ )
228
247
  }
229
248
 
230
249
  function pushIfFree(x: number, y: number, z: number) {
231
250
  if (
232
- x < bounds.x + EPS || y < bounds.y + EPS ||
233
- x > bounds.x + bounds.width - EPS || y > bounds.y + bounds.height - EPS
234
- ) return
251
+ x < bounds.x + EPS ||
252
+ y < bounds.y + EPS ||
253
+ x > bounds.x + bounds.width - EPS ||
254
+ y > bounds.y + bounds.height - EPS
255
+ )
256
+ return
235
257
  if (fullyOcc(x, y)) return // new rule: only drop if truly impossible
236
258
 
237
259
  // Distance uses obstacles + hard nodes (soft nodes ignored for ranking)
@@ -241,7 +263,9 @@ export function computeEdgeCandidates3D(
241
263
  ]
242
264
  const d = Math.min(
243
265
  distancePointToRectEdges(x, y, bounds),
244
- ...(hard.length ? hard.map((b) => distancePointToRectEdges(x, y, b)) : [Infinity]),
266
+ ...(hard.length
267
+ ? hard.map((b) => distancePointToRectEdges(x, y, b))
268
+ : [Infinity]),
245
269
  )
246
270
 
247
271
  const k = key(x, y, z)
@@ -249,12 +273,24 @@ export function computeEdgeCandidates3D(
249
273
  dedup.add(k)
250
274
 
251
275
  // Approximate z-span strength at this z (ignoring soft nodes)
252
- const span = longestFreeSpanAroundZ(x, y, z, layerCount, 1, undefined, obstaclesByLayer, hardPlacedByLayer)
276
+ const span = longestFreeSpanAroundZ(
277
+ x,
278
+ y,
279
+ z,
280
+ layerCount,
281
+ 1,
282
+ undefined,
283
+ obstaclesByLayer,
284
+ hardPlacedByLayer,
285
+ )
253
286
  out.push({ x, y, z, distance: d, zSpanLen: span.length, isEdgeSeed: true })
254
287
  }
255
288
 
256
289
  for (let z = 0; z < layerCount; z++) {
257
- const blockers = [...(obstaclesByLayer[z] ?? []), ...(hardPlacedByLayer[z] ?? [])]
290
+ const blockers = [
291
+ ...(obstaclesByLayer[z] ?? []),
292
+ ...(hardPlacedByLayer[z] ?? []),
293
+ ]
258
294
 
259
295
  // 1) Board edges — find exact uncovered segments along each edge
260
296
 
@@ -272,10 +308,18 @@ export function computeEdgeCandidates3D(
272
308
  // Top edge (y = bounds.y + δ)
273
309
  const topY = bounds.y + δ
274
310
  const topCovering = blockers
275
- .filter(b => b.y <= topY && b.y + b.height >= topY)
276
- .map(b => ({ start: Math.max(bounds.x, b.x), end: Math.min(bounds.x + bounds.width, b.x + b.width) }))
311
+ .filter((b) => b.y <= topY && b.y + b.height >= topY)
312
+ .map((b) => ({
313
+ start: Math.max(bounds.x, b.x),
314
+ end: Math.min(bounds.x + bounds.width, b.x + b.width),
315
+ }))
277
316
  // Find uncovered segments that are large enough to potentially fill
278
- const topUncovered = computeUncoveredSegments(bounds.x + δ, bounds.x + bounds.width - δ, topCovering, minSize * 0.5)
317
+ const topUncovered = computeUncoveredSegments(
318
+ bounds.x + δ,
319
+ bounds.x + bounds.width - δ,
320
+ topCovering,
321
+ minSize * 0.5,
322
+ )
279
323
  for (const seg of topUncovered) {
280
324
  const segLen = seg.end - seg.start
281
325
  if (segLen >= minSize) {
@@ -291,9 +335,17 @@ export function computeEdgeCandidates3D(
291
335
  // Bottom edge (y = bounds.y + bounds.height - δ)
292
336
  const bottomY = bounds.y + bounds.height - δ
293
337
  const bottomCovering = blockers
294
- .filter(b => b.y <= bottomY && b.y + b.height >= bottomY)
295
- .map(b => ({ start: Math.max(bounds.x, b.x), end: Math.min(bounds.x + bounds.width, b.x + b.width) }))
296
- const bottomUncovered = computeUncoveredSegments(bounds.x + δ, bounds.x + bounds.width - δ, bottomCovering, minSize * 0.5)
338
+ .filter((b) => b.y <= bottomY && b.y + b.height >= bottomY)
339
+ .map((b) => ({
340
+ start: Math.max(bounds.x, b.x),
341
+ end: Math.min(bounds.x + bounds.width, b.x + b.width),
342
+ }))
343
+ const bottomUncovered = computeUncoveredSegments(
344
+ bounds.x + δ,
345
+ bounds.x + bounds.width - δ,
346
+ bottomCovering,
347
+ minSize * 0.5,
348
+ )
297
349
  for (const seg of bottomUncovered) {
298
350
  const segLen = seg.end - seg.start
299
351
  if (segLen >= minSize) {
@@ -308,9 +360,17 @@ export function computeEdgeCandidates3D(
308
360
  // Left edge (x = bounds.x + δ)
309
361
  const leftX = bounds.x + δ
310
362
  const leftCovering = blockers
311
- .filter(b => b.x <= leftX && b.x + b.width >= leftX)
312
- .map(b => ({ start: Math.max(bounds.y, b.y), end: Math.min(bounds.y + bounds.height, b.y + b.height) }))
313
- const leftUncovered = computeUncoveredSegments(bounds.y + δ, bounds.y + bounds.height - δ, leftCovering, minSize * 0.5)
363
+ .filter((b) => b.x <= leftX && b.x + b.width >= leftX)
364
+ .map((b) => ({
365
+ start: Math.max(bounds.y, b.y),
366
+ end: Math.min(bounds.y + bounds.height, b.y + b.height),
367
+ }))
368
+ const leftUncovered = computeUncoveredSegments(
369
+ bounds.y + δ,
370
+ bounds.y + bounds.height - δ,
371
+ leftCovering,
372
+ minSize * 0.5,
373
+ )
314
374
  for (const seg of leftUncovered) {
315
375
  const segLen = seg.end - seg.start
316
376
  if (segLen >= minSize) {
@@ -325,9 +385,17 @@ export function computeEdgeCandidates3D(
325
385
  // Right edge (x = bounds.x + bounds.width - δ)
326
386
  const rightX = bounds.x + bounds.width - δ
327
387
  const rightCovering = blockers
328
- .filter(b => b.x <= rightX && b.x + b.width >= rightX)
329
- .map(b => ({ start: Math.max(bounds.y, b.y), end: Math.min(bounds.y + bounds.height, b.y + b.height) }))
330
- const rightUncovered = computeUncoveredSegments(bounds.y + δ, bounds.y + bounds.height - δ, rightCovering, minSize * 0.5)
388
+ .filter((b) => b.x <= rightX && b.x + b.width >= rightX)
389
+ .map((b) => ({
390
+ start: Math.max(bounds.y, b.y),
391
+ end: Math.min(bounds.y + bounds.height, b.y + b.height),
392
+ }))
393
+ const rightUncovered = computeUncoveredSegments(
394
+ bounds.y + δ,
395
+ bounds.y + bounds.height - δ,
396
+ rightCovering,
397
+ minSize * 0.5,
398
+ )
331
399
  for (const seg of rightUncovered) {
332
400
  const segLen = seg.end - seg.start
333
401
  if (segLen >= minSize) {
@@ -345,9 +413,19 @@ export function computeEdgeCandidates3D(
345
413
  const obLeftX = b.x - δ
346
414
  if (obLeftX > bounds.x + EPS && obLeftX < bounds.x + bounds.width - EPS) {
347
415
  const obLeftCovering = blockers
348
- .filter(bl => bl !== b && bl.x <= obLeftX && bl.x + bl.width >= obLeftX)
349
- .map(bl => ({ start: Math.max(b.y, bl.y), end: Math.min(b.y + b.height, bl.y + bl.height) }))
350
- const obLeftUncovered = computeUncoveredSegments(b.y, b.y + b.height, obLeftCovering, minSize * 0.5)
416
+ .filter(
417
+ (bl) => bl !== b && bl.x <= obLeftX && bl.x + bl.width >= obLeftX,
418
+ )
419
+ .map((bl) => ({
420
+ start: Math.max(b.y, bl.y),
421
+ end: Math.min(b.y + b.height, bl.y + bl.height),
422
+ }))
423
+ const obLeftUncovered = computeUncoveredSegments(
424
+ b.y,
425
+ b.y + b.height,
426
+ obLeftCovering,
427
+ minSize * 0.5,
428
+ )
351
429
  for (const seg of obLeftUncovered) {
352
430
  pushIfFree(obLeftX, seg.center, z)
353
431
  }
@@ -355,11 +433,24 @@ export function computeEdgeCandidates3D(
355
433
 
356
434
  // Right edge of blocker (x = b.x + b.width + δ)
357
435
  const obRightX = b.x + b.width + δ
358
- if (obRightX > bounds.x + EPS && obRightX < bounds.x + bounds.width - EPS) {
436
+ if (
437
+ obRightX > bounds.x + EPS &&
438
+ obRightX < bounds.x + bounds.width - EPS
439
+ ) {
359
440
  const obRightCovering = blockers
360
- .filter(bl => bl !== b && bl.x <= obRightX && bl.x + bl.width >= obRightX)
361
- .map(bl => ({ start: Math.max(b.y, bl.y), end: Math.min(b.y + b.height, bl.y + bl.height) }))
362
- const obRightUncovered = computeUncoveredSegments(b.y, b.y + b.height, obRightCovering, minSize * 0.5)
441
+ .filter(
442
+ (bl) => bl !== b && bl.x <= obRightX && bl.x + bl.width >= obRightX,
443
+ )
444
+ .map((bl) => ({
445
+ start: Math.max(b.y, bl.y),
446
+ end: Math.min(b.y + b.height, bl.y + bl.height),
447
+ }))
448
+ const obRightUncovered = computeUncoveredSegments(
449
+ b.y,
450
+ b.y + b.height,
451
+ obRightCovering,
452
+ minSize * 0.5,
453
+ )
363
454
  for (const seg of obRightUncovered) {
364
455
  pushIfFree(obRightX, seg.center, z)
365
456
  }
@@ -369,9 +460,19 @@ export function computeEdgeCandidates3D(
369
460
  const obTopY = b.y - δ
370
461
  if (obTopY > bounds.y + EPS && obTopY < bounds.y + bounds.height - EPS) {
371
462
  const obTopCovering = blockers
372
- .filter(bl => bl !== b && bl.y <= obTopY && bl.y + bl.height >= obTopY)
373
- .map(bl => ({ start: Math.max(b.x, bl.x), end: Math.min(b.x + b.width, bl.x + bl.width) }))
374
- const obTopUncovered = computeUncoveredSegments(b.x, b.x + b.width, obTopCovering, minSize * 0.5)
463
+ .filter(
464
+ (bl) => bl !== b && bl.y <= obTopY && bl.y + bl.height >= obTopY,
465
+ )
466
+ .map((bl) => ({
467
+ start: Math.max(b.x, bl.x),
468
+ end: Math.min(b.x + b.width, bl.x + bl.width),
469
+ }))
470
+ const obTopUncovered = computeUncoveredSegments(
471
+ b.x,
472
+ b.x + b.width,
473
+ obTopCovering,
474
+ minSize * 0.5,
475
+ )
375
476
  for (const seg of obTopUncovered) {
376
477
  pushIfFree(seg.center, obTopY, z)
377
478
  }
@@ -379,11 +480,25 @@ export function computeEdgeCandidates3D(
379
480
 
380
481
  // Bottom edge of blocker (y = b.y + b.height + δ)
381
482
  const obBottomY = b.y + b.height + δ
382
- if (obBottomY > bounds.y + EPS && obBottomY < bounds.y + bounds.height - EPS) {
483
+ if (
484
+ obBottomY > bounds.y + EPS &&
485
+ obBottomY < bounds.y + bounds.height - EPS
486
+ ) {
383
487
  const obBottomCovering = blockers
384
- .filter(bl => bl !== b && bl.y <= obBottomY && bl.y + bl.height >= obBottomY)
385
- .map(bl => ({ start: Math.max(b.x, bl.x), end: Math.min(b.x + b.width, bl.x + bl.width) }))
386
- const obBottomUncovered = computeUncoveredSegments(b.x, b.x + b.width, obBottomCovering, minSize * 0.5)
488
+ .filter(
489
+ (bl) =>
490
+ bl !== b && bl.y <= obBottomY && bl.y + bl.height >= obBottomY,
491
+ )
492
+ .map((bl) => ({
493
+ start: Math.max(b.x, bl.x),
494
+ end: Math.min(b.x + b.width, bl.x + bl.width),
495
+ }))
496
+ const obBottomUncovered = computeUncoveredSegments(
497
+ b.x,
498
+ b.x + b.width,
499
+ obBottomCovering,
500
+ minSize * 0.5,
501
+ )
387
502
  for (const seg of obBottomUncovered) {
388
503
  pushIfFree(seg.center, obBottomY, z)
389
504
  }
@@ -392,6 +507,6 @@ export function computeEdgeCandidates3D(
392
507
  }
393
508
 
394
509
  // Strong multi-layer preference then distance.
395
- out.sort((a, b) => (b.zSpanLen! - a.zSpanLen!) || (b.distance - a.distance))
510
+ out.sort((a, b) => b.zSpanLen! - a.zSpanLen! || b.distance - a.distance)
396
511
  return out
397
512
  }
@@ -1,5 +1,11 @@
1
1
  // lib/solvers/rectdiff/engine.ts
2
- import type { GridFill3DOptions, Placed3D, Rect3d, RectDiffState, XYRect } from "./types"
2
+ import type {
3
+ GridFill3DOptions,
4
+ Placed3D,
5
+ Rect3d,
6
+ RectDiffState,
7
+ XYRect,
8
+ } from "./types"
3
9
  import type { SimpleRouteJson } from "../../types/srj-types"
4
10
  import {
5
11
  computeCandidates3D,
@@ -7,10 +13,19 @@ import {
7
13
  computeEdgeCandidates3D,
8
14
  longestFreeSpanAroundZ,
9
15
  } from "./candidates"
10
- import { EPS, containsPoint, expandRectFromSeed, overlaps, subtractRect2D } from "./geometry"
16
+ import {
17
+ EPS,
18
+ containsPoint,
19
+ expandRectFromSeed,
20
+ overlaps,
21
+ subtractRect2D,
22
+ } from "./geometry"
11
23
  import { buildZIndexMap, obstacleToXYRect, obstacleZs } from "./layers"
12
24
 
13
- export function initState(srj: SimpleRouteJson, opts: Partial<GridFill3DOptions>): RectDiffState {
25
+ export function initState(
26
+ srj: SimpleRouteJson,
27
+ opts: Partial<GridFill3DOptions>,
28
+ ): RectDiffState {
14
29
  const { layerNames, zIndexByName } = buildZIndexMap(srj)
15
30
  const layerCount = Math.max(1, layerNames.length, srj.layerCount || 1)
16
31
 
@@ -22,16 +37,22 @@ export function initState(srj: SimpleRouteJson, opts: Partial<GridFill3DOptions>
22
37
  }
23
38
 
24
39
  // Obstacles per layer
25
- const obstaclesByLayer: XYRect[][] = Array.from({ length: layerCount }, () => [])
40
+ const obstaclesByLayer: XYRect[][] = Array.from(
41
+ { length: layerCount },
42
+ () => [],
43
+ )
26
44
  for (const ob of srj.obstacles ?? []) {
27
45
  const r = obstacleToXYRect(ob)
28
46
  if (!r) continue
29
47
  const zs = obstacleZs(ob, zIndexByName)
30
- for (const z of zs) if (z >= 0 && z < layerCount) obstaclesByLayer[z]!.push(r)
48
+ for (const z of zs)
49
+ if (z >= 0 && z < layerCount) obstaclesByLayer[z]!.push(r)
31
50
  }
32
51
 
33
52
  const trace = Math.max(0.01, srj.minTraceWidth || 0.15)
34
- const defaults: Required<Omit<GridFill3DOptions, "gridSizes" | "maxMultiLayerSpan">> & {
53
+ const defaults: Required<
54
+ Omit<GridFill3DOptions, "gridSizes" | "maxMultiLayerSpan">
55
+ > & {
35
56
  gridSizes: number[]
36
57
  maxMultiLayerSpan: number | undefined
37
58
  } = {
@@ -48,7 +69,11 @@ export function initState(srj: SimpleRouteJson, opts: Partial<GridFill3DOptions>
48
69
  maxMultiLayerSpan: undefined,
49
70
  }
50
71
 
51
- const options = { ...defaults, ...opts, gridSizes: opts.gridSizes ?? defaults.gridSizes }
72
+ const options = {
73
+ ...defaults,
74
+ ...opts,
75
+ gridSizes: opts.gridSizes ?? defaults.gridSizes,
76
+ }
52
77
 
53
78
  const placedByLayer: XYRect[][] = Array.from({ length: layerCount }, () => [])
54
79
 
@@ -83,7 +108,11 @@ function buildHardPlacedByLayer(state: RectDiffState): XYRect[][] {
83
108
  return out
84
109
  }
85
110
 
86
- function isFullyOccupiedAtPoint(state: RectDiffState, x: number, y: number): boolean {
111
+ function isFullyOccupiedAtPoint(
112
+ state: RectDiffState,
113
+ x: number,
114
+ y: number,
115
+ ): boolean {
87
116
  for (let z = 0; z < state.layerCount; z++) {
88
117
  const obs = state.obstaclesByLayer[z] ?? []
89
118
  const placed = state.placedByLayer[z] ?? []
@@ -126,8 +155,14 @@ function resizeSoftOverlaps(state: RectDiffState, newIndex: number) {
126
155
  }
127
156
 
128
157
  // Re-add carved pieces for affected layers, dropping tiny slivers
129
- const minW = Math.min(state.options.minSingle.width, state.options.minMulti.width)
130
- const minH = Math.min(state.options.minSingle.height, state.options.minMulti.height)
158
+ const minW = Math.min(
159
+ state.options.minSingle.width,
160
+ state.options.minMulti.width,
161
+ )
162
+ const minH = Math.min(
163
+ state.options.minSingle.height,
164
+ state.options.minMulti.height,
165
+ )
131
166
  for (const p of parts) {
132
167
  if (p.width + EPS >= minW && p.height + EPS >= minH) {
133
168
  toAdd.push({ rect: p, zLayers: sharedZ.slice() })
@@ -136,14 +171,16 @@ function resizeSoftOverlaps(state: RectDiffState, newIndex: number) {
136
171
  }
137
172
 
138
173
  // Remove (and clear placedByLayer)
139
- removeIdx.sort((a, b) => b - a).forEach((idx) => {
140
- const rem = state.placed.splice(idx, 1)[0]!
141
- for (const z of rem.zLayers) {
142
- const arr = state.placedByLayer[z]!
143
- const j = arr.findIndex((r) => r === rem.rect)
144
- if (j >= 0) arr.splice(j, 1)
145
- }
146
- })
174
+ removeIdx
175
+ .sort((a, b) => b - a)
176
+ .forEach((idx) => {
177
+ const rem = state.placed.splice(idx, 1)[0]!
178
+ for (const z of rem.zLayers) {
179
+ const arr = state.placedByLayer[z]!
180
+ const j = arr.findIndex((r) => r === rem.rect)
181
+ if (j >= 0) arr.splice(j, 1)
182
+ }
183
+ })
147
184
 
148
185
  // Add replacements
149
186
  for (const p of toAdd) {
@@ -175,8 +212,8 @@ export function stepGrid(state: RectDiffState): void {
175
212
  grid,
176
213
  state.layerCount,
177
214
  state.obstaclesByLayer,
178
- state.placedByLayer, // all nodes (soft + hard) for fully-occupied test
179
- hardPlacedByLayer, // hard blockers for ranking/span
215
+ state.placedByLayer, // all nodes (soft + hard) for fully-occupied test
216
+ hardPlacedByLayer, // hard blockers for ranking/span
180
217
  )
181
218
  state.totalSeedsThisGrid = state.candidates.length
182
219
  state.consumedSeedsThisGrid = 0
@@ -197,7 +234,7 @@ export function stepGrid(state: RectDiffState): void {
197
234
  minSize,
198
235
  state.layerCount,
199
236
  state.obstaclesByLayer,
200
- state.placedByLayer, // for fully-occupied test
237
+ state.placedByLayer, // for fully-occupied test
201
238
  hardPlacedByLayer,
202
239
  )
203
240
  state.edgeAnalysisDone = true
@@ -234,9 +271,17 @@ export function stepGrid(state: RectDiffState): void {
234
271
  }> = []
235
272
 
236
273
  if (span.length >= minMulti.minLayers) {
237
- attempts.push({ kind: "multi", layers: span, minReq: { width: minMulti.width, height: minMulti.height } })
274
+ attempts.push({
275
+ kind: "multi",
276
+ layers: span,
277
+ minReq: { width: minMulti.width, height: minMulti.height },
278
+ })
238
279
  }
239
- attempts.push({ kind: "single", layers: [cand.z], minReq: { width: minSingle.width, height: minSingle.height } })
280
+ attempts.push({
281
+ kind: "single",
282
+ layers: [cand.z],
283
+ minReq: { width: minSingle.width, height: minSingle.height },
284
+ })
240
285
 
241
286
  const ordered = preferMultiLayer ? attempts : attempts.reverse()
242
287
 
@@ -244,7 +289,8 @@ export function stepGrid(state: RectDiffState): void {
244
289
  // HARD blockers only: obstacles on those layers + full-stack nodes
245
290
  const hardBlockers: XYRect[] = []
246
291
  for (const z of attempt.layers) {
247
- if (state.obstaclesByLayer[z]) hardBlockers.push(...state.obstaclesByLayer[z]!)
292
+ if (state.obstaclesByLayer[z])
293
+ hardBlockers.push(...state.obstaclesByLayer[z]!)
248
294
  if (hardPlacedByLayer[z]) hardBlockers.push(...hardPlacedByLayer[z]!)
249
295
  }
250
296
 
@@ -253,7 +299,7 @@ export function stepGrid(state: RectDiffState): void {
253
299
  cand.y,
254
300
  grid,
255
301
  state.bounds,
256
- hardBlockers, // soft nodes DO NOT block expansion
302
+ hardBlockers, // soft nodes DO NOT block expansion
257
303
  initialCellRatio,
258
304
  maxAspectRatio,
259
305
  attempt.minReq,
@@ -269,7 +315,9 @@ export function stepGrid(state: RectDiffState): void {
269
315
  resizeSoftOverlaps(state, newIndex)
270
316
 
271
317
  // New: relax candidate culling — only drop seeds that became fully occupied
272
- state.candidates = state.candidates.filter((c) => !isFullyOccupiedAtPoint(state, c.x, c.y))
318
+ state.candidates = state.candidates.filter(
319
+ (c) => !isFullyOccupiedAtPoint(state, c.x, c.y),
320
+ )
273
321
 
274
322
  return // processed one candidate
275
323
  }
@@ -304,8 +352,8 @@ export function stepExpansion(state: RectDiffState): void {
304
352
  lastGrid,
305
353
  state.bounds,
306
354
  hardBlockers,
307
- 0, // seed bias off
308
- null, // no aspect cap in expansion pass
355
+ 0, // seed bias off
356
+ null, // no aspect cap in expansion pass
309
357
  { width: p.rect.width, height: p.rect.height },
310
358
  )
311
359
 
@@ -326,13 +374,44 @@ export function stepExpansion(state: RectDiffState): void {
326
374
  }
327
375
 
328
376
  export function finalizeRects(state: RectDiffState): Rect3d[] {
329
- return state.placed.map((p) => ({
377
+ // Convert all placed (free space) nodes to output format
378
+ const out: Rect3d[] = state.placed.map((p) => ({
330
379
  minX: p.rect.x,
331
380
  minY: p.rect.y,
332
381
  maxX: p.rect.x + p.rect.width,
333
382
  maxY: p.rect.y + p.rect.height,
334
383
  zLayers: [...p.zLayers].sort((a, b) => a - b),
335
384
  }))
385
+
386
+ /**
387
+ * Recover obstacles as mesh nodes.
388
+ * Obstacles are stored per-layer in `obstaclesByLayer`, but we want to emit
389
+ * single 3D nodes for multi-layer obstacles if they share the same rect.
390
+ * We use the `XYRect` object reference identity to group layers.
391
+ */
392
+ const layersByObstacleRect = new Map<XYRect, number[]>()
393
+
394
+ state.obstaclesByLayer.forEach((layerObs, z) => {
395
+ for (const rect of layerObs) {
396
+ const layerIndices = layersByObstacleRect.get(rect) ?? []
397
+ layerIndices.push(z)
398
+ layersByObstacleRect.set(rect, layerIndices)
399
+ }
400
+ })
401
+
402
+ // Append obstacle nodes to the output
403
+ for (const [rect, layerIndices] of layersByObstacleRect.entries()) {
404
+ out.push({
405
+ minX: rect.x,
406
+ minY: rect.y,
407
+ maxX: rect.x + rect.width,
408
+ maxY: rect.y + rect.height,
409
+ zLayers: layerIndices.sort((a, b) => a - b),
410
+ isObstacle: true,
411
+ })
412
+ }
413
+
414
+ return out
336
415
  }
337
416
 
338
417
  /** Optional: rough progress number for BaseSolver.progress */