@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.
- 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 +240 -42
- 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 +117 -29
- package/lib/solvers/rectdiff/geometry.ts +81 -28
- package/lib/solvers/rectdiff/layers.ts +44 -8
- 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/obstacle-extra-layers.test.ts +37 -0
- package/tests/obstacle-zlayers.test.ts +37 -0
- 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
|
@@ -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 {
|
|
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[]): {
|
|
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: {
|
|
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[][],
|
|
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,31 @@ 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
|
-
|
|
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<
|
|
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 = {
|
|
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(
|
|
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(
|
|
130
|
-
|
|
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
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
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,
|
|
179
|
-
hardPlacedByLayer,
|
|
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,
|
|
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({
|
|
283
|
+
attempts.push({
|
|
284
|
+
kind: "multi",
|
|
285
|
+
layers: span,
|
|
286
|
+
minReq: { width: minMulti.width, height: minMulti.height },
|
|
287
|
+
})
|
|
238
288
|
}
|
|
239
|
-
attempts.push({
|
|
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])
|
|
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,
|
|
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(
|
|
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,
|
|
308
|
-
null,
|
|
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
|
-
|
|
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 */
|