@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.
- package/.github/workflows/bun-formatcheck.yml +1 -1
- package/.github/workflows/bun-pver-release.yml +2 -2
- package/.github/workflows/bun-test.yml +1 -1
- package/.github/workflows/bun-typecheck.yml +1 -1
- package/README.md +139 -0
- package/dist/index.js +195 -37
- package/global.d.ts +2 -2
- package/lib/solvers/RectDiffSolver.ts +15 -3
- package/lib/solvers/rectdiff/candidates.ts +158 -43
- package/lib/solvers/rectdiff/engine.ts +108 -29
- package/lib/solvers/rectdiff/geometry.ts +81 -28
- package/lib/solvers/rectdiff/layers.ts +11 -3
- package/lib/solvers/rectdiff/rectsToMeshNodes.ts +3 -0
- package/lib/solvers/rectdiff/types.ts +4 -1
- package/package.json +6 -2
- package/pages/bugreport11.page.tsx +16 -0
- package/pages/example01.page.tsx +1 -1
- package/test-assets/bugreport11-b2de3c.json +4315 -0
- package/tests/examples/example01.test.tsx +2 -2
- package/tests/fixtures/preload.ts +1 -1
- package/tests/rect-diff-solver.test.ts +6 -5
- package/tests/svg.test.ts +10 -11
- package/.claude/settings.local.json +0 -9
- package/bun.lock +0 -29
|
@@ -25,8 +25,8 @@ export function computeCandidates3D(
|
|
|
25
25
|
gridSize: number,
|
|
26
26
|
layerCount: number,
|
|
27
27
|
obstaclesByLayer: XYRect[][],
|
|
28
|
-
placedByLayer: XYRect[][],
|
|
29
|
-
hardPlacedByLayer: XYRect[][],
|
|
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 (
|
|
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,
|
|
67
|
+
undefined, // no cap here
|
|
59
68
|
obstaclesByLayer,
|
|
60
|
-
hardPlacedByLayer,
|
|
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) =>
|
|
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 = [
|
|
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[][],
|
|
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) =>
|
|
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(
|
|
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 ||
|
|
233
|
-
|
|
234
|
-
|
|
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
|
|
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(
|
|
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 = [
|
|
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 => ({
|
|
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(
|
|
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 => ({
|
|
296
|
-
|
|
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 => ({
|
|
313
|
-
|
|
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 => ({
|
|
330
|
-
|
|
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(
|
|
349
|
-
|
|
350
|
-
|
|
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 (
|
|
436
|
+
if (
|
|
437
|
+
obRightX > bounds.x + EPS &&
|
|
438
|
+
obRightX < bounds.x + bounds.width - EPS
|
|
439
|
+
) {
|
|
359
440
|
const obRightCovering = blockers
|
|
360
|
-
.filter(
|
|
361
|
-
|
|
362
|
-
|
|
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(
|
|
373
|
-
|
|
374
|
-
|
|
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 (
|
|
483
|
+
if (
|
|
484
|
+
obBottomY > bounds.y + EPS &&
|
|
485
|
+
obBottomY < bounds.y + bounds.height - EPS
|
|
486
|
+
) {
|
|
383
487
|
const obBottomCovering = blockers
|
|
384
|
-
.filter(
|
|
385
|
-
|
|
386
|
-
|
|
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) =>
|
|
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 {
|
|
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 {
|
|
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(
|
|
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(
|
|
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)
|
|
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<
|
|
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 = {
|
|
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(
|
|
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(
|
|
130
|
-
|
|
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
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
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,
|
|
179
|
-
hardPlacedByLayer,
|
|
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,
|
|
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({
|
|
274
|
+
attempts.push({
|
|
275
|
+
kind: "multi",
|
|
276
|
+
layers: span,
|
|
277
|
+
minReq: { width: minMulti.width, height: minMulti.height },
|
|
278
|
+
})
|
|
238
279
|
}
|
|
239
|
-
attempts.push({
|
|
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])
|
|
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,
|
|
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(
|
|
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,
|
|
308
|
-
null,
|
|
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
|
-
|
|
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 */
|