@tscircuit/rectdiff 0.0.1

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 (40) hide show
  1. package/.claude/settings.local.json +9 -0
  2. package/.github/workflows/bun-formatcheck.yml +26 -0
  3. package/.github/workflows/bun-pver-release.yml +71 -0
  4. package/.github/workflows/bun-test.yml +31 -0
  5. package/.github/workflows/bun-typecheck.yml +26 -0
  6. package/CLAUDE.md +23 -0
  7. package/README.md +5 -0
  8. package/biome.json +93 -0
  9. package/bun.lock +29 -0
  10. package/bunfig.toml +5 -0
  11. package/components/SolverDebugger3d.tsx +833 -0
  12. package/cosmos.config.json +6 -0
  13. package/cosmos.decorator.tsx +21 -0
  14. package/dist/index.d.ts +111 -0
  15. package/dist/index.js +921 -0
  16. package/experiments/rect-fill-2d.tsx +983 -0
  17. package/experiments/rect3d_visualizer.html +640 -0
  18. package/global.d.ts +4 -0
  19. package/index.html +12 -0
  20. package/lib/index.ts +1 -0
  21. package/lib/solvers/RectDiffSolver.ts +158 -0
  22. package/lib/solvers/rectdiff/candidates.ts +397 -0
  23. package/lib/solvers/rectdiff/engine.ts +355 -0
  24. package/lib/solvers/rectdiff/geometry.ts +284 -0
  25. package/lib/solvers/rectdiff/layers.ts +48 -0
  26. package/lib/solvers/rectdiff/rectsToMeshNodes.ts +22 -0
  27. package/lib/solvers/rectdiff/types.ts +63 -0
  28. package/lib/types/capacity-mesh-types.ts +33 -0
  29. package/lib/types/srj-types.ts +37 -0
  30. package/package.json +33 -0
  31. package/pages/example01.page.tsx +11 -0
  32. package/test-assets/example01.json +933 -0
  33. package/tests/__snapshots__/svg.snap.svg +3 -0
  34. package/tests/examples/__snapshots__/example01.snap.svg +121 -0
  35. package/tests/examples/example01.test.tsx +65 -0
  36. package/tests/fixtures/preload.ts +1 -0
  37. package/tests/incremental-solver.test.ts +100 -0
  38. package/tests/rect-diff-solver.test.ts +154 -0
  39. package/tests/svg.test.ts +12 -0
  40. package/tsconfig.json +30 -0
