@tscircuit/rectdiff 0.0.12 → 0.0.13

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 +163 -27
  2. package/dist/index.js +1884 -1675
  3. package/lib/RectDiffPipeline.ts +18 -17
  4. package/lib/{solvers/rectdiff/types.ts → rectdiff-types.ts} +0 -34
  5. package/lib/{solvers/rectdiff/visualization.ts → rectdiff-visualization.ts} +2 -1
  6. package/lib/solvers/GapFillSolver/FindSegmentsWithAdjacentEmptySpaceSolver.ts +9 -12
  7. package/lib/solvers/GapFillSolver/visuallyOffsetLine.ts +5 -2
  8. package/lib/solvers/RectDiffExpansionSolver/RectDiffExpansionSolver.ts +205 -0
  9. package/lib/solvers/{rectdiff → RectDiffExpansionSolver}/rectsToMeshNodes.ts +1 -2
  10. package/lib/solvers/RectDiffGridSolverPipeline/RectDiffGridSolverPipeline.ts +87 -0
  11. package/lib/solvers/RectDiffSeedingSolver/RectDiffSeedingSolver.ts +516 -0
  12. package/lib/solvers/RectDiffSeedingSolver/computeCandidates3D.ts +109 -0
  13. package/lib/solvers/RectDiffSeedingSolver/computeDefaultGridSizes.ts +9 -0
  14. package/lib/solvers/{rectdiff/candidates.ts → RectDiffSeedingSolver/computeEdgeCandidates3D.ts} +39 -220
  15. package/lib/solvers/{rectdiff/geometry → RectDiffSeedingSolver}/computeInverseRects.ts +6 -2
  16. package/lib/solvers/{rectdiff → RectDiffSeedingSolver}/layers.ts +4 -3
  17. package/lib/solvers/RectDiffSeedingSolver/longestFreeSpanAroundZ.ts +52 -0
  18. package/lib/utils/buildHardPlacedByLayer.ts +14 -0
  19. package/lib/{solvers/rectdiff/geometry.ts → utils/expandRectFromSeed.ts} +21 -120
  20. package/lib/utils/finalizeRects.ts +49 -0
  21. package/lib/utils/isFullyOccupiedAtPoint.ts +21 -0
  22. package/lib/utils/rectdiff-geometry.ts +94 -0
  23. package/lib/utils/resizeSoftOverlaps.ts +74 -0
  24. package/package.json +1 -1
  25. package/tests/board-outline.test.ts +2 -1
  26. package/tests/obstacle-extra-layers.test.ts +1 -1
  27. package/tests/obstacle-zlayers.test.ts +1 -1
  28. package/utils/rectsEqual.ts +2 -2
  29. package/utils/rectsOverlap.ts +2 -2
  30. package/lib/solvers/RectDiffSolver.ts +0 -231
  31. package/lib/solvers/rectdiff/engine.ts +0 -481
  32. /package/lib/solvers/{rectdiff/geometry → RectDiffSeedingSolver}/isPointInPolygon.ts +0 -0
