@tscircuit/rectdiff 0.0.4 → 0.0.5
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/dist/index.d.ts +112 -3
- package/dist/index.js +869 -142
- package/lib/solvers/RectDiffSolver.ts +125 -24
- package/lib/solvers/rectdiff/candidates.ts +150 -104
- package/lib/solvers/rectdiff/engine.ts +72 -53
- package/lib/solvers/rectdiff/gapfill/detection/deduplicateGaps.ts +28 -0
- package/lib/solvers/rectdiff/gapfill/detection/findAllGaps.ts +83 -0
- package/lib/solvers/rectdiff/gapfill/detection/findGapsOnLayer.ts +100 -0
- package/lib/solvers/rectdiff/gapfill/detection/mergeUncoveredCells.ts +75 -0
- package/lib/solvers/rectdiff/gapfill/detection.ts +3 -0
- package/lib/solvers/rectdiff/gapfill/engine/addPlacement.ts +27 -0
- package/lib/solvers/rectdiff/gapfill/engine/calculateCoverage.ts +44 -0
- package/lib/solvers/rectdiff/gapfill/engine/findUncoveredPoints.ts +43 -0
- package/lib/solvers/rectdiff/gapfill/engine/getGapFillProgress.ts +42 -0
- package/lib/solvers/rectdiff/gapfill/engine/initGapFillState.ts +57 -0
- package/lib/solvers/rectdiff/gapfill/engine/stepGapFill.ts +128 -0
- package/lib/solvers/rectdiff/gapfill/engine/tryExpandGap.ts +78 -0
- package/lib/solvers/rectdiff/gapfill/engine.ts +7 -0
- package/lib/solvers/rectdiff/gapfill/types.ts +60 -0
- package/lib/solvers/rectdiff/geometry.ts +23 -11
- package/lib/solvers/rectdiff/subsolvers/GapFillSubSolver.ts +253 -0
- package/lib/solvers/rectdiff/types.ts +1 -1
- package/package.json +1 -1
- package/tests/obstacle-extra-layers.test.ts +1 -1
- package/tests/obstacle-zlayers.test.ts +1 -1
- package/tests/rect-diff-solver.test.ts +1 -4
- package/utils/README.md +21 -0
- package/utils/rectsEqual.ts +18 -0
- package/utils/rectsOverlap.ts +18 -0
|
@@ -22,6 +22,9 @@ import {
|
|
|
22
22
|
} from "./geometry"
|
|
23
23
|
import { buildZIndexMap, obstacleToXYRect, obstacleZs } from "./layers"
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Initialize the RectDiff solver state from SimpleRouteJson.
|
|
27
|
+
*/
|
|
25
28
|
export function initState(
|
|
26
29
|
srj: SimpleRouteJson,
|
|
27
30
|
opts: Partial<GridFill3DOptions>,
|
|
@@ -106,7 +109,9 @@ export function initState(
|
|
|
106
109
|
}
|
|
107
110
|
}
|
|
108
111
|
|
|
109
|
-
|
|
112
|
+
/**
|
|
113
|
+
* Build per-layer list of "hard" placed rects (nodes spanning all layers).
|
|
114
|
+
*/
|
|
110
115
|
function buildHardPlacedByLayer(state: RectDiffState): XYRect[][] {
|
|
111
116
|
const out: XYRect[][] = Array.from({ length: state.layerCount }, () => [])
|
|
112
117
|
for (const p of state.placed) {
|
|
@@ -117,23 +122,27 @@ function buildHardPlacedByLayer(state: RectDiffState): XYRect[][] {
|
|
|
117
122
|
return out
|
|
118
123
|
}
|
|
119
124
|
|
|
125
|
+
/**
|
|
126
|
+
* Check if a point is occupied on ALL layers.
|
|
127
|
+
*/
|
|
120
128
|
function isFullyOccupiedAtPoint(
|
|
121
129
|
state: RectDiffState,
|
|
122
|
-
x: number,
|
|
123
|
-
y: number,
|
|
130
|
+
point: { x: number; y: number },
|
|
124
131
|
): boolean {
|
|
125
132
|
for (let z = 0; z < state.layerCount; z++) {
|
|
126
133
|
const obs = state.obstaclesByLayer[z] ?? []
|
|
127
134
|
const placed = state.placedByLayer[z] ?? []
|
|
128
135
|
const occ =
|
|
129
|
-
obs.some((b) => containsPoint(b, x, y)) ||
|
|
130
|
-
placed.some((b) => containsPoint(b, x, y))
|
|
136
|
+
obs.some((b) => containsPoint(b, point.x, point.y)) ||
|
|
137
|
+
placed.some((b) => containsPoint(b, point.x, point.y))
|
|
131
138
|
if (!occ) return false
|
|
132
139
|
}
|
|
133
140
|
return true
|
|
134
141
|
}
|
|
135
142
|
|
|
136
|
-
/**
|
|
143
|
+
/**
|
|
144
|
+
* Shrink/split any soft (non-full-stack) nodes overlapped by the newcomer.
|
|
145
|
+
*/
|
|
137
146
|
function resizeSoftOverlaps(state: RectDiffState, newIndex: number) {
|
|
138
147
|
const newcomer = state.placed[newIndex]!
|
|
139
148
|
const { rect: newR, zLayers: newZs } = newcomer
|
|
@@ -198,7 +207,9 @@ function resizeSoftOverlaps(state: RectDiffState, newIndex: number) {
|
|
|
198
207
|
}
|
|
199
208
|
}
|
|
200
209
|
|
|
201
|
-
/**
|
|
210
|
+
/**
|
|
211
|
+
* One micro-step during the GRID phase: handle exactly one candidate.
|
|
212
|
+
*/
|
|
202
213
|
export function stepGrid(state: RectDiffState): void {
|
|
203
214
|
const {
|
|
204
215
|
gridSizes,
|
|
@@ -216,14 +227,14 @@ export function stepGrid(state: RectDiffState): void {
|
|
|
216
227
|
|
|
217
228
|
// Ensure candidates exist for this grid
|
|
218
229
|
if (state.candidates.length === 0 && state.consumedSeedsThisGrid === 0) {
|
|
219
|
-
state.candidates = computeCandidates3D(
|
|
220
|
-
state.bounds,
|
|
221
|
-
grid,
|
|
222
|
-
state.layerCount,
|
|
223
|
-
state.obstaclesByLayer,
|
|
224
|
-
state.placedByLayer,
|
|
225
|
-
hardPlacedByLayer,
|
|
226
|
-
)
|
|
230
|
+
state.candidates = computeCandidates3D({
|
|
231
|
+
bounds: state.bounds,
|
|
232
|
+
gridSize: grid,
|
|
233
|
+
layerCount: state.layerCount,
|
|
234
|
+
obstaclesByLayer: state.obstaclesByLayer,
|
|
235
|
+
placedByLayer: state.placedByLayer,
|
|
236
|
+
hardPlacedByLayer,
|
|
237
|
+
})
|
|
227
238
|
state.totalSeedsThisGrid = state.candidates.length
|
|
228
239
|
state.consumedSeedsThisGrid = 0
|
|
229
240
|
}
|
|
@@ -238,14 +249,14 @@ export function stepGrid(state: RectDiffState): void {
|
|
|
238
249
|
} else {
|
|
239
250
|
if (!state.edgeAnalysisDone) {
|
|
240
251
|
const minSize = Math.min(minSingle.width, minSingle.height)
|
|
241
|
-
state.candidates = computeEdgeCandidates3D(
|
|
242
|
-
state.bounds,
|
|
252
|
+
state.candidates = computeEdgeCandidates3D({
|
|
253
|
+
bounds: state.bounds,
|
|
243
254
|
minSize,
|
|
244
|
-
state.layerCount,
|
|
245
|
-
state.obstaclesByLayer,
|
|
246
|
-
state.placedByLayer,
|
|
255
|
+
layerCount: state.layerCount,
|
|
256
|
+
obstaclesByLayer: state.obstaclesByLayer,
|
|
257
|
+
placedByLayer: state.placedByLayer,
|
|
247
258
|
hardPlacedByLayer,
|
|
248
|
-
)
|
|
259
|
+
})
|
|
249
260
|
state.edgeAnalysisDone = true
|
|
250
261
|
state.totalSeedsThisGrid = state.candidates.length
|
|
251
262
|
state.consumedSeedsThisGrid = 0
|
|
@@ -262,16 +273,16 @@ export function stepGrid(state: RectDiffState): void {
|
|
|
262
273
|
state.consumedSeedsThisGrid += 1
|
|
263
274
|
|
|
264
275
|
// Evaluate attempts — multi-layer span first (computed ignoring soft nodes)
|
|
265
|
-
const span = longestFreeSpanAroundZ(
|
|
266
|
-
cand.x,
|
|
267
|
-
cand.y,
|
|
268
|
-
cand.z,
|
|
269
|
-
state.layerCount,
|
|
270
|
-
minMulti.minLayers,
|
|
271
|
-
maxMultiLayerSpan,
|
|
272
|
-
state.obstaclesByLayer,
|
|
273
|
-
hardPlacedByLayer,
|
|
274
|
-
)
|
|
276
|
+
const span = longestFreeSpanAroundZ({
|
|
277
|
+
x: cand.x,
|
|
278
|
+
y: cand.y,
|
|
279
|
+
z: cand.z,
|
|
280
|
+
layerCount: state.layerCount,
|
|
281
|
+
minSpan: minMulti.minLayers,
|
|
282
|
+
maxSpan: maxMultiLayerSpan,
|
|
283
|
+
obstaclesByLayer: state.obstaclesByLayer,
|
|
284
|
+
placedByLayer: hardPlacedByLayer,
|
|
285
|
+
})
|
|
275
286
|
|
|
276
287
|
const attempts: Array<{
|
|
277
288
|
kind: "multi" | "single"
|
|
@@ -303,16 +314,16 @@ export function stepGrid(state: RectDiffState): void {
|
|
|
303
314
|
if (hardPlacedByLayer[z]) hardBlockers.push(...hardPlacedByLayer[z]!)
|
|
304
315
|
}
|
|
305
316
|
|
|
306
|
-
const rect = expandRectFromSeed(
|
|
307
|
-
cand.x,
|
|
308
|
-
cand.y,
|
|
309
|
-
grid,
|
|
310
|
-
state.bounds,
|
|
311
|
-
hardBlockers,
|
|
317
|
+
const rect = expandRectFromSeed({
|
|
318
|
+
startX: cand.x,
|
|
319
|
+
startY: cand.y,
|
|
320
|
+
gridSize: grid,
|
|
321
|
+
bounds: state.bounds,
|
|
322
|
+
blockers: hardBlockers,
|
|
312
323
|
initialCellRatio,
|
|
313
324
|
maxAspectRatio,
|
|
314
|
-
attempt.minReq,
|
|
315
|
-
)
|
|
325
|
+
minReq: attempt.minReq,
|
|
326
|
+
})
|
|
316
327
|
if (!rect) continue
|
|
317
328
|
|
|
318
329
|
// Place the new node
|
|
@@ -325,7 +336,7 @@ export function stepGrid(state: RectDiffState): void {
|
|
|
325
336
|
|
|
326
337
|
// New: relax candidate culling — only drop seeds that became fully occupied
|
|
327
338
|
state.candidates = state.candidates.filter(
|
|
328
|
-
(c) => !isFullyOccupiedAtPoint(state, c.x, c.y),
|
|
339
|
+
(c) => !isFullyOccupiedAtPoint(state, { x: c.x, y: c.y }),
|
|
329
340
|
)
|
|
330
341
|
|
|
331
342
|
return // processed one candidate
|
|
@@ -334,10 +345,13 @@ export function stepGrid(state: RectDiffState): void {
|
|
|
334
345
|
// Neither attempt worked; drop this candidate for now.
|
|
335
346
|
}
|
|
336
347
|
|
|
337
|
-
/**
|
|
348
|
+
/**
|
|
349
|
+
* One micro-step during the EXPANSION phase: expand exactly one placed rect.
|
|
350
|
+
*/
|
|
338
351
|
export function stepExpansion(state: RectDiffState): void {
|
|
339
352
|
if (state.expansionIndex >= state.placed.length) {
|
|
340
|
-
|
|
353
|
+
// Transition to gap fill phase instead of done
|
|
354
|
+
state.phase = "GAP_FILL"
|
|
341
355
|
return
|
|
342
356
|
}
|
|
343
357
|
|
|
@@ -355,16 +369,16 @@ export function stepExpansion(state: RectDiffState): void {
|
|
|
355
369
|
}
|
|
356
370
|
|
|
357
371
|
const oldRect = p.rect
|
|
358
|
-
const expanded = expandRectFromSeed(
|
|
359
|
-
p.rect.x + p.rect.width / 2,
|
|
360
|
-
p.rect.y + p.rect.height / 2,
|
|
361
|
-
lastGrid,
|
|
362
|
-
state.bounds,
|
|
363
|
-
hardBlockers,
|
|
364
|
-
0,
|
|
365
|
-
null,
|
|
366
|
-
{ width: p.rect.width, height: p.rect.height },
|
|
367
|
-
)
|
|
372
|
+
const expanded = expandRectFromSeed({
|
|
373
|
+
startX: p.rect.x + p.rect.width / 2,
|
|
374
|
+
startY: p.rect.y + p.rect.height / 2,
|
|
375
|
+
gridSize: lastGrid,
|
|
376
|
+
bounds: state.bounds,
|
|
377
|
+
blockers: hardBlockers,
|
|
378
|
+
initialCellRatio: 0,
|
|
379
|
+
maxAspectRatio: null,
|
|
380
|
+
minReq: { width: p.rect.width, height: p.rect.height },
|
|
381
|
+
})
|
|
368
382
|
|
|
369
383
|
if (expanded) {
|
|
370
384
|
// Update placement + per-layer index (replace old rect object)
|
|
@@ -382,6 +396,9 @@ export function stepExpansion(state: RectDiffState): void {
|
|
|
382
396
|
state.expansionIndex += 1
|
|
383
397
|
}
|
|
384
398
|
|
|
399
|
+
/**
|
|
400
|
+
* Finalize placed rectangles into output format.
|
|
401
|
+
*/
|
|
385
402
|
export function finalizeRects(state: RectDiffState): Rect3d[] {
|
|
386
403
|
// Convert all placed (free space) nodes to output format
|
|
387
404
|
const out: Rect3d[] = state.placed.map((p) => ({
|
|
@@ -423,7 +440,9 @@ export function finalizeRects(state: RectDiffState): Rect3d[] {
|
|
|
423
440
|
return out
|
|
424
441
|
}
|
|
425
442
|
|
|
426
|
-
/**
|
|
443
|
+
/**
|
|
444
|
+
* Calculate rough progress number for BaseSolver.progress.
|
|
445
|
+
*/
|
|
427
446
|
export function computeProgress(state: RectDiffState): number {
|
|
428
447
|
const grids = state.options.gridSizes.length
|
|
429
448
|
if (state.phase === "GRID") {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// lib/solvers/rectdiff/gapfill/detection/deduplicateGaps.ts
|
|
2
|
+
import { rectsEqual } from "../../../../../utils/rectsEqual"
|
|
3
|
+
import { rectsOverlap } from "../../../../../utils/rectsOverlap"
|
|
4
|
+
import type { GapRegion } from "../types"
|
|
5
|
+
|
|
6
|
+
export function deduplicateGaps(gaps: GapRegion[]): GapRegion[] {
|
|
7
|
+
const result: GapRegion[] = []
|
|
8
|
+
|
|
9
|
+
for (const gap of gaps) {
|
|
10
|
+
// Check if we already have a gap at the same location with overlapping layers
|
|
11
|
+
const existing = result.find(
|
|
12
|
+
(g) =>
|
|
13
|
+
rectsEqual(g.rect, gap.rect) ||
|
|
14
|
+
(rectsOverlap(g.rect, gap.rect) &&
|
|
15
|
+
gap.zLayers.some((z) => g.zLayers.includes(z))),
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
if (!existing) {
|
|
19
|
+
result.push(gap)
|
|
20
|
+
} else if (gap.zLayers.length > existing.zLayers.length) {
|
|
21
|
+
// Replace with the one that has more layers
|
|
22
|
+
const idx = result.indexOf(existing)
|
|
23
|
+
result[idx] = gap
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return result
|
|
28
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// lib/solvers/rectdiff/gapfill/detection/findAllGaps.ts
|
|
2
|
+
import type { XYRect } from "../../types"
|
|
3
|
+
import type { GapRegion, LayerContext } from "../types"
|
|
4
|
+
import { EPS } from "../../geometry"
|
|
5
|
+
import { findGapsOnLayer } from "./findGapsOnLayer"
|
|
6
|
+
import { rectsOverlap } from "../../../../../utils/rectsOverlap"
|
|
7
|
+
import { deduplicateGaps } from "./deduplicateGaps"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Find gaps across all layers and return GapRegions with z-layer info.
|
|
11
|
+
*/
|
|
12
|
+
export function findAllGaps(
|
|
13
|
+
{
|
|
14
|
+
scanResolution,
|
|
15
|
+
minWidth,
|
|
16
|
+
minHeight,
|
|
17
|
+
}: {
|
|
18
|
+
scanResolution: number
|
|
19
|
+
minWidth: number
|
|
20
|
+
minHeight: number
|
|
21
|
+
},
|
|
22
|
+
ctx: LayerContext,
|
|
23
|
+
): GapRegion[] {
|
|
24
|
+
const { bounds, layerCount, obstaclesByLayer, placedByLayer } = ctx
|
|
25
|
+
|
|
26
|
+
// Find gaps on each layer
|
|
27
|
+
const gapsByLayer: XYRect[][] = []
|
|
28
|
+
for (let z = 0; z < layerCount; z++) {
|
|
29
|
+
const obstacles = obstaclesByLayer[z] ?? []
|
|
30
|
+
const placed = placedByLayer[z] ?? []
|
|
31
|
+
const gaps = findGapsOnLayer({ bounds, obstacles, placed, scanResolution })
|
|
32
|
+
gapsByLayer.push(gaps)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Convert to GapRegions with z-layer info
|
|
36
|
+
const allGaps: GapRegion[] = []
|
|
37
|
+
|
|
38
|
+
for (let z = 0; z < layerCount; z++) {
|
|
39
|
+
for (const gap of gapsByLayer[z]!) {
|
|
40
|
+
// Filter out gaps that are too small
|
|
41
|
+
if (gap.width < minWidth - EPS || gap.height < minHeight - EPS) continue
|
|
42
|
+
|
|
43
|
+
// Check if this gap exists on adjacent layers too
|
|
44
|
+
const zLayers = [z]
|
|
45
|
+
|
|
46
|
+
// Look up
|
|
47
|
+
for (let zu = z + 1; zu < layerCount; zu++) {
|
|
48
|
+
const hasOverlap = gapsByLayer[zu]!.some((g) => rectsOverlap(g, gap))
|
|
49
|
+
if (hasOverlap) zLayers.push(zu)
|
|
50
|
+
else break
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Look down (if z > 0 and not already counted)
|
|
54
|
+
for (let zd = z - 1; zd >= 0; zd--) {
|
|
55
|
+
const hasOverlap = gapsByLayer[zd]!.some((g) => rectsOverlap(g, gap))
|
|
56
|
+
if (hasOverlap && !zLayers.includes(zd)) zLayers.unshift(zd)
|
|
57
|
+
else break
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
allGaps.push({
|
|
61
|
+
rect: gap,
|
|
62
|
+
zLayers: zLayers.sort((a, b) => a - b),
|
|
63
|
+
centerX: gap.x + gap.width / 2,
|
|
64
|
+
centerY: gap.y + gap.height / 2,
|
|
65
|
+
area: gap.width * gap.height,
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Deduplicate gaps that are essentially the same across layers
|
|
71
|
+
const deduped = deduplicateGaps(allGaps)
|
|
72
|
+
|
|
73
|
+
// Sort by priority: prefer larger gaps and multi-layer gaps
|
|
74
|
+
deduped.sort((a, b) => {
|
|
75
|
+
// Prefer multi-layer gaps
|
|
76
|
+
const layerDiff = b.zLayers.length - a.zLayers.length
|
|
77
|
+
if (layerDiff !== 0) return layerDiff
|
|
78
|
+
// Then prefer larger area
|
|
79
|
+
return b.area - a.area
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
return deduped
|
|
83
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// lib/solvers/rectdiff/gapfill/detection/findGapsOnLayer.ts
|
|
2
|
+
import type { XYRect } from "../../types"
|
|
3
|
+
import { EPS } from "../../geometry"
|
|
4
|
+
|
|
5
|
+
import { mergeUncoveredCells } from "./mergeUncoveredCells"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Sweep-line algorithm to find maximal uncovered rectangles on a single layer.
|
|
9
|
+
*/
|
|
10
|
+
export function findGapsOnLayer({
|
|
11
|
+
bounds,
|
|
12
|
+
obstacles,
|
|
13
|
+
placed,
|
|
14
|
+
scanResolution,
|
|
15
|
+
}: {
|
|
16
|
+
bounds: XYRect
|
|
17
|
+
obstacles: XYRect[]
|
|
18
|
+
placed: XYRect[]
|
|
19
|
+
scanResolution: number
|
|
20
|
+
}): XYRect[] {
|
|
21
|
+
const blockers = [...obstacles, ...placed]
|
|
22
|
+
|
|
23
|
+
// Collect all unique x-coordinates
|
|
24
|
+
const xCoords = new Set<number>()
|
|
25
|
+
xCoords.add(bounds.x)
|
|
26
|
+
xCoords.add(bounds.x + bounds.width)
|
|
27
|
+
|
|
28
|
+
for (const b of blockers) {
|
|
29
|
+
if (b.x > bounds.x && b.x < bounds.x + bounds.width) {
|
|
30
|
+
xCoords.add(b.x)
|
|
31
|
+
}
|
|
32
|
+
if (b.x + b.width > bounds.x && b.x + b.width < bounds.x + bounds.width) {
|
|
33
|
+
xCoords.add(b.x + b.width)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Also add intermediate points based on scan resolution
|
|
38
|
+
for (let x = bounds.x; x <= bounds.x + bounds.width; x += scanResolution) {
|
|
39
|
+
xCoords.add(x)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const sortedX = Array.from(xCoords).sort((a, b) => a - b)
|
|
43
|
+
|
|
44
|
+
// Similarly for y-coordinates
|
|
45
|
+
const yCoords = new Set<number>()
|
|
46
|
+
yCoords.add(bounds.y)
|
|
47
|
+
yCoords.add(bounds.y + bounds.height)
|
|
48
|
+
|
|
49
|
+
for (const b of blockers) {
|
|
50
|
+
if (b.y > bounds.y && b.y < bounds.y + bounds.height) {
|
|
51
|
+
yCoords.add(b.y)
|
|
52
|
+
}
|
|
53
|
+
if (
|
|
54
|
+
b.y + b.height > bounds.y &&
|
|
55
|
+
b.y + b.height < bounds.y + bounds.height
|
|
56
|
+
) {
|
|
57
|
+
yCoords.add(b.y + b.height)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
for (let y = bounds.y; y <= bounds.y + bounds.height; y += scanResolution) {
|
|
62
|
+
yCoords.add(y)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const sortedY = Array.from(yCoords).sort((a, b) => a - b)
|
|
66
|
+
|
|
67
|
+
// Build a grid of cells and mark which are uncovered
|
|
68
|
+
const uncoveredCells: Array<{ x: number; y: number; w: number; h: number }> =
|
|
69
|
+
[]
|
|
70
|
+
|
|
71
|
+
for (let i = 0; i < sortedX.length - 1; i++) {
|
|
72
|
+
for (let j = 0; j < sortedY.length - 1; j++) {
|
|
73
|
+
const cellX = sortedX[i]!
|
|
74
|
+
const cellY = sortedY[j]!
|
|
75
|
+
const cellW = sortedX[i + 1]! - cellX
|
|
76
|
+
const cellH = sortedY[j + 1]! - cellY
|
|
77
|
+
|
|
78
|
+
if (cellW <= EPS || cellH <= EPS) continue
|
|
79
|
+
|
|
80
|
+
// Check if this cell is covered by any blocker
|
|
81
|
+
const cellCenterX = cellX + cellW / 2
|
|
82
|
+
const cellCenterY = cellY + cellH / 2
|
|
83
|
+
|
|
84
|
+
const isCovered = blockers.some(
|
|
85
|
+
(b) =>
|
|
86
|
+
cellCenterX >= b.x - EPS &&
|
|
87
|
+
cellCenterX <= b.x + b.width + EPS &&
|
|
88
|
+
cellCenterY >= b.y - EPS &&
|
|
89
|
+
cellCenterY <= b.y + b.height + EPS,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
if (!isCovered) {
|
|
93
|
+
uncoveredCells.push({ x: cellX, y: cellY, w: cellW, h: cellH })
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Merge adjacent uncovered cells into maximal rectangles
|
|
99
|
+
return mergeUncoveredCells(uncoveredCells)
|
|
100
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// lib/solvers/rectdiff/gapfill/detection/mergeUncoveredCells.ts
|
|
2
|
+
import type { XYRect } from "../../types"
|
|
3
|
+
import { EPS } from "../../geometry"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Merge adjacent uncovered cells into larger rectangles using a greedy approach.
|
|
7
|
+
*/
|
|
8
|
+
export function mergeUncoveredCells(
|
|
9
|
+
cells: Array<{ x: number; y: number; w: number; h: number }>,
|
|
10
|
+
): XYRect[] {
|
|
11
|
+
if (cells.length === 0) return []
|
|
12
|
+
|
|
13
|
+
// Group cells by their left edge and width
|
|
14
|
+
const byXW = new Map<string, typeof cells>()
|
|
15
|
+
for (const c of cells) {
|
|
16
|
+
const key = `${c.x.toFixed(9)}|${c.w.toFixed(9)}`
|
|
17
|
+
const arr = byXW.get(key) ?? []
|
|
18
|
+
arr.push(c)
|
|
19
|
+
byXW.set(key, arr)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Within each vertical strip, merge adjacent cells
|
|
23
|
+
const verticalStrips: XYRect[] = []
|
|
24
|
+
for (const stripCells of byXW.values()) {
|
|
25
|
+
// Sort by y
|
|
26
|
+
stripCells.sort((a, b) => a.y - b.y)
|
|
27
|
+
|
|
28
|
+
let current: XYRect | null = null
|
|
29
|
+
for (const c of stripCells) {
|
|
30
|
+
if (!current) {
|
|
31
|
+
current = { x: c.x, y: c.y, width: c.w, height: c.h }
|
|
32
|
+
} else if (Math.abs(current.y + current.height - c.y) < EPS) {
|
|
33
|
+
// Adjacent vertically, merge
|
|
34
|
+
current.height += c.h
|
|
35
|
+
} else {
|
|
36
|
+
// Gap, save current and start new
|
|
37
|
+
verticalStrips.push(current)
|
|
38
|
+
current = { x: c.x, y: c.y, width: c.w, height: c.h }
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (current) verticalStrips.push(current)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Now try to merge horizontal strips with same y and height
|
|
45
|
+
const byYH = new Map<string, XYRect[]>()
|
|
46
|
+
for (const r of verticalStrips) {
|
|
47
|
+
const key = `${r.y.toFixed(9)}|${r.height.toFixed(9)}`
|
|
48
|
+
const arr = byYH.get(key) ?? []
|
|
49
|
+
arr.push(r)
|
|
50
|
+
byYH.set(key, arr)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const merged: XYRect[] = []
|
|
54
|
+
for (const rowRects of byYH.values()) {
|
|
55
|
+
// Sort by x
|
|
56
|
+
rowRects.sort((a, b) => a.x - b.x)
|
|
57
|
+
|
|
58
|
+
let current: XYRect | null = null
|
|
59
|
+
for (const r of rowRects) {
|
|
60
|
+
if (!current) {
|
|
61
|
+
current = { ...r }
|
|
62
|
+
} else if (Math.abs(current.x + current.width - r.x) < EPS) {
|
|
63
|
+
// Adjacent horizontally, merge
|
|
64
|
+
current.width += r.width
|
|
65
|
+
} else {
|
|
66
|
+
// Gap, save current and start new
|
|
67
|
+
merged.push(current)
|
|
68
|
+
current = { ...r }
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (current) merged.push(current)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return merged
|
|
75
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// lib/solvers/rectdiff/gapfill/engine/addPlacement.ts
|
|
2
|
+
import type { Placed3D, XYRect } from "../../types"
|
|
3
|
+
import type { GapFillState } from "../types"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Add a new placement to the state.
|
|
7
|
+
*/
|
|
8
|
+
export function addPlacement(
|
|
9
|
+
state: GapFillState,
|
|
10
|
+
{
|
|
11
|
+
rect,
|
|
12
|
+
zLayers,
|
|
13
|
+
}: {
|
|
14
|
+
rect: XYRect
|
|
15
|
+
zLayers: number[]
|
|
16
|
+
},
|
|
17
|
+
): void {
|
|
18
|
+
const placed: Placed3D = { rect, zLayers: [...zLayers] }
|
|
19
|
+
state.placed.push(placed)
|
|
20
|
+
|
|
21
|
+
for (const z of zLayers) {
|
|
22
|
+
if (!state.placedByLayer[z]) {
|
|
23
|
+
state.placedByLayer[z] = []
|
|
24
|
+
}
|
|
25
|
+
state.placedByLayer[z]!.push(rect)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// lib/solvers/rectdiff/gapfill/engine/calculateCoverage.ts
|
|
2
|
+
import type { LayerContext } from "../types"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Calculate coverage percentage (0-1).
|
|
6
|
+
*/
|
|
7
|
+
export function calculateCoverage(
|
|
8
|
+
{ sampleResolution = 0.1 }: { sampleResolution?: number },
|
|
9
|
+
ctx: LayerContext,
|
|
10
|
+
): number {
|
|
11
|
+
const { bounds, layerCount, obstaclesByLayer, placedByLayer } = ctx
|
|
12
|
+
|
|
13
|
+
let totalPoints = 0
|
|
14
|
+
let coveredPoints = 0
|
|
15
|
+
|
|
16
|
+
for (let z = 0; z < layerCount; z++) {
|
|
17
|
+
const obstacles = obstaclesByLayer[z] ?? []
|
|
18
|
+
const placed = placedByLayer[z] ?? []
|
|
19
|
+
const allRects = [...obstacles, ...placed]
|
|
20
|
+
|
|
21
|
+
for (
|
|
22
|
+
let x = bounds.x;
|
|
23
|
+
x <= bounds.x + bounds.width;
|
|
24
|
+
x += sampleResolution
|
|
25
|
+
) {
|
|
26
|
+
for (
|
|
27
|
+
let y = bounds.y;
|
|
28
|
+
y <= bounds.y + bounds.height;
|
|
29
|
+
y += sampleResolution
|
|
30
|
+
) {
|
|
31
|
+
totalPoints++
|
|
32
|
+
|
|
33
|
+
const isCovered = allRects.some(
|
|
34
|
+
(r) =>
|
|
35
|
+
x >= r.x && x <= r.x + r.width && y >= r.y && y <= r.y + r.height,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
if (isCovered) coveredPoints++
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return totalPoints > 0 ? coveredPoints / totalPoints : 1
|
|
44
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// lib/solvers/rectdiff/gapfill/engine/findUncoveredPoints.ts
|
|
2
|
+
import type { LayerContext } from "../types"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Find uncovered points for debugging gaps.
|
|
6
|
+
*/
|
|
7
|
+
export function findUncoveredPoints(
|
|
8
|
+
{ sampleResolution = 0.05 }: { sampleResolution?: number },
|
|
9
|
+
ctx: LayerContext,
|
|
10
|
+
): Array<{ x: number; y: number; z: number }> {
|
|
11
|
+
const { bounds, layerCount, obstaclesByLayer, placedByLayer } = ctx
|
|
12
|
+
|
|
13
|
+
const uncovered: Array<{ x: number; y: number; z: number }> = []
|
|
14
|
+
|
|
15
|
+
for (let z = 0; z < layerCount; z++) {
|
|
16
|
+
const obstacles = obstaclesByLayer[z] ?? []
|
|
17
|
+
const placed = placedByLayer[z] ?? []
|
|
18
|
+
const allRects = [...obstacles, ...placed]
|
|
19
|
+
|
|
20
|
+
for (
|
|
21
|
+
let x = bounds.x;
|
|
22
|
+
x <= bounds.x + bounds.width;
|
|
23
|
+
x += sampleResolution
|
|
24
|
+
) {
|
|
25
|
+
for (
|
|
26
|
+
let y = bounds.y;
|
|
27
|
+
y <= bounds.y + bounds.height;
|
|
28
|
+
y += sampleResolution
|
|
29
|
+
) {
|
|
30
|
+
const isCovered = allRects.some(
|
|
31
|
+
(r) =>
|
|
32
|
+
x >= r.x && x <= r.x + r.width && y >= r.y && y <= r.y + r.height,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
if (!isCovered) {
|
|
36
|
+
uncovered.push({ x, y, z })
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return uncovered
|
|
43
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// lib/solvers/rectdiff/gapfill/engine/getGapFillProgress.ts
|
|
2
|
+
import type { GapFillState } from "../types"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Get progress as a number between 0 and 1.
|
|
6
|
+
* Accounts for four-stage processing (scan → select → expand → place for each gap).
|
|
7
|
+
*/
|
|
8
|
+
export function getGapFillProgress(state: GapFillState): number {
|
|
9
|
+
if (state.done) return 1
|
|
10
|
+
|
|
11
|
+
const iterationProgress = state.iteration / state.options.maxIterations
|
|
12
|
+
const gapProgress =
|
|
13
|
+
state.gapsFound.length > 0 ? state.gapIndex / state.gapsFound.length : 0
|
|
14
|
+
|
|
15
|
+
// Add sub-progress within current gap based on stage
|
|
16
|
+
let stageProgress = 0
|
|
17
|
+
switch (state.stage) {
|
|
18
|
+
case "scan":
|
|
19
|
+
stageProgress = 0
|
|
20
|
+
break
|
|
21
|
+
case "select":
|
|
22
|
+
stageProgress = 0.25
|
|
23
|
+
break
|
|
24
|
+
case "expand":
|
|
25
|
+
stageProgress = 0.5
|
|
26
|
+
break
|
|
27
|
+
case "place":
|
|
28
|
+
stageProgress = 0.75
|
|
29
|
+
break
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const gapStageProgress =
|
|
33
|
+
state.gapsFound.length > 0
|
|
34
|
+
? stageProgress / (state.gapsFound.length * 4) // 4 stages per gap
|
|
35
|
+
: 0
|
|
36
|
+
|
|
37
|
+
return Math.min(
|
|
38
|
+
0.999,
|
|
39
|
+
iterationProgress +
|
|
40
|
+
(gapProgress + gapStageProgress) / state.options.maxIterations,
|
|
41
|
+
)
|
|
42
|
+
}
|