@@ -0,0 +1,983 @@
1
+ // @ts-nocheck
2
+ import React, { useState, useEffect, useRef } from "react"
3
+ import { Play, Square, SkipForward } from "lucide-react"
4
+
5
+ const RectFillVisualizer = () => {
6
+ const canvasRef = useRef(null)
7
+
8
+ const GRID_PROGRESSION = [100, 50, 20]
9
+ const MIN_RECT_SIZE_IN_CELL_RATIO = 0.2
10
+
11
+ const [maxRatio, setMaxRatio] = useState(2)
12
+ const [isRunning, setIsRunning] = useState(false)
13
+ const [currentStep, setCurrentStep] = useState(0)
14
+ const [fillRects, setFillRects] = useState([])
15
+ const [candidatePoints, setCandidatePoints] = useState([])
16
+ const [expansionPhase, setExpansionPhase] = useState(false)
17
+ const [expansionIndex, setExpansionIndex] = useState(0)
18
+ const [gapFillingPhase, setGapFillingPhase] = useState(false)
19
+ const [gapCandidates, setGapCandidates] = useState([])
20
+ const [currentGridIndex, setCurrentGridIndex] = useState(0)
21
+ const [currentGridSize, setCurrentGridSize] = useState(GRID_PROGRESSION[0])
22
+
23
+ // Example problem setup
24
+ const outerBorder = { x: 50, y: 50, width: 700, height: 500 }
25
+ const obstacles = [
26
+ { x: 150, y: 100, width: 100, height: 80 },
27
+ { x: 400, y: 150, width: 120, height: 100 },
28
+ { x: 250, y: 300, width: 80, height: 120 },
29
+ { x: 600, y: 250, width: 100, height: 150 },
30
+ ]
31
+
32
+ const pointToLineSegmentDistance = (px, py, x1, y1, x2, y2) => {
33
+ const A = px - x1
34
+ const B = py - y1
35
+ const C = x2 - x1
36
+ const D = y2 - y1
37
+
38
+ const dot = A * C + B * D
39
+ const lenSq = C * C + D * D
40
+ let param = -1
41
+
42
+ if (lenSq !== 0) param = dot / lenSq
43
+
44
+ let xx, yy
45
+
46
+ if (param < 0) {
47
+ xx = x1
48
+ yy = y1
49
+ } else if (param > 1) {
50
+ xx = x2
51
+ yy = y2
52
+ } else {
53
+ xx = x1 + param * C
54
+ yy = y1 + param * D
55
+ }
56
+
57
+ const dx = px - xx
58
+ const dy = py - yy
59
+ return Math.sqrt(dx * dx + dy * dy)
60
+ }
61
+
62
+ const distanceToRect = (px, py, rect) => {
63
+ const edges = [
64
+ [rect.x, rect.y, rect.x + rect.width, rect.y],
65
+ [rect.x + rect.width, rect.y, rect.x + rect.width, rect.y + rect.height],
66
+ [rect.x + rect.width, rect.y + rect.height, rect.x, rect.y + rect.height],
67
+ [rect.x, rect.y + rect.height, rect.x, rect.y],
68
+ ]
69
+
70
+ return Math.min(
71
+ ...edges.map(([x1, y1, x2, y2]) =>
72
+ pointToLineSegmentDistance(px, py, x1, y1, x2, y2),
73
+ ),
74
+ )
75
+ }
76
+
77
+ const isPointInRect = (px, py, rect) => {
78
+ return (
79
+ px >= rect.x &&
80
+ px <= rect.x + rect.width &&
81
+ py >= rect.y &&
82
+ py <= rect.y + rect.height
83
+ )
84
+ }
85
+
86
+ const rectOverlaps = (r1, r2) => {
87
+ return !(
88
+ r1.x + r1.width <= r2.x ||
89
+ r2.x + r2.width <= r1.x ||
90
+ r1.y + r1.height <= r2.y ||
91
+ r2.y + r2.height <= r1.y
92
+ )
93
+ }
94
+
95
+ const computeCandidatePoints = (gridSize, existingRects = []) => {
96
+ const points = []
97
+ const allBlockers = [...obstacles, ...existingRects]
98
+
99
+ for (
100
+ let x = outerBorder.x;
101
+ x < outerBorder.x + outerBorder.width;
102
+ x += gridSize
103
+ ) {
104
+ for (
105
+ let y = outerBorder.y;
106
+ y < outerBorder.y + outerBorder.height;
107
+ y += gridSize
108
+ ) {
109
+ // Skip points along the border edges
110
+ if (
111
+ x === outerBorder.x ||
112
+ y === outerBorder.y ||
113
+ x >= outerBorder.x + outerBorder.width - gridSize ||
114
+ y >= outerBorder.y + outerBorder.height - gridSize
115
+ ) {
116
+ continue
117
+ }
118
+
119
+ // Check if point is inside any blocker
120
+ let insideBlocker = false
121
+
122
+ for (const blocker of allBlockers) {
123
+ if (isPointInRect(x, y, blocker)) {
124
+ insideBlocker = true
125
+ const remainingHeight = blocker.y + blocker.height - y
126
+ const skipAmount = Math.max(
127
+ 0,
128
+ Math.floor(remainingHeight / gridSize),
129
+ )
130
+ if (skipAmount > 0) {
131
+ y += (skipAmount - 1) * gridSize
132
+ }
133
+ break
134
+ }
135
+ }
136
+
137
+ if (insideBlocker) {
138
+ continue
139
+ }
140
+
141
+ let minDist = distanceToRect(x, y, outerBorder)
142
+
143
+ for (const obs of obstacles) {
144
+ const dist = distanceToRect(x, y, obs)
145
+ minDist = Math.min(minDist, dist)
146
+ }
147
+
148
+ points.push({ x, y, distance: minDist })
149
+ }
150
+ }
151
+
152
+ return points.sort((a, b) => b.distance - a.distance)
153
+ }
154
+
155
+ const findGapCandidates = (rects) => {
156
+ const allBlockers = [...obstacles, ...rects]
157
+ const gapPoints = []
158
+ const sampleStep = 10
159
+
160
+ rects.forEach((rect, rectIdx) => {
161
+ const edges = [
162
+ {
163
+ name: "right",
164
+ points: () => {
165
+ const pts = []
166
+ for (let y = rect.y; y <= rect.y + rect.height; y += sampleStep) {
167
+ pts.push({
168
+ x: rect.x + rect.width + 1,
169
+ y: Math.min(y, rect.y + rect.height),
170
+ })
171
+ }
172
+ return pts
173
+ },
174
+ },
175
+ {
176
+ name: "bottom",
177
+ points: () => {
178
+ const pts = []
179
+ for (let x = rect.x; x <= rect.x + rect.width; x += sampleStep) {
180
+ pts.push({
181
+ x: Math.min(x, rect.x + rect.width),
182
+ y: rect.y + rect.height + 1,
183
+ })
184
+ }
185
+ return pts
186
+ },
187
+ },
188
+ {
189
+ name: "left",
190
+ points: () => {
191
+ const pts = []
192
+ for (let y = rect.y; y <= rect.y + rect.height; y += sampleStep) {
193
+ pts.push({ x: rect.x - 1, y: Math.min(y, rect.y + rect.height) })
194
+ }
195
+ return pts
196
+ },
197
+ },
198
+ {
199
+ name: "top",
200
+ points: () => {
201
+ const pts = []
202
+ for (let x = rect.x; x <= rect.x + rect.width; x += sampleStep) {
203
+ pts.push({ x: Math.min(x, rect.x + rect.width), y: rect.y - 1 })
204
+ }
205
+ return pts
206
+ },
207
+ },
208
+ ]
209
+
210
+ edges.forEach((edge) => {
211
+ const edgePoints = edge.points()
212
+
213
+ edgePoints.forEach((point) => {
214
+ if (
215
+ point.x < outerBorder.x ||
216
+ point.x > outerBorder.x + outerBorder.width ||
217
+ point.y < outerBorder.y ||
218
+ point.y > outerBorder.y + outerBorder.height
219
+ ) {
220
+ return
221
+ }
222
+
223
+ let isOccupied = false
224
+ for (const blocker of allBlockers) {
225
+ if (blocker === rect) continue
226
+ if (isPointInRect(point.x, point.y, blocker)) {
227
+ isOccupied = true
228
+ break
229
+ }
230
+ }
231
+
232
+ if (!isOccupied) {
233
+ gapPoints.push({ x: point.x, y: point.y, sourceRect: rectIdx })
234
+ }
235
+ })
236
+ })
237
+ })
238
+
239
+ return gapPoints
240
+ }
241
+
242
+ const expandExistingRect = (existingRect, existingRects = []) => {
243
+ let rect = { ...existingRect }
244
+ const allBlockers = [...obstacles, ...existingRects]
245
+
246
+ let improved = true
247
+
248
+ while (improved) {
249
+ improved = false
250
+
251
+ const maxRight = outerBorder.x + outerBorder.width - (rect.x + rect.width)
252
+ if (maxRight > 0) {
253
+ let bestExpansion = 0
254
+
255
+ for (let expand = 1; expand <= maxRight; expand++) {
256
+ let testRect = { ...rect, width: rect.width + expand }
257
+
258
+ let hasCollision = false
259
+ for (const blocker of allBlockers) {
260
+ if (rectOverlaps(testRect, blocker)) {
261
+ const maxWidth = blocker.x - rect.x
262
+ if (maxWidth > rect.width) {
263
+ bestExpansion = maxWidth - rect.width
264
+ }
265
+ hasCollision = true
266
+ break
267
+ }
268
+ }
269
+
270
+ if (hasCollision) break
271
+ bestExpansion = expand
272
+ }
273
+
274
+ if (bestExpansion > 0) {
275
+ rect.width += bestExpansion
276
+ improved = true
277
+ }
278
+ }
279
+
280
+ const maxDown =
281
+ outerBorder.y + outerBorder.height - (rect.y + rect.height)
282
+ if (maxDown > 0) {
283
+ let bestExpansion = 0
284
+
285
+ for (let expand = 1; expand <= maxDown; expand++) {
286
+ let testRect = { ...rect, height: rect.height + expand }
287
+
288
+ let hasCollision = false
289
+ for (const blocker of allBlockers) {
290
+ if (rectOverlaps(testRect, blocker)) {
291
+ const maxHeight = blocker.y - rect.y
292
+ if (maxHeight > rect.height) {
293
+ bestExpansion = maxHeight - rect.height
294
+ }
295
+ hasCollision = true
296
+ break
297
+ }
298
+ }
299
+
300
+ if (hasCollision) break
301
+ bestExpansion = expand
302
+ }
303
+
304
+ if (bestExpansion > 0) {
305
+ rect.height += bestExpansion
306
+ improved = true
307
+ }
308
+ }
309
+
310
+ const maxLeft = rect.x - outerBorder.x
311
+ if (maxLeft > 0) {
312
+ let bestExpansion = 0
313
+
314
+ for (let expand = 1; expand <= maxLeft; expand++) {
315
+ let testRect = {
316
+ x: rect.x - expand,
317
+ y: rect.y,
318
+ width: rect.width + expand,
319
+ height: rect.height,
320
+ }
321
+
322
+ let hasCollision = false
323
+ for (const blocker of allBlockers) {
324
+ if (rectOverlaps(testRect, blocker)) {
325
+ const newLeft = blocker.x + blocker.width
326
+ if (newLeft < rect.x) {
327
+ bestExpansion = rect.x - newLeft
328
+ }
329
+ hasCollision = true
330
+ break
331
+ }
332
+ }
333
+
334
+ if (hasCollision) break
335
+ bestExpansion = expand
336
+ }
337
+
338
+ if (bestExpansion > 0) {
339
+ rect.x -= bestExpansion
340
+ rect.width += bestExpansion
341
+ improved = true
342
+ }
343
+ }
344
+
345
+ const maxUp = rect.y - outerBorder.y
346
+ if (maxUp > 0) {
347
+ let bestExpansion = 0
348
+
349
+ for (let expand = 1; expand <= maxUp; expand++) {
350
+ let testRect = {
351
+ x: rect.x,
352
+ y: rect.y - expand,
353
+ width: rect.width,
354
+ height: rect.height + expand,
355
+ }
356
+
357
+ let hasCollision = false
358
+ for (const blocker of allBlockers) {
359
+ if (rectOverlaps(testRect, blocker)) {
360
+ const newTop = blocker.y + blocker.height
361
+ if (newTop < rect.y) {
362
+ bestExpansion = rect.y - newTop
363
+ }
364
+ hasCollision = true
365
+ break
366
+ }
367
+ }
368
+
369
+ if (hasCollision) break
370
+ bestExpansion = expand
371
+ }
372
+
373
+ if (bestExpansion > 0) {
374
+ rect.y -= bestExpansion
375
+ rect.height += bestExpansion
376
+ improved = true
377
+ }
378
+ }
379
+ }
380
+
381
+ return rect
382
+ }
383
+
384
+ const expandRect = (
385
+ startX,
386
+ startY,
387
+ gridSize,
388
+ maxRatio,
389
+ existingRects = [],
390
+ ) => {
391
+ const minSize = Math.max(1, gridSize * MIN_RECT_SIZE_IN_CELL_RATIO)
392
+
393
+ const strategies = [
394
+ { startOffsetX: 0, startOffsetY: 0 },
395
+ { startOffsetX: -minSize, startOffsetY: 0 },
396
+ { startOffsetX: 0, startOffsetY: -minSize },
397
+ { startOffsetX: -minSize, startOffsetY: -minSize },
398
+ { startOffsetX: -minSize / 2, startOffsetY: -minSize / 2 },
399
+ ]
400
+
401
+ let bestRect = null
402
+ let bestArea = 0
403
+
404
+ for (const strategy of strategies) {
405
+ let rect = {
406
+ x: startX + strategy.startOffsetX,
407
+ y: startY + strategy.startOffsetY,
408
+ width: minSize,
409
+ height: minSize,
410
+ }
411
+
412
+ if (
413
+ rect.x < outerBorder.x ||
414
+ rect.y < outerBorder.y ||
415
+ rect.x + rect.width > outerBorder.x + outerBorder.width ||
416
+ rect.y + rect.height > outerBorder.y + outerBorder.height
417
+ ) {
418
+ continue
419
+ }
420
+
421
+ let hasOverlap =
422
+ obstacles.some((obs) => rectOverlaps(rect, obs)) ||
423
+ existingRects.some((fr) => rectOverlaps(rect, fr))
424
+
425
+ if (hasOverlap) continue
426
+
427
+ const allBlockers = [...obstacles, ...existingRects]
428
+
429
+ let improved = true
430
+
431
+ while (improved) {
432
+ improved = false
433
+
434
+ const maxRight =
435
+ outerBorder.x + outerBorder.width - (rect.x + rect.width)
436
+ if (maxRight > 0) {
437
+ let bestExpansion = 0
438
+
439
+ for (let expand = 1; expand <= maxRight; expand++) {
440
+ let testRect = { ...rect, width: rect.width + expand }
441
+
442
+ if (maxRatio !== null && maxRatio !== undefined) {
443
+ const ratio = Math.max(
444
+ testRect.width / testRect.height,
445
+ testRect.height / testRect.width,
446
+ )
447
+ if (ratio > maxRatio) break
448
+ }
449
+
450
+ let hasCollision = false
451
+ for (const blocker of allBlockers) {
452
+ if (rectOverlaps(testRect, blocker)) {
453
+ const maxWidth = blocker.x - rect.x
454
+ if (maxWidth > rect.width) {
455
+ bestExpansion = maxWidth - rect.width
456
+ }
457
+ hasCollision = true
458
+ break
459
+ }
460
+ }
461
+
462
+ if (hasCollision) break
463
+ bestExpansion = expand
464
+ }
465
+
466
+ if (bestExpansion > 0) {
467
+ rect.width += bestExpansion
468
+ improved = true
469
+ }
470
+ }
471
+
472
+ const maxDown =
473
+ outerBorder.y + outerBorder.height - (rect.y + rect.height)
474
+ if (maxDown > 0) {
475
+ let bestExpansion = 0
476
+
477
+ for (let expand = 1; expand <= maxDown; expand++) {
478
+ let testRect = { ...rect, height: rect.height + expand }
479
+
480
+ if (maxRatio !== null && maxRatio !== undefined) {
481
+ const ratio = Math.max(
482
+ testRect.width / testRect.height,
483
+ testRect.height / testRect.width,
484
+ )
485
+ if (ratio > maxRatio) break
486
+ }
487
+
488
+ let hasCollision = false
489
+ for (const blocker of allBlockers) {
490
+ if (rectOverlaps(testRect, blocker)) {
491
+ const maxHeight = blocker.y - rect.y
492
+ if (maxHeight > rect.height) {
493
+ bestExpansion = maxHeight - rect.height
494
+ }
495
+ hasCollision = true
496
+ break
497
+ }
498
+ }
499
+
500
+ if (hasCollision) break
501
+ bestExpansion = expand
502
+ }
503
+
504
+ if (bestExpansion > 0) {
505
+ rect.height += bestExpansion
506
+ improved = true
507
+ }
508
+ }
509
+
510
+ const maxLeft = rect.x - outerBorder.x
511
+ if (maxLeft > 0) {
512
+ let bestExpansion = 0
513
+
514
+ for (let expand = 1; expand <= maxLeft; expand++) {
515
+ let testRect = {
516
+ x: rect.x - expand,
517
+ y: rect.y,
518
+ width: rect.width + expand,
519
+ height: rect.height,
520
+ }
521
+
522
+ if (maxRatio !== null && maxRatio !== undefined) {
523
+ const ratio = Math.max(
524
+ testRect.width / testRect.height,
525
+ testRect.height / testRect.width,
526
+ )
527
+ if (ratio > maxRatio) break
528
+ }
529
+
530
+ let hasCollision = false
531
+ for (const blocker of allBlockers) {
532
+ if (rectOverlaps(testRect, blocker)) {
533
+ const newLeft = blocker.x + blocker.width
534
+ if (newLeft < rect.x) {
535
+ bestExpansion = rect.x - newLeft
536
+ }
537
+ hasCollision = true
538
+ break
539
+ }
540
+ }
541
+
542
+ if (hasCollision) break
543
+ bestExpansion = expand
544
+ }
545
+
546
+ if (bestExpansion > 0) {
547
+ rect.x -= bestExpansion
548
+ rect.width += bestExpansion
549
+ improved = true
550
+ }
551
+ }
552
+
553
+ const maxUp = rect.y - outerBorder.y
554
+ if (maxUp > 0) {
555
+ let bestExpansion = 0
556
+
557
+ for (let expand = 1; expand <= maxUp; expand++) {
558
+ let testRect = {
559
+ x: rect.x,
560
+ y: rect.y - expand,
561
+ width: rect.width,
562
+ height: rect.height + expand,
563
+ }
564
+
565
+ if (maxRatio !== null && maxRatio !== undefined) {
566
+ const ratio = Math.max(
567
+ testRect.width / testRect.height,
568
+ testRect.height / testRect.width,
569
+ )
570
+ if (ratio > maxRatio) break
571
+ }
572
+
573
+ let hasCollision = false
574
+ for (const blocker of allBlockers) {
575
+ if (rectOverlaps(testRect, blocker)) {
576
+ const newTop = blocker.y + blocker.height
577
+ if (newTop < rect.y) {
578
+ bestExpansion = rect.y - newTop
579
+ }
580
+ hasCollision = true
581
+ break
582
+ }
583
+ }
584
+
585
+ if (hasCollision) break
586
+ bestExpansion = expand
587
+ }
588
+
589
+ if (bestExpansion > 0) {
590
+ rect.y -= bestExpansion
591
+ rect.height += bestExpansion
592
+ improved = true
593
+ }
594
+ }
595
+ }
596
+
597
+ const area = rect.width * rect.height
598
+ if (area > bestArea) {
599
+ bestArea = area
600
+ bestRect = rect
601
+ }
602
+ }
603
+
604
+ return bestRect
605
+ }
606
+
607
+ const step = () => {
608
+ if (!expansionPhase && !gapFillingPhase && candidatePoints.length === 0) {
609
+ if (currentGridIndex < GRID_PROGRESSION.length - 1) {
610
+ const nextGridIndex = currentGridIndex + 1
611
+ const nextGridSize = GRID_PROGRESSION[nextGridIndex]
612
+ setCurrentGridIndex(nextGridIndex)
613
+ setCurrentGridSize(nextGridSize)
614
+ const newCandidates = computeCandidatePoints(nextGridSize, fillRects)
615
+ setCandidatePoints(newCandidates)
616
+ return
617
+ } else {
618
+ setExpansionPhase(true)
619
+ setExpansionIndex(0)
620
+ return
621
+ }
622
+ }
623
+
624
+ if (gapFillingPhase) {
625
+ if (gapCandidates.length === 0) return
626
+
627
+ const point = gapCandidates[0]
628
+ const newRect = expandRect(point.x, point.y, 5, null, fillRects)
629
+
630
+ if (newRect) {
631
+ const newFillRects = [...fillRects, newRect]
632
+ setFillRects(newFillRects)
633
+
634
+ const remainingGaps = gapCandidates.filter(
635
+ (p) => !isPointInRect(p.x, p.y, newRect),
636
+ )
637
+ setGapCandidates(remainingGaps)
638
+ } else {
639
+ setGapCandidates(gapCandidates.slice(1))
640
+ }
641
+
642
+ setCurrentStep(currentStep + 1)
643
+ } else if (expansionPhase) {
644
+ if (expansionIndex >= fillRects.length) {
645
+ const gaps = findGapCandidates(fillRects)
646
+ setGapCandidates(gaps)
647
+ setGapFillingPhase(true)
648
+ setExpansionPhase(false)
649
+ return
650
+ }
651
+
652
+ const rectToExpand = fillRects[expansionIndex]
653
+ const otherRects = fillRects.filter((_, i) => i !== expansionIndex)
654
+
655
+ const expandedRect = expandExistingRect(rectToExpand, otherRects)
656
+
657
+ if (expandedRect) {
658
+ const newFillRects = [...fillRects]
659
+ newFillRects[expansionIndex] = expandedRect
660
+ setFillRects(newFillRects)
661
+ }
662
+
663
+ setExpansionIndex(expansionIndex + 1)
664
+ } else {
665
+ const point = candidatePoints[0]
666
+ const newRect = expandRect(
667
+ point.x,
668
+ point.y,
669
+ currentGridSize,
670
+ maxRatio,
671
+ fillRects,
672
+ )
673
+
674
+ if (newRect) {
675
+ const newFillRects = [...fillRects, newRect]
676
+ setFillRects(newFillRects)
677
+
678
+ const remainingCandidates = candidatePoints.filter(
679
+ (p) => !isPointInRect(p.x, p.y, newRect),
680
+ )
681
+ setCandidatePoints(remainingCandidates)
682
+ } else {
683
+ setCandidatePoints(candidatePoints.slice(1))
684
+ }
685
+
686
+ setCurrentStep(currentStep + 1)
687
+ }
688
+ }
689
+
690
+ const runAll = () => {
691
+ setIsRunning(true)
692
+ let rects = [...fillRects]
693
+ let steps = currentStep
694
+
695
+ for (
696
+ let gridIdx = currentGridIndex;
697
+ gridIdx < GRID_PROGRESSION.length;
698
+ gridIdx++
699
+ ) {
700
+ const gridSize = GRID_PROGRESSION[gridIdx]
701
+ let remainingCandidates = computeCandidatePoints(gridSize, rects)
702
+
703
+ while (remainingCandidates.length > 0) {
704
+ const point = remainingCandidates[0]
705
+ const newRect = expandRect(point.x, point.y, gridSize, maxRatio, rects)
706
+
707
+ if (newRect) {
708
+ rects.push(newRect)
709
+
710
+ remainingCandidates = remainingCandidates.filter(
711
+ (p) => !isPointInRect(p.x, p.y, newRect),
712
+ )
713
+ } else {
714
+ remainingCandidates = remainingCandidates.slice(1)
715
+ }
716
+
717
+ steps++
718
+ }
719
+ }
720
+
721
+ for (let i = 0; i < rects.length; i++) {
722
+ const rectToExpand = rects[i]
723
+ const otherRects = rects.filter((_, idx) => idx !== i)
724
+ const expandedRect = expandExistingRect(rectToExpand, otherRects)
725
+ if (expandedRect) {
726
+ rects[i] = expandedRect
727
+ }
728
+ }
729
+
730
+ let gaps = findGapCandidates(rects)
731
+ while (gaps.length > 0) {
732
+ const point = gaps[0]
733
+ const newRect = expandRect(point.x, point.y, 5, null, rects)
734
+
735
+ if (newRect) {
736
+ rects.push(newRect)
737
+ gaps = gaps.filter((p) => !isPointInRect(p.x, p.y, newRect))
738
+ } else {
739
+ gaps = gaps.slice(1)
740
+ }
741
+
742
+ steps++
743
+
744
+ if (steps > currentStep + 1000) break
745
+ }
746
+
747
+ setFillRects(rects)
748
+ setCandidatePoints([])
749
+ setGapCandidates([])
750
+ setCurrentStep(steps)
751
+ setCurrentGridIndex(GRID_PROGRESSION.length - 1)
752
+ setCurrentGridSize(GRID_PROGRESSION[GRID_PROGRESSION.length - 1])
753
+ setExpansionPhase(false)
754
+ setGapFillingPhase(true)
755
+ setExpansionIndex(rects.length)
756
+ setIsRunning(false)
757
+ }
758
+
759
+ const reset = () => {
760
+ setIsRunning(false)
761
+ setCurrentStep(0)
762
+ setFillRects([])
763
+ setExpansionPhase(false)
764
+ setGapFillingPhase(false)
765
+ setExpansionIndex(0)
766
+ setGapCandidates([])
767
+ setCurrentGridIndex(0)
768
+ const firstGridSize = GRID_PROGRESSION[0]
769
+ setCurrentGridSize(firstGridSize)
770
+ const points = computeCandidatePoints(firstGridSize, [])
771
+ setCandidatePoints(points)
772
+ }
773
+
774
+ useEffect(() => {
775
+ reset()
776
+ }, [maxRatio])
777
+
778
+ useEffect(() => {
779
+ const canvas = canvasRef.current
780
+ if (!canvas) return
781
+
782
+ const ctx = canvas.getContext("2d")
783
+ ctx.clearRect(0, 0, canvas.width, canvas.height)
784
+
785
+ ctx.strokeStyle = "#22c55e"
786
+ ctx.lineWidth = 3
787
+ ctx.strokeRect(
788
+ outerBorder.x,
789
+ outerBorder.y,
790
+ outerBorder.width,
791
+ outerBorder.height,
792
+ )
793
+
794
+ ctx.fillStyle = "#ef4444"
795
+ obstacles.forEach((obs) => {
796
+ ctx.fillRect(obs.x, obs.y, obs.width, obs.height)
797
+ })
798
+
799
+ if (!expansionPhase && !gapFillingPhase && candidatePoints.length > 0) {
800
+ ctx.fillStyle = "rgba(156, 163, 175, 0.3)"
801
+ candidatePoints.forEach((point) => {
802
+ ctx.fillRect(point.x - 1, point.y - 1, 2, 2)
803
+ })
804
+ }
805
+
806
+ fillRects.forEach((rect, idx) => {
807
+ if (expansionPhase && idx === expansionIndex - 1) {
808
+ ctx.fillStyle = "rgba(251, 191, 36, 0.5)"
809
+ ctx.strokeStyle = "#fbbf24"
810
+ ctx.lineWidth = 2
811
+ } else {
812
+ ctx.fillStyle = "rgba(59, 130, 246, 0.5)"
813
+ ctx.strokeStyle = "#3b82f6"
814
+ ctx.lineWidth = 1
815
+ }
816
+ ctx.fillRect(rect.x, rect.y, rect.width, rect.height)
817
+ ctx.strokeRect(rect.x, rect.y, rect.width, rect.height)
818
+ })
819
+
820
+ if (gapFillingPhase && gapCandidates.length > 0) {
821
+ ctx.fillStyle = "#ec4899"
822
+ gapCandidates.forEach((point) => {
823
+ ctx.beginPath()
824
+ ctx.arc(point.x, point.y, 3, 0, 2 * Math.PI)
825
+ ctx.fill()
826
+ })
827
+ }
828
+
829
+ if (!expansionPhase && !gapFillingPhase && candidatePoints.length > 0) {
830
+ const point = candidatePoints[0]
831
+ ctx.fillStyle = "#fbbf24"
832
+ ctx.beginPath()
833
+ ctx.arc(point.x, point.y, 5, 0, 2 * Math.PI)
834
+ ctx.fill()
835
+ }
836
+ }, [
837
+ fillRects,
838
+ currentStep,
839
+ candidatePoints,
840
+ expansionPhase,
841
+ expansionIndex,
842
+ currentGridSize,
843
+ gapFillingPhase,
844
+ gapCandidates,
845
+ ])
846
+
847
+ return (
848
+ <div className="w-full h-full bg-gray-900 text-white p-6">
849
+ <div className="max-w-6xl mx-auto">
850
+ <h1 className="text-3xl font-bold mb-2">
851
+ Space-Filling Rectangle Algorithm
852
+ </h1>
853
+ <p className="text-gray-400 mb-6">
854
+ Fills space with rectangles while avoiding obstacles using progressive
855
+ grid refinement (100px → 50px → 20px). Points furthest from obstacles
856
+ are prioritized. After all grids are processed, rectangles expand
857
+ without ratio constraints. Finally, edge gaps are identified and
858
+ filled with additional rectangles.
859
+ </p>
860
+
861
+ <div className="bg-gray-800 rounded-lg p-6 mb-4">
862
+ <canvas
863
+ ref={canvasRef}
864
+ width={800}
865
+ height={600}
866
+ className="border border-gray-700 rounded"
867
+ />
868
+ </div>
869
+
870
+ <div className="grid grid-cols-1 gap-4 mb-4">
871
+ <div className="bg-gray-800 rounded-lg p-4">
872
+ <label className="block text-sm font-medium mb-2">
873
+ Max Dimension Ratio: {maxRatio.toFixed(1)}
874
+ </label>
875
+ <input
876
+ type="range"
877
+ min="1"
878
+ max="5"
879
+ step="0.1"
880
+ value={maxRatio}
881
+ onChange={(e) => setMaxRatio(parseFloat(e.target.value))}
882
+ className="w-full"
883
+ />
884
+ </div>
885
+ </div>
886
+
887
+ <div className="flex gap-3">
888
+ <button
889
+ onClick={step}
890
+ disabled={gapFillingPhase && gapCandidates.length === 0}
891
+ className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-600 px-4 py-2 rounded-lg transition"
892
+ >
893
+ <SkipForward size={20} />
894
+ Step
895
+ </button>
896
+
897
+ <button
898
+ onClick={runAll}
899
+ disabled={isRunning}
900
+ className="flex items-center gap-2 bg-green-600 hover:bg-green-700 disabled:bg-gray-600 px-4 py-2 rounded-lg transition"
901
+ >
902
+ <Play size={20} />
903
+ Run All
904
+ </button>
905
+
906
+ <button
907
+ onClick={reset}
908
+ className="flex items-center gap-2 bg-gray-600 hover:bg-gray-700 px-4 py-2 rounded-lg transition"
909
+ >
910
+ <Square size={20} />
911
+ Reset
912
+ </button>
913
+
914
+ <div className="ml-auto bg-gray-800 px-4 py-2 rounded-lg">
915
+ <span className="text-gray-400">Grid:</span> {currentGridSize}px
916
+ <span className="ml-4 text-gray-400">Phase:</span>{" "}
917
+ {gapFillingPhase
918
+ ? "Gap Filling"
919
+ : expansionPhase
920
+ ? "Expansion"
921
+ : `Candidates (${currentGridIndex + 1}/${GRID_PROGRESSION.length})`}
922
+ {!expansionPhase && !gapFillingPhase && (
923
+ <>
924
+ <span className="ml-4 text-gray-400">Remaining:</span>{" "}
925
+ {candidatePoints.length}
926
+ </>
927
+ )}
928
+ {expansionPhase && (
929
+ <>
930
+ <span className="ml-4 text-gray-400">Expanding:</span>{" "}
931
+ {expansionIndex}/{fillRects.length}
932
+ </>
933
+ )}
934
+ {gapFillingPhase && (
935
+ <>
936
+ <span className="ml-4 text-gray-400">Gaps:</span>{" "}
937
+ {gapCandidates.length}
938
+ </>
939
+ )}
940
+ <span className="ml-4 text-gray-400">Rectangles:</span>{" "}
941
+ {fillRects.length}
942
+ </div>
943
+ </div>
944
+
945
+ <div className="mt-4 bg-gray-800 rounded-lg p-4">
946
+ <h3 className="font-semibold mb-2">Legend:</h3>
947
+ <div className="flex gap-6 flex-wrap">
948
+ <div className="flex items-center gap-2">
949
+ <div className="w-4 h-4 border-2 border-green-500"></div>
950
+ <span>Outer Border</span>
951
+ </div>
952
+ <div className="flex items-center gap-2">
953
+ <div className="w-4 h-4 bg-red-500"></div>
954
+ <span>Obstacles</span>
955
+ </div>
956
+ <div className="flex items-center gap-2">
957
+ <div className="w-2 h-2 bg-gray-400 opacity-30"></div>
958
+ <span>Grid Candidates</span>
959
+ </div>
960
+ <div className="flex items-center gap-2">
961
+ <div className="w-4 h-4 bg-blue-500 opacity-50"></div>
962
+ <span>Fill Rectangles</span>
963
+ </div>
964
+ <div className="flex items-center gap-2">
965
+ <div className="w-3 h-3 bg-yellow-400 rounded-full"></div>
966
+ <span>Next Candidate</span>
967
+ </div>
968
+ <div className="flex items-center gap-2">
969
+ <div className="w-4 h-4 bg-yellow-400 opacity-50 border border-yellow-400"></div>
970
+ <span>Expanding Rectangle</span>
971
+ </div>
972
+ <div className="flex items-center gap-2">
973
+ <div className="w-3 h-3 bg-pink-500 rounded-full"></div>
974
+ <span>Gap Candidates</span>
975
+ </div>
976
+ </div>
977
+ </div>
978
+ </div>
979
+ </div>
980
+ )
981
+ }
982
+
983
+ export default RectFillVisualizer