@@ -1,190 +1,7 @@
1
- // lib/solvers/rectdiff/candidates.ts
2
- import type { Candidate3D, XYRect } from "./types"
3
- import { EPS, clamp, containsPoint, distancePointToRectEdges } from "./geometry"
4
-
5
- /**
6
- * Check if a point is occupied on all layers.
7
- */
8
- function isFullyOccupiedAllLayers(params: {
9
- x: number
10
- y: number
11
- layerCount: number
12
- obstaclesByLayer: XYRect[][]
13
- placedByLayer: XYRect[][]
14
- }): boolean {
15
- const { x, y, layerCount, obstaclesByLayer, placedByLayer } = params
16
- for (let z = 0; z < layerCount; z++) {
17
- const obs = obstaclesByLayer[z] ?? []
18
- const placed = placedByLayer[z] ?? []
19
- const occ =
20
- obs.some((b) => containsPoint(b, x, y)) ||
21
- placed.some((b) => containsPoint(b, x, y))
22
- if (!occ) return false
23
- }
24
- return true
25
- }
26
-
27
- /**
28
- * Compute candidate seed points for a given grid size.
29
- */
30
- export function computeCandidates3D(params: {
31
- bounds: XYRect
32
- gridSize: number
33
- layerCount: number
34
- obstaclesByLayer: XYRect[][]
35
- placedByLayer: XYRect[][]
36
- hardPlacedByLayer: XYRect[][]
37
- }): Candidate3D[] {
38
- const {
39
- bounds,
40
- gridSize,
41
- layerCount,
42
- obstaclesByLayer,
43
- placedByLayer,
44
- hardPlacedByLayer,
45
- } = params
46
- const out = new Map<string, Candidate3D>() // key by (x,y)
47
-
48
- for (let x = bounds.x; x < bounds.x + bounds.width; x += gridSize) {
49
- for (let y = bounds.y; y < bounds.y + bounds.height; y += gridSize) {
50
- // Skip outermost row/col (stable with prior behavior)
51
- if (
52
- Math.abs(x - bounds.x) < EPS ||
53
- Math.abs(y - bounds.y) < EPS ||
54
- x > bounds.x + bounds.width - gridSize - EPS ||
55
- y > bounds.y + bounds.height - gridSize - EPS
56
- ) {
57
- continue
58
- }
59
-
60
- // New rule: Only drop if EVERY layer is occupied (by obstacle or node)
61
- if (
62
- isFullyOccupiedAllLayers({
63
- x,
64
- y,
65
- layerCount,
66
- obstaclesByLayer,
67
- placedByLayer,
68
- })
69
- )
70
- continue
71
-
72
- // Find the best (longest) free contiguous Z span at (x,y) ignoring soft nodes.
73
- let bestSpan: number[] = []
74
- let bestZ = 0
75
- for (let z = 0; z < layerCount; z++) {
76
- const s = longestFreeSpanAroundZ({
77
- x,
78
- y,
79
- z,
80
- layerCount,
81
- minSpan: 1,
82
- maxSpan: undefined,
83
- obstaclesByLayer,
84
- placedByLayer: hardPlacedByLayer,
85
- })
86
- if (s.length > bestSpan.length) {
87
- bestSpan = s
88
- bestZ = z
89
- }
90
- }
91
- const anchorZ = bestSpan.length
92
- ? bestSpan[Math.floor(bestSpan.length / 2)]!
93
- : bestZ
94
-
95
- // Distance heuristic against hard blockers only (obstacles + full-stack)
96
- const hardAtZ = [
97
- ...(obstaclesByLayer[anchorZ] ?? []),
98
- ...(hardPlacedByLayer[anchorZ] ?? []),
99
- ]
100
- const d = Math.min(
101
- distancePointToRectEdges(x, y, bounds),
102
- ...(hardAtZ.length
103
- ? hardAtZ.map((b) => distancePointToRectEdges(x, y, b))
104
- : [Infinity]),
105
- )
106
-
107
- const k = `${x.toFixed(6)}|${y.toFixed(6)}`
108
- const cand: Candidate3D = {
109
- x,
110
- y,
111
- z: anchorZ,
112
- distance: d,
113
- zSpanLen: bestSpan.length,
114
- }
115
- const prev = out.get(k)
116
- if (
117
- !prev ||
118
- cand.zSpanLen! > (prev.zSpanLen ?? 0) ||
119
- (cand.zSpanLen === prev.zSpanLen && cand.distance > prev.distance)
120
- ) {
121
- out.set(k, cand)
122
- }
123
- }
124
- }
125
-
126
- const arr = Array.from(out.values())
127
- arr.sort((a, b) => b.zSpanLen! - a.zSpanLen! || b.distance - a.distance)
128
- return arr
129
- }
130
-
131
- /**
132
- * Find the longest contiguous free span around z (optionally capped).
133
- */
134
- export function longestFreeSpanAroundZ(params: {
135
- x: number
136
- y: number
137
- z: number
138
- layerCount: number
139
- minSpan: number
140
- maxSpan: number | undefined
141
- obstaclesByLayer: XYRect[][]
142
- placedByLayer: XYRect[][]
143
- }): number[] {
144
- const {
145
- x,
146
- y,
147
- z,
148
- layerCount,
149
- minSpan,
150
- maxSpan,
151
- obstaclesByLayer,
152
- placedByLayer,
153
- } = params
154
-
155
- const isFreeAt = (layer: number) => {
156
- const blockers = [
157
- ...(obstaclesByLayer[layer] ?? []),
158
- ...(placedByLayer[layer] ?? []),
159
- ]
160
- return !blockers.some((b) => containsPoint(b, x, y))
161
- }
162
- let lo = z
163
- let hi = z
164
- while (lo - 1 >= 0 && isFreeAt(lo - 1)) lo--
165
- while (hi + 1 < layerCount && isFreeAt(hi + 1)) hi++
166
-
167
- if (typeof maxSpan === "number") {
168
- const target = clamp(maxSpan, 1, layerCount)
169
- // trim symmetrically (keeping z inside)
170
- while (hi - lo + 1 > target) {
171
- if (z - lo > hi - z) lo++
172
- else hi--
173
- }
174
- }
175
-
176
- const res: number[] = []
177
- for (let i = lo; i <= hi; i++) res.push(i)
178
- return res.length >= minSpan ? res : []
179
- }
180
-
181
- /**
182
- * Compute default grid sizes based on bounds.
183
- */
184
- export function computeDefaultGridSizes(bounds: XYRect): number[] {
185
- const ref = Math.max(bounds.width, bounds.height)
186
- return [ref / 8, ref / 16, ref / 32]
187
- }
1
+ import type { Candidate3D, XYRect } from "../../rectdiff-types"
2
+ import { EPS, distancePointToRectEdges } from "../../utils/rectdiff-geometry"
3
+ import { isFullyOccupiedAtPoint } from "../../utils/isFullyOccupiedAtPoint"
4
+ import { longestFreeSpanAroundZ } from "./longestFreeSpanAroundZ"
188
5
 
