@tscircuit/rectdiff 0.0.1 → 0.0.3

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.
@@ -5,7 +5,13 @@ import type { GraphicsObject } from "graphics-debug"
5
5
  import type { CapacityMeshNode } from "../types/capacity-mesh-types"
6
6
 
7
7
  import type { GridFill3DOptions, RectDiffState } from "./rectdiff/types"
8
- import { initState, stepGrid, stepExpansion, finalizeRects, computeProgress } from "./rectdiff/engine"
8
+ import {
9
+ initState,
10
+ stepGrid,
11
+ stepExpansion,
12
+ finalizeRects,
13
+ computeProgress,
14
+ } from "./rectdiff/engine"
9
15
  import { rectsToMeshNodes } from "./rectdiff/rectsToMeshNodes"
10
16
 
11
17
  // A streaming, one-step-per-iteration solver.
@@ -71,7 +77,10 @@ export class RectDiffSolver extends BaseSolver {
71
77
  }
72
78
 
73
79
  // Helper to get color based on z layer
74
- private getColorForZLayer(zLayers: number[]): { fill: string; stroke: string } {
80
+ private getColorForZLayer(zLayers: number[]): {
81
+ fill: string
82
+ stroke: string
83
+ } {
75
84
  const minZ = Math.min(...zLayers)
76
85
  const colors = [
77
86
  { fill: "#dbeafe", stroke: "#3b82f6" }, // blue (z=0)
@@ -135,7 +144,10 @@ export class RectDiffSolver extends BaseSolver {
135
144
  for (const p of this.state.placed) {
136
145
  const colors = this.getColorForZLayer(p.zLayers)
137
146
  rects.push({
138
- center: { x: p.rect.x + p.rect.width / 2, y: p.rect.y + p.rect.height / 2 },
147
+ center: {
148
+ x: p.rect.x + p.rect.width / 2,
149
+ y: p.rect.y + p.rect.height / 2,
150
+ },
139
151
  width: p.rect.width,
140
152
  height: p.rect.height,
141
153
  fill: colors.fill,
@@ -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,31 @@ 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
+ const invalidZs = zs.filter((z) => z < 0 || z >= layerCount)
49
+ if (invalidZs.length) {
50
+ throw new Error(
51
+ `RectDiffSolver: obstacle uses z-layer indices ${invalidZs.join(
52
+ ",",
53
+ )} outside 0-${layerCount - 1}`,
54
+ )
55
+ }
56
+ // Persist normalized zLayers back onto the shared SRJ so downstream solvers see them.
57
+ if ((!ob.zLayers || ob.zLayers.length === 0) && zs.length) ob.zLayers = zs
58
+ for (const z of zs) obstaclesByLayer[z]!.push(r)
31
59
  }
32
60
 
33
61
  const trace = Math.max(0.01, srj.minTraceWidth || 0.15)
34
- const defaults: Required<Omit<GridFill3DOptions, "gridSizes" | "maxMultiLayerSpan">> & {
62
+ const defaults: Required<
63
+ Omit<GridFill3DOptions, "gridSizes" | "maxMultiLayerSpan">
64
+ > & {
35
65
  gridSizes: number[]
36
66
  maxMultiLayerSpan: number | undefined
37
67
  } = {
@@ -48,7 +78,11 @@ export function initState(srj: SimpleRouteJson, opts: Partial<GridFill3DOptions>
48
78
  maxMultiLayerSpan: undefined,
49
79
  }
50
80
 
51
- const options = { ...defaults, ...opts, gridSizes: opts.gridSizes ?? defaults.gridSizes }
81
+ const options = {
82
+ ...defaults,
83
+ ...opts,
84
+ gridSizes: opts.gridSizes ?? defaults.gridSizes,
85
+ }
52
86
 
53
87
  const placedByLayer: XYRect[][] = Array.from({ length: layerCount }, () => [])
54
88
 
@@ -83,7 +117,11 @@ function buildHardPlacedByLayer(state: RectDiffState): XYRect[][] {
83
117
  return out
84
118
  }
85
119
 
86
- function isFullyOccupiedAtPoint(state: RectDiffState, x: number, y: number): boolean {
120
+ function isFullyOccupiedAtPoint(
121
+ state: RectDiffState,
122
+ x: number,
123
+ y: number,
124
+ ): boolean {
87
125
  for (let z = 0; z < state.layerCount; z++) {
88
126
  const obs = state.obstaclesByLayer[z] ?? []
89
127
  const placed = state.placedByLayer[z] ?? []
@@ -126,8 +164,14 @@ function resizeSoftOverlaps(state: RectDiffState, newIndex: number) {
126
164
  }
127
165
 
128
166
  // 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)
167
+ const minW = Math.min(
168
+ state.options.minSingle.width,
169
+ state.options.minMulti.width,
170
+ )
171
+ const minH = Math.min(
172
+ state.options.minSingle.height,
173
+ state.options.minMulti.height,
174
+ )
131
175
  for (const p of parts) {
132
176
  if (p.width + EPS >= minW && p.height + EPS >= minH) {
133
177
  toAdd.push({ rect: p, zLayers: sharedZ.slice() })
@@ -136,14 +180,16 @@ function resizeSoftOverlaps(state: RectDiffState, newIndex: number) {
136
180
  }
137
181
 
138
182
  // 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
- })
183
+ removeIdx
184
+ .sort((a, b) => b - a)
185
+ .forEach((idx) => {
186
+ const rem = state.placed.splice(idx, 1)[0]!
187
+ for (const z of rem.zLayers) {
188
+ const arr = state.placedByLayer[z]!
189
+ const j = arr.findIndex((r) => r === rem.rect)
190
+ if (j >= 0) arr.splice(j, 1)
191
+ }
192
+ })
147
193
 
148
194
  // Add replacements
149
195
  for (const p of toAdd) {
@@ -175,8 +221,8 @@ export function stepGrid(state: RectDiffState): void {
175
221
  grid,
176
222
  state.layerCount,
177
223
  state.obstaclesByLayer,
178
- state.placedByLayer, // all nodes (soft + hard) for fully-occupied test
179
- hardPlacedByLayer, // hard blockers for ranking/span
224
+ state.placedByLayer, // all nodes (soft + hard) for fully-occupied test
225
+ hardPlacedByLayer, // hard blockers for ranking/span
180
226
  )
181
227
  state.totalSeedsThisGrid = state.candidates.length
182
228
  state.consumedSeedsThisGrid = 0
@@ -197,7 +243,7 @@ export function stepGrid(state: RectDiffState): void {
197
243
  minSize,
198
244
  state.layerCount,
199
245
  state.obstaclesByLayer,
200
- state.placedByLayer, // for fully-occupied test
246
+ state.placedByLayer, // for fully-occupied test
201
247
  hardPlacedByLayer,
202
248
  )
203
249
  state.edgeAnalysisDone = true
@@ -234,9 +280,17 @@ export function stepGrid(state: RectDiffState): void {
234
280
  }> = []
235
281
 
236
282
  if (span.length >= minMulti.minLayers) {
237
- attempts.push({ kind: "multi", layers: span, minReq: { width: minMulti.width, height: minMulti.height } })
283
+ attempts.push({
284
+ kind: "multi",
285
+ layers: span,
286
+ minReq: { width: minMulti.width, height: minMulti.height },
287
+ })
238
288
  }
239
- attempts.push({ kind: "single", layers: [cand.z], minReq: { width: minSingle.width, height: minSingle.height } })
289
+ attempts.push({
290
+ kind: "single",
291
+ layers: [cand.z],
292
+ minReq: { width: minSingle.width, height: minSingle.height },
293
+ })
240
294
 
241
295
  const ordered = preferMultiLayer ? attempts : attempts.reverse()
242
296
 
@@ -244,7 +298,8 @@ export function stepGrid(state: RectDiffState): void {
244
298
  // HARD blockers only: obstacles on those layers + full-stack nodes
245
299
  const hardBlockers: XYRect[] = []
246
300
  for (const z of attempt.layers) {
247
- if (state.obstaclesByLayer[z]) hardBlockers.push(...state.obstaclesByLayer[z]!)
301
+ if (state.obstaclesByLayer[z])
302
+ hardBlockers.push(...state.obstaclesByLayer[z]!)
248
303
  if (hardPlacedByLayer[z]) hardBlockers.push(...hardPlacedByLayer[z]!)
249
304
  }
250
305
 
@@ -253,7 +308,7 @@ export function stepGrid(state: RectDiffState): void {
253
308
  cand.y,
254
309
  grid,
255
310
  state.bounds,
256
- hardBlockers, // soft nodes DO NOT block expansion
311
+ hardBlockers, // soft nodes DO NOT block expansion
257
312
  initialCellRatio,
258
313
  maxAspectRatio,
259
314
  attempt.minReq,
@@ -269,7 +324,9 @@ export function stepGrid(state: RectDiffState): void {
269
324
  resizeSoftOverlaps(state, newIndex)
270
325
 
271
326
  // New: relax candidate culling — only drop seeds that became fully occupied
272
- state.candidates = state.candidates.filter((c) => !isFullyOccupiedAtPoint(state, c.x, c.y))
327
+ state.candidates = state.candidates.filter(
328
+ (c) => !isFullyOccupiedAtPoint(state, c.x, c.y),
329
+ )
273
330
 
274
331
  return // processed one candidate
275
332
  }
@@ -304,8 +361,8 @@ export function stepExpansion(state: RectDiffState): void {
304
361
  lastGrid,
305
362
  state.bounds,
306
363
  hardBlockers,
307
- 0, // seed bias off
308
- null, // no aspect cap in expansion pass
364
+ 0, // seed bias off
365
+ null, // no aspect cap in expansion pass
309
366
  { width: p.rect.width, height: p.rect.height },
310
367
  )
311
368
 
@@ -326,13 +383,44 @@ export function stepExpansion(state: RectDiffState): void {
326
383
  }
327
384
 
328
385
  export function finalizeRects(state: RectDiffState): Rect3d[] {
329
- return state.placed.map((p) => ({
386
+ // Convert all placed (free space) nodes to output format
387
+ const out: Rect3d[] = state.placed.map((p) => ({
330
388
  minX: p.rect.x,
331
389
  minY: p.rect.y,
332
390
  maxX: p.rect.x + p.rect.width,
333
391
  maxY: p.rect.y + p.rect.height,
334
392
  zLayers: [...p.zLayers].sort((a, b) => a - b),
335
393
  }))
394
+
395
+ /**
396
+ * Recover obstacles as mesh nodes.
397
+ * Obstacles are stored per-layer in `obstaclesByLayer`, but we want to emit
398
+ * single 3D nodes for multi-layer obstacles if they share the same rect.
399
+ * We use the `XYRect` object reference identity to group layers.
400
+ */
401
+ const layersByObstacleRect = new Map<XYRect, number[]>()
402
+
403
+ state.obstaclesByLayer.forEach((layerObs, z) => {
404
+ for (const rect of layerObs) {
405
+ const layerIndices = layersByObstacleRect.get(rect) ?? []
406
+ layerIndices.push(z)
407
+ layersByObstacleRect.set(rect, layerIndices)
408
+ }
409
+ })
410
+
411
+ // Append obstacle nodes to the output
412
+ for (const [rect, layerIndices] of layersByObstacleRect.entries()) {
413
+ out.push({
414
+ minX: rect.x,
415
+ minY: rect.y,
416
+ maxX: rect.x + rect.width,
417
+ maxY: rect.y + rect.height,
418
+ zLayers: layerIndices.sort((a, b) => a - b),
419
+ isObstacle: true,
420
+ })
421
+ }
422
+
423
+ return out
336
424
  }
337
425
 
338
426
  /** Optional: rough progress number for BaseSolver.progress */