@tscircuit/rectdiff 0.0.3 → 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.
Files changed (32) hide show
  1. package/dist/index.d.ts +112 -3
  2. package/dist/index.js +869 -142
  3. package/index.html +2 -2
  4. package/lib/solvers/RectDiffSolver.ts +125 -24
  5. package/lib/solvers/rectdiff/candidates.ts +150 -104
  6. package/lib/solvers/rectdiff/engine.ts +72 -53
  7. package/lib/solvers/rectdiff/gapfill/detection/deduplicateGaps.ts +28 -0
  8. package/lib/solvers/rectdiff/gapfill/detection/findAllGaps.ts +83 -0
  9. package/lib/solvers/rectdiff/gapfill/detection/findGapsOnLayer.ts +100 -0
  10. package/lib/solvers/rectdiff/gapfill/detection/mergeUncoveredCells.ts +75 -0
  11. package/lib/solvers/rectdiff/gapfill/detection.ts +3 -0
  12. package/lib/solvers/rectdiff/gapfill/engine/addPlacement.ts +27 -0
  13. package/lib/solvers/rectdiff/gapfill/engine/calculateCoverage.ts +44 -0
  14. package/lib/solvers/rectdiff/gapfill/engine/findUncoveredPoints.ts +43 -0
  15. package/lib/solvers/rectdiff/gapfill/engine/getGapFillProgress.ts +42 -0
  16. package/lib/solvers/rectdiff/gapfill/engine/initGapFillState.ts +57 -0
  17. package/lib/solvers/rectdiff/gapfill/engine/stepGapFill.ts +128 -0
  18. package/lib/solvers/rectdiff/gapfill/engine/tryExpandGap.ts +78 -0
  19. package/lib/solvers/rectdiff/gapfill/engine.ts +7 -0
  20. package/lib/solvers/rectdiff/gapfill/types.ts +60 -0
  21. package/lib/solvers/rectdiff/geometry.ts +23 -11
  22. package/lib/solvers/rectdiff/subsolvers/GapFillSubSolver.ts +253 -0
  23. package/lib/solvers/rectdiff/types.ts +1 -1
  24. package/main.tsx +1 -0
  25. package/package.json +11 -8
  26. package/tests/obstacle-extra-layers.test.ts +1 -1
  27. package/tests/obstacle-zlayers.test.ts +1 -1
  28. package/tests/rect-diff-solver.test.ts +1 -4
  29. package/utils/README.md +21 -0
  30. package/utils/rectsEqual.ts +18 -0
  31. package/utils/rectsOverlap.ts +18 -0
  32. package/vite.config.ts +7 -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
- // Build per-layer list of "hard" placed rects = nodes spanning all layers
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
- /** Shrink/split any *soft* (non-full-stack) nodes overlapped by `newIndex` */
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
- /** One micro-step during the GRID phase: handle (or fetch) exactly one candidate */
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, // all nodes (soft + hard) for fully-occupied test
225
- hardPlacedByLayer, // hard blockers for ranking/span
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, // for fully-occupied test
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, // ignore soft nodes for span
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, // soft nodes DO NOT block expansion
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
- /** One micro-step during the EXPANSION phase: expand exactly one placed rect */
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
- state.phase = "DONE"
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, // seed bias off
365
- null, // no aspect cap in expansion pass
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
- /** Optional: rough progress number for BaseSolver.progress */
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,3 @@
1
+ // lib/solvers/rectdiff/gapfill/detection.ts
2
+ export * from "./detection/findAllGaps"
3
+ // findGapsOnLayer is not exported as it's only used by findAllGaps
@@ -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
+ }