189
6
  /**
190
7
  * Compute exact uncovered segments along a 1D line.
@@ -279,20 +96,22 @@ export function computeEdgeCandidates3D(params: {
279
96
  // Use small inset from edges for placement
280
97
  const δ = Math.max(minSize * 0.15, EPS * 3)
281
98
  const dedup = new Set<string>()
282
- const key = (x: number, y: number, z: number) =>
283
- `${z}|${x.toFixed(6)}|${y.toFixed(6)}`
284
-
285
- function fullyOcc(x: number, y: number) {
286
- return isFullyOccupiedAllLayers({
287
- x,
288
- y,
289
- layerCount,
290
- obstaclesByLayer,
291
- placedByLayer,
292
- })
99
+ const key = (p: { x: number; y: number; z: number }) =>
100
+ `${p.z}|${p.x.toFixed(6)}|${p.y.toFixed(6)}`
101
+
102
+ function fullyOcc(p: { x: number; y: number }) {
103
+ return isFullyOccupiedAtPoint(
104
+ {
105
+ layerCount,
106
+ obstaclesByLayer,
107
+ placedByLayer,
108
+ },
109
+ p,
110
+ )
293
111
  }
294
112
 
295
- function pushIfFree(x: number, y: number, z: number) {
113
+ function pushIfFree(p: { x: number; y: number; z: number }) {
114
+ const { x, y, z } = p
296
115
  if (
297
116
  x < bounds.x + EPS ||
298
117
  y < bounds.y + EPS ||
@@ -300,7 +119,7 @@ export function computeEdgeCandidates3D(params: {
300
119
  y > bounds.y + bounds.height - EPS
301
120
  )
302
121
  return
303
- if (fullyOcc(x, y)) return // new rule: only drop if truly impossible
122
+ if (fullyOcc({ x, y })) return // new rule: only drop if truly impossible
304
123
 
305
124
  // Distance uses obstacles + hard nodes (soft nodes ignored for ranking)
306
125
  const hard = [
@@ -308,13 +127,13 @@ export function computeEdgeCandidates3D(params: {
308
127
  ...(hardPlacedByLayer[z] ?? []),
309
128
  ]
310
129
  const d = Math.min(
311
- distancePointToRectEdges(x, y, bounds),
130
+ distancePointToRectEdges({ x, y }, bounds),
312
131
  ...(hard.length
313
- ? hard.map((b) => distancePointToRectEdges(x, y, b))
132
+ ? hard.map((b) => distancePointToRectEdges({ x, y }, b))
314
133
  : [Infinity]),
315
134
  )
316
135
 
317
- const k = key(x, y, z)
136
+ const k = key({ x, y, z })
318
137
  if (dedup.has(k)) return
319
138
  dedup.add(k)
320
139
 
@@ -348,7 +167,7 @@ export function computeEdgeCandidates3D(params: {
348
167
  { x: bounds.x + bounds.width - δ, y: bounds.y + bounds.height - δ }, // bottom-right
349
168
  ]
350
169
  for (const corner of corners) {
351
- pushIfFree(corner.x, corner.y, z)
170
+ pushIfFree({ x: corner.x, y: corner.y, z })
352
171
  }
353
172
 
354
173
  // Top edge (y = bounds.y + δ)
@@ -370,10 +189,10 @@ export function computeEdgeCandidates3D(params: {
370
189
  const segLen = seg.end - seg.start
371
190
  if (segLen >= minSize) {
372
191
  // Seed center and a few strategic points
373
- pushIfFree(seg.center, topY, z)
192
+ pushIfFree({ x: seg.center, y: topY, z })
374
193
  if (segLen > minSize * 1.5) {
375
- pushIfFree(seg.start + minSize * 0.4, topY, z)
376
- pushIfFree(seg.end - minSize * 0.4, topY, z)
194
+ pushIfFree({ x: seg.start + minSize * 0.4, y: topY, z })
195
+ pushIfFree({ x: seg.end - minSize * 0.4, y: topY, z })
377
196
  }
378
197
  }
379
198
  }
@@ -395,10 +214,10 @@ export function computeEdgeCandidates3D(params: {
395
214
  for (const seg of bottomUncovered) {
396
215
  const segLen = seg.end - seg.start
397
216
  if (segLen >= minSize) {
398
- pushIfFree(seg.center, bottomY, z)
217
+ pushIfFree({ x: seg.center, y: bottomY, z })
399
218
  if (segLen > minSize * 1.5) {
400
- pushIfFree(seg.start + minSize * 0.4, bottomY, z)
401
- pushIfFree(seg.end - minSize * 0.4, bottomY, z)
219
+ pushIfFree({ x: seg.start + minSize * 0.4, y: bottomY, z })
220
+ pushIfFree({ x: seg.end - minSize * 0.4, y: bottomY, z })
402
221
  }
403
222
  }
404
223
  }
@@ -420,10 +239,10 @@ export function computeEdgeCandidates3D(params: {
420
239
  for (const seg of leftUncovered) {
421
240
  const segLen = seg.end - seg.start
422
241
  if (segLen >= minSize) {
423
- pushIfFree(leftX, seg.center, z)
242
+ pushIfFree({ x: leftX, y: seg.center, z })
424
243
  if (segLen > minSize * 1.5) {
425
- pushIfFree(leftX, seg.start + minSize * 0.4, z)
426
- pushIfFree(leftX, seg.end - minSize * 0.4, z)
244
+ pushIfFree({ x: leftX, y: seg.start + minSize * 0.4, z })
245
+ pushIfFree({ x: leftX, y: seg.end - minSize * 0.4, z })
427
246
  }
428
247
  }
429
248
  }
@@ -445,10 +264,10 @@ export function computeEdgeCandidates3D(params: {
445
264
  for (const seg of rightUncovered) {
446
265
  const segLen = seg.end - seg.start
447
266
  if (segLen >= minSize) {
448
- pushIfFree(rightX, seg.center, z)
267
+ pushIfFree({ x: rightX, y: seg.center, z })
449
268
  if (segLen > minSize * 1.5) {
450
- pushIfFree(rightX, seg.start + minSize * 0.4, z)
451
- pushIfFree(rightX, seg.end - minSize * 0.4, z)
269
+ pushIfFree({ x: rightX, y: seg.start + minSize * 0.4, z })
270
+ pushIfFree({ x: rightX, y: seg.end - minSize * 0.4, z })
452
271
  }
453
272
  }
454
273
  }
@@ -473,7 +292,7 @@ export function computeEdgeCandidates3D(params: {
473
292
  minSegmentLength: minSize * 0.5,
474
293
  })
475
294
  for (const seg of obLeftUncovered) {
476
- pushIfFree(obLeftX, seg.center, z)
295
+ pushIfFree({ x: obLeftX, y: seg.center, z })
477
296
  }
478
297
  }
479
298
 
@@ -498,7 +317,7 @@ export function computeEdgeCandidates3D(params: {
498
317
  minSegmentLength: minSize * 0.5,
499
318
  })
500
319
  for (const seg of obRightUncovered) {
501
- pushIfFree(obRightX, seg.center, z)
320
+ pushIfFree({ x: obRightX, y: seg.center, z })
502
321
  }
503
322
  }
504
323
 
@@ -520,7 +339,7 @@ export function computeEdgeCandidates3D(params: {
520
339
  minSegmentLength: minSize * 0.5,
521
340
  })
522
341
  for (const seg of obTopUncovered) {
523
- pushIfFree(seg.center, obTopY, z)
342
+ pushIfFree({ x: seg.center, y: obTopY, z })
524
343
  }
525
344
  }
526
345
 
@@ -546,7 +365,7 @@ export function computeEdgeCandidates3D(params: {
546
365
  minSegmentLength: minSize * 0.5,
547
366
  })
548
367
  for (const seg of obBottomUncovered) {
549
- pushIfFree(seg.center, obBottomY, z)
368
+ pushIfFree({ x: seg.center, y: obBottomY, z })
550
369
  }
551
370
  }
552
371
  }
@@ -1,6 +1,10 @@
1
- import type { XYRect } from "../types"
1
+ import type { XYRect } from "../../rectdiff-types"
2
+ import {
3
+ containsPoint,
4
+ subtractRect2D,
5
+ EPS,
6
+ } from "../../utils/rectdiff-geometry"
2
7
  import { isPointInPolygon } from "./isPointInPolygon"
3
- import { EPS } from "../geometry" // Import EPS from common geometry file
4
8
 
5
9
  /**
6
10
  * Decompose the empty space inside 'bounds' but outside 'polygon' into rectangles.
@@ -1,6 +1,7 @@
1
- // lib/solvers/rectdiff/layers.ts
2
- import type { SimpleRouteJson, Obstacle } from "../../types/srj-types"
3
- import type { XYRect } from "./types"
1
+ import type { SimpleRouteJson } from "../../types/srj-types"
2
+ import type { XYRect } from "../../rectdiff-types"
3
+
4
+ type Obstacle = NonNullable<SimpleRouteJson["obstacles"]>[number]
4
5
 
5
6
  function layerSortKey(n: string) {
6
7
  const L = n.toLowerCase()
@@ -0,0 +1,52 @@
1
+ import type { XYRect } from "../../rectdiff-types"
2
+ import { clamp, containsPoint } from "../../utils/rectdiff-geometry"
3
+
4
+ /**
5
+ * Find the longest contiguous free span around z (optionally capped).
6
+ */
7
+ export function longestFreeSpanAroundZ(params: {
8
+ x: number
9
+ y: number
10
+ z: number
11
+ layerCount: number
12
+ minSpan: number
13
+ maxSpan: number | undefined
14
+ obstaclesByLayer: XYRect[][]
15
+ placedByLayer: XYRect[][]
16
+ }): number[] {
17
+ const {
18
+ x,
19
+ y,
20
+ z,
21
+ layerCount,
22
+ minSpan,
23
+ maxSpan,
24
+ obstaclesByLayer,
25
+ placedByLayer,
26
+ } = params
27
+
28
+ const isFreeAt = (layer: number) => {
29
+ const blockers = [
30
+ ...(obstaclesByLayer[layer] ?? []),
31
+ ...(placedByLayer[layer] ?? []),
32
+ ]
33
+ return !blockers.some((b) => containsPoint(b, { x, y }))
34
+ }
35
+ let lo = z
36
+ let hi = z
37
+ while (lo - 1 >= 0 && isFreeAt(lo - 1)) lo--
38
+ while (hi + 1 < layerCount && isFreeAt(hi + 1)) hi++
39
+
40
+ if (typeof maxSpan === "number") {
41
+ const target = clamp(maxSpan, 1, layerCount)
42
+ // trim symmetrically (keeping z inside)
43
+ while (hi - lo + 1 > target) {
44
+ if (z - lo > hi - z) lo++
45
+ else hi--
46
+ }
47
+ }
48
+
49
+ const res: number[] = []
50
+ for (let i = lo; i <= hi; i++) res.push(i)
51
+ return res.length >= minSpan ? res : []
52
+ }
@@ -0,0 +1,14 @@
1
+ import type { Placed3D, XYRect } from "../rectdiff-types"
2
+
3
+ export function allLayerNode(params: {
4
+ layerCount: number
5
+ placed: Placed3D[]
6
+ }): XYRect[][] {
7
+ const out: XYRect[][] = Array.from({ length: params.layerCount }, () => [])
8
+ for (const p of params.placed) {
9
+ if (p.zLayers.length >= params.layerCount) {
10
+ for (const z of p.zLayers) out[z]!.push(p.rect)
11
+ }
12
+ }
13
+ return out
14
+ }
@@ -1,64 +1,15 @@
1
- // lib/solvers/rectdiff/geometry.ts
2
- import type { XYRect } from "./types"
1
+ import type { XYRect } from "../rectdiff-types"
2
+ import { EPS, gt, gte, lt, lte, overlaps } from "./rectdiff-geometry"
3
3
 
4
- export const EPS = 1e-9
5
- export const clamp = (v: number, lo: number, hi: number) =>
6
- Math.max(lo, Math.min(hi, v))
7
- export const gt = (a: number, b: number) => a > b + EPS
8
- export const gte = (a: number, b: number) => a > b - EPS
9
- export const lt = (a: number, b: number) => a < b - EPS
10
- export const lte = (a: number, b: number) => a < b + EPS
11
-
12
- export function overlaps(a: XYRect, b: XYRect) {
13
- return !(
14
- a.x + a.width <= b.x + EPS ||
15
- b.x + b.width <= a.x + EPS ||
16
- a.y + a.height <= b.y + EPS ||
17
- b.y + b.height <= a.y + EPS
18
- )
19
- }
20
-
21
- export function containsPoint(r: XYRect, x: number, y: number) {
22
- return (
23
- x >= r.x - EPS &&
24
- x <= r.x + r.width + EPS &&
25
- y >= r.y - EPS &&
26
- y <= r.y + r.height + EPS
27
- )
28
- }
29
-
30
- export function distancePointToRectEdges(px: number, py: number, r: XYRect) {
31
- const edges: [number, number, number, number][] = [
32
- [r.x, r.y, r.x + r.width, r.y],
33
- [r.x + r.width, r.y, r.x + r.width, r.y + r.height],
34
- [r.x + r.width, r.y + r.height, r.x, r.y + r.height],
35
- [r.x, r.y + r.height, r.x, r.y],
36
- ]
37
- let best = Infinity
38
- for (const [x1, y1, x2, y2] of edges) {
39
- const A = px - x1,
40
- B = py - y1,
41
- C = x2 - x1,
42
- D = y2 - y1
43
- const dot = A * C + B * D
44
- const lenSq = C * C + D * D
45
- let t = lenSq !== 0 ? dot / lenSq : 0
46
- t = clamp(t, 0, 1)
47
- const xx = x1 + t * C
48
- const yy = y1 + t * D
49
- best = Math.min(best, Math.hypot(px - xx, py - yy))
50
- }
51
- return best
4
+ type ExpandDirectionParams = {
5
+ r: XYRect
6
+ bounds: XYRect
7
+ blockers: XYRect[]
8
+ maxAspect: number | null | undefined
52
9
  }
53
10
 
54
- // --- directional expansion caps (respect board + blockers + aspect) ---
55
-
56
- function maxExpandRight(
57
- r: XYRect,
58
- bounds: XYRect,
59
- blockers: XYRect[],
60
- maxAspect: number | null | undefined,
61
- ) {
11
+ function maxExpandRight(params: ExpandDirectionParams) {
12
+ const { r, bounds, blockers, maxAspect } = params
62
13
  // Start with board boundary
63
14
  let maxWidth = bounds.x + bounds.width - r.x
64
15
 
@@ -94,12 +45,8 @@ function maxExpandRight(
94
45
  return Math.max(0, e)
95
46
  }
96
47
 
97
- function maxExpandDown(
98
- r: XYRect,
99
- bounds: XYRect,
100
- blockers: XYRect[],
101
- maxAspect: number | null | undefined,
102
- ) {
48
+ function maxExpandDown(params: ExpandDirectionParams) {
49
+ const { r, bounds, blockers, maxAspect } = params
103
50
  // Start with board boundary
104
51
  let maxHeight = bounds.y + bounds.height - r.y
105
52
 
@@ -134,12 +81,8 @@ function maxExpandDown(
134
81
  return Math.max(0, e)
135
82
  }
136
83
 
137
- function maxExpandLeft(
138
- r: XYRect,
139
- bounds: XYRect,
140
- blockers: XYRect[],
141
- maxAspect: number | null | undefined,
142
- ) {
84
+ function maxExpandLeft(params: ExpandDirectionParams) {
85
+ const { r, bounds, blockers, maxAspect } = params
143
86
  // Start with board boundary
144
87
  let minX = bounds.x
145
88
 
@@ -172,12 +115,8 @@ function maxExpandLeft(
172
115
  return Math.max(0, e)
173
116
  }
174
117
 
175
- function maxExpandUp(
176
- r: XYRect,
177
- bounds: XYRect,
178
- blockers: XYRect[],
179
- maxAspect: number | null | undefined,
180
- ) {
118
+ function maxExpandUp(params: ExpandDirectionParams) {
119
+ const { r, bounds, blockers, maxAspect } = params
181
120
  // Start with board boundary
182
121
  let minY = bounds.y
183
122
 
@@ -271,25 +210,27 @@ export function expandRectFromSeed(params: {
271
210
  let improved = true
272
211
  while (improved) {
273
212
  improved = false
274
- const eR = maxExpandRight(r, bounds, blockers, maxAspectRatio)
213
+ const commonParams = { bounds, blockers, maxAspect: maxAspectRatio }
214
+
215
+ const eR = maxExpandRight({ ...commonParams, r })
275
216
  if (eR > 0) {
276
217
  r = { ...r, width: r.width + eR }
277
218
  improved = true
278
219
  }
279
220
 
280
- const eD = maxExpandDown(r, bounds, blockers, maxAspectRatio)
221
+ const eD = maxExpandDown({ ...commonParams, r })
281
222
  if (eD > 0) {
282
223
  r = { ...r, height: r.height + eD }
283
224
  improved = true
284
225
  }
285
226
 
286
- const eL = maxExpandLeft(r, bounds, blockers, maxAspectRatio)
227
+ const eL = maxExpandLeft({ ...commonParams, r })
287
228
  if (eL > 0) {
288
229
  r = { x: r.x - eL, y: r.y, width: r.width + eL, height: r.height }
289
230
  improved = true
290
231
  }
291
232
 
292
- const eU = maxExpandUp(r, bounds, blockers, maxAspectRatio)
233
+ const eU = maxExpandUp({ ...commonParams, r })
293
234
  if (eU > 0) {
294
235
  r = { x: r.x, y: r.y - eU, width: r.width, height: r.height + eU }
295
236
  improved = true
@@ -307,43 +248,3 @@ export function expandRectFromSeed(params: {
307
248
 
308
249
  return best
309
250
  }
310
-
311
- /** Find the intersection of two 1D intervals, or null if they don't overlap. */
312
- export function intersect1D(a0: number, a1: number, b0: number, b1: number) {
313
- const lo = Math.max(a0, b0)
314
- const hi = Math.min(a1, b1)
315
- return hi > lo + EPS ? ([lo, hi] as const) : null
316
- }
317
-
318
- /** Return A \ B as up to 4 non-overlapping rectangles (or [A] if no overlap). */
319
- export function subtractRect2D(A: XYRect, B: XYRect): XYRect[] {
320
- if (!overlaps(A, B)) return [A]
321
-
322
- const Xi = intersect1D(A.x, A.x + A.width, B.x, B.x + B.width)
323
- const Yi = intersect1D(A.y, A.y + A.height, B.y, B.y + B.height)
324
- if (!Xi || !Yi) return [A]
325
-
326
- const [X0, X1] = Xi
327
- const [Y0, Y1] = Yi
328
- const out: XYRect[] = []
329
-
330
- // Left strip
331
- if (X0 > A.x + EPS) {
332
- out.push({ x: A.x, y: A.y, width: X0 - A.x, height: A.height })
333
- }
334
- // Right strip
335
- if (A.x + A.width > X1 + EPS) {
336
- out.push({ x: X1, y: A.y, width: A.x + A.width - X1, height: A.height })
337
- }
338
- // Top wedge in the middle band
339
- const midW = Math.max(0, X1 - X0)
340
- if (midW > EPS && Y0 > A.y + EPS) {
341
- out.push({ x: X0, y: A.y, width: midW, height: Y0 - A.y })
342
- }
343
- // Bottom wedge in the middle band
344
- if (midW > EPS && A.y + A.height > Y1 + EPS) {
345
- out.push({ x: X0, y: Y1, width: midW, height: A.y + A.height - Y1 })
346
- }
347
-
348
- return out.filter((r) => r.width > EPS && r.height > EPS)
349
- }
@@ -0,0 +1,49 @@
1
+ import type { Placed3D, Rect3d, XYRect } from "../rectdiff-types"
2
+
3
+ export function finalizeRects(params: {
4
+ placed: Placed3D[]
5
+ obstaclesByLayer: XYRect[][]
6
+ boardVoidRects: XYRect[]
7
+ }): Rect3d[] {
8
+ // Convert all placed (free space) nodes to output format
9
+ const out: Rect3d[] = params.placed.map((p) => ({
10
+ minX: p.rect.x,
11
+ minY: p.rect.y,
12
+ maxX: p.rect.x + p.rect.width,
13
+ maxY: p.rect.y + p.rect.height,
14
+ zLayers: [...p.zLayers].sort((a, b) => a - b),
15
+ }))
16
+
17
+ /**
18
+ * Recover obstacles as mesh nodes.
19
+ * Obstacles are stored per-layer in `obstaclesByLayer`, but we want to emit
20
+ * single 3D nodes for multi-layer obstacles if they share the same rect.
21
+ * We use the `XYRect` object reference identity to group layers.
22
+ */
23
+ const layersByObstacleRect = new Map<XYRect, number[]>()
24
+
25
+ params.obstaclesByLayer.forEach((layerObs, z) => {
26
+ for (const rect of layerObs) {
27
+ const layerIndices = layersByObstacleRect.get(rect) ?? []
28
+ layerIndices.push(z)
29
+ layersByObstacleRect.set(rect, layerIndices)
30
+ }
31
+ })
32
+
33
+ // Append obstacle nodes to the output
34
+ const voidSet = new Set(params.boardVoidRects || [])
35
+ for (const [rect, layerIndices] of layersByObstacleRect.entries()) {
36
+ if (voidSet.has(rect)) continue // Skip void rects
37
+
38
+ out.push({
39
+ minX: rect.x,
40
+ minY: rect.y,
41
+ maxX: rect.x + rect.width,
42
+ maxY: rect.y + rect.height,
43
+ zLayers: layerIndices.sort((a, b) => a - b),
44
+ isObstacle: true,
45
+ })
46
+ }
47
+
48
+ return out
49
+ }