@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,355 @@
1
+ // lib/solvers/rectdiff/engine.ts
2
+ import type { GridFill3DOptions, Placed3D, Rect3d, RectDiffState, XYRect } from "./types"
3
+ import type { SimpleRouteJson } from "../../types/srj-types"
4
+ import {
5
+ computeCandidates3D,
6
+ computeDefaultGridSizes,
7
+ computeEdgeCandidates3D,
8
+ longestFreeSpanAroundZ,
9
+ } from "./candidates"
10
+ import { EPS, containsPoint, expandRectFromSeed, overlaps, subtractRect2D } from "./geometry"
11
+ import { buildZIndexMap, obstacleToXYRect, obstacleZs } from "./layers"
12
+
13
+ export function initState(srj: SimpleRouteJson, opts: Partial<GridFill3DOptions>): RectDiffState {
14
+ const { layerNames, zIndexByName } = buildZIndexMap(srj)
15
+ const layerCount = Math.max(1, layerNames.length, srj.layerCount || 1)
16
+
17
+ const bounds: XYRect = {
18
+ x: srj.bounds.minX,
19
+ y: srj.bounds.minY,
20
+ width: srj.bounds.maxX - srj.bounds.minX,
21
+ height: srj.bounds.maxY - srj.bounds.minY,
22
+ }
23
+
24
+ // Obstacles per layer
25
+ const obstaclesByLayer: XYRect[][] = Array.from({ length: layerCount }, () => [])
26
+ for (const ob of srj.obstacles ?? []) {
27
+ const r = obstacleToXYRect(ob)
28
+ if (!r) continue
29
+ const zs = obstacleZs(ob, zIndexByName)
30
+ for (const z of zs) if (z >= 0 && z < layerCount) obstaclesByLayer[z]!.push(r)
31
+ }
32
+
33
+ const trace = Math.max(0.01, srj.minTraceWidth || 0.15)
34
+ const defaults: Required<Omit<GridFill3DOptions, "gridSizes" | "maxMultiLayerSpan">> & {
35
+ gridSizes: number[]
36
+ maxMultiLayerSpan: number | undefined
37
+ } = {
38
+ gridSizes: computeDefaultGridSizes(bounds),
39
+ initialCellRatio: 0.2,
40
+ maxAspectRatio: 3,
41
+ minSingle: { width: 2 * trace, height: 2 * trace },
42
+ minMulti: {
43
+ width: 4 * trace,
44
+ height: 4 * trace,
45
+ minLayers: Math.min(2, Math.max(1, srj.layerCount || 1)),
46
+ },
47
+ preferMultiLayer: true,
48
+ maxMultiLayerSpan: undefined,
49
+ }
50
+
51
+ const options = { ...defaults, ...opts, gridSizes: opts.gridSizes ?? defaults.gridSizes }
52
+
53
+ const placedByLayer: XYRect[][] = Array.from({ length: layerCount }, () => [])
54
+
55
+ // Begin at the **first** grid level; candidates computed lazily on first step
56
+ return {
57
+ srj,
58
+ layerNames,
59
+ layerCount,
60
+ bounds,
61
+ options,
62
+ obstaclesByLayer,
63
+ phase: "GRID",
64
+ gridIndex: 0,
65
+ candidates: [],
66
+ placed: [],
67
+ placedByLayer,
68
+ expansionIndex: 0,
69
+ edgeAnalysisDone: false,
70
+ totalSeedsThisGrid: 0,
71
+ consumedSeedsThisGrid: 0,
72
+ }
73
+ }
74
+
75
+ // Build per-layer list of "hard" placed rects = nodes spanning all layers
76
+ function buildHardPlacedByLayer(state: RectDiffState): XYRect[][] {
77
+ const out: XYRect[][] = Array.from({ length: state.layerCount }, () => [])
78
+ for (const p of state.placed) {
79
+ if (p.zLayers.length >= state.layerCount) {
80
+ for (const z of p.zLayers) out[z]!.push(p.rect)
81
+ }
82
+ }
83
+ return out
84
+ }
85
+
86
+ function isFullyOccupiedAtPoint(state: RectDiffState, x: number, y: number): boolean {
87
+ for (let z = 0; z < state.layerCount; z++) {
88
+ const obs = state.obstaclesByLayer[z] ?? []
89
+ const placed = state.placedByLayer[z] ?? []
90
+ const occ =
91
+ obs.some((b) => containsPoint(b, x, y)) ||
92
+ placed.some((b) => containsPoint(b, x, y))
93
+ if (!occ) return false
94
+ }
95
+ return true
96
+ }
97
+
98
+ /** Shrink/split any *soft* (non-full-stack) nodes overlapped by `newIndex` */
99
+ function resizeSoftOverlaps(state: RectDiffState, newIndex: number) {
100
+ const newcomer = state.placed[newIndex]!
101
+ const { rect: newR, zLayers: newZs } = newcomer
102
+ const layerCount = state.layerCount
103
+
104
+ const removeIdx: number[] = []
105
+ const toAdd: typeof state.placed = []
106
+
107
+ for (let i = 0; i < state.placed.length; i++) {
108
+ if (i === newIndex) continue
109
+ const old = state.placed[i]!
110
+ // Protect full-stack nodes
111
+ if (old.zLayers.length >= layerCount) continue
112
+
113
+ const sharedZ = old.zLayers.filter((z) => newZs.includes(z))
114
+ if (sharedZ.length === 0) continue
115
+ if (!overlaps(old.rect, newR)) continue
116
+
117
+ // Carve the overlap on the shared layers
118
+ const parts = subtractRect2D(old.rect, newR)
119
+
120
+ // We will replace `old` entirely; re-add unaffected layers (same rect object).
121
+ removeIdx.push(i)
122
+
123
+ const unaffectedZ = old.zLayers.filter((z) => !newZs.includes(z))
124
+ if (unaffectedZ.length > 0) {
125
+ toAdd.push({ rect: old.rect, zLayers: unaffectedZ })
126
+ }
127
+
128
+ // Re-add carved pieces for affected layers, dropping tiny slivers
129
+ const minW = Math.min(state.options.minSingle.width, state.options.minMulti.width)
130
+ const minH = Math.min(state.options.minSingle.height, state.options.minMulti.height)
131
+ for (const p of parts) {
132
+ if (p.width + EPS >= minW && p.height + EPS >= minH) {
133
+ toAdd.push({ rect: p, zLayers: sharedZ.slice() })
134
+ }
135
+ }
136
+ }
137
+
138
+ // Remove (and clear placedByLayer)
139
+ removeIdx.sort((a, b) => b - a).forEach((idx) => {
140
+ const rem = state.placed.splice(idx, 1)[0]!
141
+ for (const z of rem.zLayers) {
142
+ const arr = state.placedByLayer[z]!
143
+ const j = arr.findIndex((r) => r === rem.rect)
144
+ if (j >= 0) arr.splice(j, 1)
145
+ }
146
+ })
147
+
148
+ // Add replacements
149
+ for (const p of toAdd) {
150
+ state.placed.push(p)
151
+ for (const z of p.zLayers) state.placedByLayer[z]!.push(p.rect)
152
+ }
153
+ }
154
+
155
+ /** One micro-step during the GRID phase: handle (or fetch) exactly one candidate */
156
+ export function stepGrid(state: RectDiffState): void {
157
+ const {
158
+ gridSizes,
159
+ initialCellRatio,
160
+ maxAspectRatio,
161
+ minSingle,
162
+ minMulti,
163
+ preferMultiLayer,
164
+ maxMultiLayerSpan,
165
+ } = state.options
166
+ const grid = gridSizes[state.gridIndex]!
167
+
168
+ // Build hard-placed map once per micro-step (cheap)
169
+ const hardPlacedByLayer = buildHardPlacedByLayer(state)
170
+
171
+ // Ensure candidates exist for this grid
172
+ if (state.candidates.length === 0 && state.consumedSeedsThisGrid === 0) {
173
+ state.candidates = computeCandidates3D(
174
+ state.bounds,
175
+ grid,
176
+ state.layerCount,
177
+ state.obstaclesByLayer,
178
+ state.placedByLayer, // all nodes (soft + hard) for fully-occupied test
179
+ hardPlacedByLayer, // hard blockers for ranking/span
180
+ )
181
+ state.totalSeedsThisGrid = state.candidates.length
182
+ state.consumedSeedsThisGrid = 0
183
+ }
184
+
185
+ // If no candidates remain, advance grid or run edge pass or switch phase
186
+ if (state.candidates.length === 0) {
187
+ if (state.gridIndex + 1 < gridSizes.length) {
188
+ state.gridIndex += 1
189
+ state.totalSeedsThisGrid = 0
190
+ state.consumedSeedsThisGrid = 0
191
+ return
192
+ } else {
193
+ if (!state.edgeAnalysisDone) {
194
+ const minSize = Math.min(minSingle.width, minSingle.height)
195
+ state.candidates = computeEdgeCandidates3D(
196
+ state.bounds,
197
+ minSize,
198
+ state.layerCount,
199
+ state.obstaclesByLayer,
200
+ state.placedByLayer, // for fully-occupied test
201
+ hardPlacedByLayer,
202
+ )
203
+ state.edgeAnalysisDone = true
204
+ state.totalSeedsThisGrid = state.candidates.length
205
+ state.consumedSeedsThisGrid = 0
206
+ return
207
+ }
208
+ state.phase = "EXPANSION"
209
+ state.expansionIndex = 0
210
+ return
211
+ }
212
+ }
213
+
214
+ // Consume exactly one candidate
215
+ const cand = state.candidates.shift()!
216
+ state.consumedSeedsThisGrid += 1
217
+
218
+ // Evaluate attempts — multi-layer span first (computed ignoring soft nodes)
219
+ const span = longestFreeSpanAroundZ(
220
+ cand.x,
221
+ cand.y,
222
+ cand.z,
223
+ state.layerCount,
224
+ minMulti.minLayers,
225
+ maxMultiLayerSpan,
226
+ state.obstaclesByLayer,
227
+ hardPlacedByLayer, // ignore soft nodes for span
228
+ )
229
+
230
+ const attempts: Array<{
231
+ kind: "multi" | "single"
232
+ layers: number[]
233
+ minReq: { width: number; height: number }
234
+ }> = []
235
+
236
+ if (span.length >= minMulti.minLayers) {
237
+ attempts.push({ kind: "multi", layers: span, minReq: { width: minMulti.width, height: minMulti.height } })
238
+ }
239
+ attempts.push({ kind: "single", layers: [cand.z], minReq: { width: minSingle.width, height: minSingle.height } })
240
+
241
+ const ordered = preferMultiLayer ? attempts : attempts.reverse()
242
+
243
+ for (const attempt of ordered) {
244
+ // HARD blockers only: obstacles on those layers + full-stack nodes
245
+ const hardBlockers: XYRect[] = []
246
+ for (const z of attempt.layers) {
247
+ if (state.obstaclesByLayer[z]) hardBlockers.push(...state.obstaclesByLayer[z]!)
248
+ if (hardPlacedByLayer[z]) hardBlockers.push(...hardPlacedByLayer[z]!)
249
+ }
250
+
251
+ const rect = expandRectFromSeed(
252
+ cand.x,
253
+ cand.y,
254
+ grid,
255
+ state.bounds,
256
+ hardBlockers, // soft nodes DO NOT block expansion
257
+ initialCellRatio,
258
+ maxAspectRatio,
259
+ attempt.minReq,
260
+ )
261
+ if (!rect) continue
262
+
263
+ // Place the new node
264
+ const placed: Placed3D = { rect, zLayers: [...attempt.layers] }
265
+ const newIndex = state.placed.push(placed) - 1
266
+ for (const z of attempt.layers) state.placedByLayer[z]!.push(rect)
267
+
268
+ // New: carve overlapped soft nodes
269
+ resizeSoftOverlaps(state, newIndex)
270
+
271
+ // New: relax candidate culling — only drop seeds that became fully occupied
272
+ state.candidates = state.candidates.filter((c) => !isFullyOccupiedAtPoint(state, c.x, c.y))
273
+
274
+ return // processed one candidate
275
+ }
276
+
277
+ // Neither attempt worked; drop this candidate for now.
278
+ }
279
+
280
+ /** One micro-step during the EXPANSION phase: expand exactly one placed rect */
281
+ export function stepExpansion(state: RectDiffState): void {
282
+ if (state.expansionIndex >= state.placed.length) {
283
+ state.phase = "DONE"
284
+ return
285
+ }
286
+
287
+ const idx = state.expansionIndex
288
+ const p = state.placed[idx]!
289
+ const lastGrid = state.options.gridSizes[state.options.gridSizes.length - 1]!
290
+
291
+ const hardPlacedByLayer = buildHardPlacedByLayer(state)
292
+
293
+ // HARD blockers only: obstacles on p.zLayers + full-stack nodes
294
+ const hardBlockers: XYRect[] = []
295
+ for (const z of p.zLayers) {
296
+ hardBlockers.push(...(state.obstaclesByLayer[z] ?? []))
297
+ hardBlockers.push(...(hardPlacedByLayer[z] ?? []))
298
+ }
299
+
300
+ const oldRect = p.rect
301
+ const expanded = expandRectFromSeed(
302
+ p.rect.x + p.rect.width / 2,
303
+ p.rect.y + p.rect.height / 2,
304
+ lastGrid,
305
+ state.bounds,
306
+ hardBlockers,
307
+ 0, // seed bias off
308
+ null, // no aspect cap in expansion pass
309
+ { width: p.rect.width, height: p.rect.height },
310
+ )
311
+
312
+ if (expanded) {
313
+ // Update placement + per-layer index (replace old rect object)
314
+ state.placed[idx] = { rect: expanded, zLayers: p.zLayers }
315
+ for (const z of p.zLayers) {
316
+ const arr = state.placedByLayer[z]!
317
+ const j = arr.findIndex((r) => r === oldRect)
318
+ if (j >= 0) arr[j] = expanded
319
+ }
320
+
321
+ // Carve overlapped soft neighbors (respect full-stack nodes)
322
+ resizeSoftOverlaps(state, idx)
323
+ }
324
+
325
+ state.expansionIndex += 1
326
+ }
327
+
328
+ export function finalizeRects(state: RectDiffState): Rect3d[] {
329
+ return state.placed.map((p) => ({
330
+ minX: p.rect.x,
331
+ minY: p.rect.y,
332
+ maxX: p.rect.x + p.rect.width,
333
+ maxY: p.rect.y + p.rect.height,
334
+ zLayers: [...p.zLayers].sort((a, b) => a - b),
335
+ }))
336
+ }
337
+
338
+ /** Optional: rough progress number for BaseSolver.progress */
339
+ export function computeProgress(state: RectDiffState): number {
340
+ const grids = state.options.gridSizes.length
341
+ if (state.phase === "GRID") {
342
+ const g = state.gridIndex
343
+ const base = g / (grids + 1) // reserve final slice for expansion
344
+ const denom = Math.max(1, state.totalSeedsThisGrid)
345
+ const frac = denom ? state.consumedSeedsThisGrid / denom : 1
346
+ return Math.min(0.999, base + frac * (1 / (grids + 1)))
347
+ }
348
+ if (state.phase === "EXPANSION") {
349
+ const base = grids / (grids + 1)
350
+ const denom = Math.max(1, state.placed.length)
351
+ const frac = denom ? state.expansionIndex / denom : 1
352
+ return Math.min(0.999, base + frac * (1 / (grids + 1)))
353
+ }
354
+ return 1
355
+ }
@@ -0,0 +1,284 @@
1
+ // lib/solvers/rectdiff/geometry.ts
2
+ import type { XYRect } from "./types"
3
+
4
+ export const EPS = 1e-9
5
+ export const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v))
6
+ export const gt = (a: number, b: number) => a > b + EPS
7
+ export const gte = (a: number, b: number) => a > b - EPS
8
+ export const lt = (a: number, b: number) => a < b - EPS
9
+ export const lte = (a: number, b: number) => a < b + EPS
10
+
11
+ export function overlaps(a: XYRect, b: XYRect) {
12
+ return !(
13
+ a.x + a.width <= b.x + EPS ||
14
+ b.x + b.width <= a.x + EPS ||
15
+ a.y + a.height <= b.y + EPS ||
16
+ b.y + b.height <= a.y + EPS
17
+ )
18
+ }
19
+
20
+ export function containsPoint(r: XYRect, x: number, y: number) {
21
+ return (
22
+ x >= r.x - EPS && x <= r.x + r.width + EPS &&
23
+ y >= r.y - EPS && y <= r.y + r.height + EPS
24
+ )
25
+ }
26
+
27
+ export function distancePointToRectEdges(px: number, py: number, r: XYRect) {
28
+ const edges: [number, number, number, number][] = [
29
+ [r.x, r.y, r.x + r.width, r.y],
30
+ [r.x + r.width, r.y, r.x + r.width, r.y + r.height],
31
+ [r.x + r.width, r.y + r.height, r.x, r.y + r.height],
32
+ [r.x, r.y + r.height, r.x, r.y],
33
+ ]
34
+ let best = Infinity
35
+ for (const [x1, y1, x2, y2] of edges) {
36
+ const A = px - x1, B = py - y1, C = x2 - x1, D = y2 - y1
37
+ const dot = A * C + B * D
38
+ const lenSq = C * C + D * D
39
+ let t = lenSq !== 0 ? dot / lenSq : 0
40
+ t = clamp(t, 0, 1)
41
+ const xx = x1 + t * C
42
+ const yy = y1 + t * D
43
+ best = Math.min(best, Math.hypot(px - xx, py - yy))
44
+ }
45
+ return best
46
+ }
47
+
48
+ // --- directional expansion caps (respect board + blockers + aspect) ---
49
+
50
+ function maxExpandRight(
51
+ r: XYRect, bounds: XYRect, blockers: XYRect[], maxAspect: number | null | undefined
52
+ ) {
53
+ // Start with board boundary
54
+ let maxWidth = bounds.x + bounds.width - r.x
55
+
56
+ // Check all blockers that could limit rightward expansion
57
+ for (const b of blockers) {
58
+ // Only consider blockers that vertically overlap with current rect
59
+ const verticallyOverlaps = r.y + r.height > b.y + EPS && b.y + b.height > r.y + EPS
60
+ if (verticallyOverlaps) {
61
+ // Blocker is to the right - limits how far we can expand
62
+ if (gte(b.x, r.x + r.width)) {
63
+ maxWidth = Math.min(maxWidth, b.x - r.x)
64
+ }
65
+ // Blocker overlaps current position - can't expand at all
66
+ else if (b.x + b.width > r.x + r.width - EPS && b.x < r.x + r.width + EPS) {
67
+ return 0
68
+ }
69
+ }
70
+ }
71
+
72
+ let e = Math.max(0, maxWidth - r.width)
73
+ if (e <= 0) return 0
74
+
75
+ // Apply aspect ratio constraint
76
+ if (maxAspect != null) {
77
+ const w = r.width, h = r.height
78
+ if (w >= h) e = Math.min(e, maxAspect * h - w)
79
+ }
80
+ return Math.max(0, e)
81
+ }
82
+
83
+ function maxExpandDown(
84
+ r: XYRect, bounds: XYRect, blockers: XYRect[], maxAspect: number | null | undefined
85
+ ) {
86
+ // Start with board boundary
87
+ let maxHeight = bounds.y + bounds.height - r.y
88
+
89
+ // Check all blockers that could limit downward expansion
90
+ for (const b of blockers) {
91
+ // Only consider blockers that horizontally overlap with current rect
92
+ const horizOverlaps = r.x + r.width > b.x + EPS && b.x + b.width > r.x + EPS
93
+ if (horizOverlaps) {
94
+ // Blocker is below - limits how far we can expand
95
+ if (gte(b.y, r.y + r.height)) {
96
+ maxHeight = Math.min(maxHeight, b.y - r.y)
97
+ }
98
+ // Blocker overlaps current position - can't expand at all
99
+ else if (b.y + b.height > r.y + r.height - EPS && b.y < r.y + r.height + EPS) {
100
+ return 0
101
+ }
102
+ }
103
+ }
104
+
105
+ let e = Math.max(0, maxHeight - r.height)
106
+ if (e <= 0) return 0
107
+
108
+ // Apply aspect ratio constraint
109
+ if (maxAspect != null) {
110
+ const w = r.width, h = r.height
111
+ if (h >= w) e = Math.min(e, maxAspect * w - h)
112
+ }
113
+ return Math.max(0, e)
114
+ }
115
+
116
+ function maxExpandLeft(
117
+ r: XYRect, bounds: XYRect, blockers: XYRect[], maxAspect: number | null | undefined
118
+ ) {
119
+ // Start with board boundary
120
+ let minX = bounds.x
121
+
122
+ // Check all blockers that could limit leftward expansion
123
+ for (const b of blockers) {
124
+ // Only consider blockers that vertically overlap with current rect
125
+ const verticallyOverlaps = r.y + r.height > b.y + EPS && b.y + b.height > r.y + EPS
126
+ if (verticallyOverlaps) {
127
+ // Blocker is to the left - limits how far we can expand
128
+ if (lte(b.x + b.width, r.x)) {
129
+ minX = Math.max(minX, b.x + b.width)
130
+ }
131
+ // Blocker overlaps current position - can't expand at all
132
+ else if (b.x < r.x + EPS && b.x + b.width > r.x - EPS) {
133
+ return 0
134
+ }
135
+ }
136
+ }
137
+
138
+ let e = Math.max(0, r.x - minX)
139
+ if (e <= 0) return 0
140
+
141
+ // Apply aspect ratio constraint
142
+ if (maxAspect != null) {
143
+ const w = r.width, h = r.height
144
+ if (w >= h) e = Math.min(e, maxAspect * h - w)
145
+ }
146
+ return Math.max(0, e)
147
+ }
148
+
149
+ function maxExpandUp(
150
+ r: XYRect, bounds: XYRect, blockers: XYRect[], maxAspect: number | null | undefined
151
+ ) {
152
+ // Start with board boundary
153
+ let minY = bounds.y
154
+
155
+ // Check all blockers that could limit upward expansion
156
+ for (const b of blockers) {
157
+ // Only consider blockers that horizontally overlap with current rect
158
+ const horizOverlaps = r.x + r.width > b.x + EPS && b.x + b.width > r.x + EPS
159
+ if (horizOverlaps) {
160
+ // Blocker is above - limits how far we can expand
161
+ if (lte(b.y + b.height, r.y)) {
162
+ minY = Math.max(minY, b.y + b.height)
163
+ }
164
+ // Blocker overlaps current position - can't expand at all
165
+ else if (b.y < r.y + EPS && b.y + b.height > r.y - EPS) {
166
+ return 0
167
+ }
168
+ }
169
+ }
170
+
171
+ let e = Math.max(0, r.y - minY)
172
+ if (e <= 0) return 0
173
+
174
+ // Apply aspect ratio constraint
175
+ if (maxAspect != null) {
176
+ const w = r.width, h = r.height
177
+ if (h >= w) e = Math.min(e, maxAspect * w - h)
178
+ }
179
+ return Math.max(0, e)
180
+ }
181
+
182
+ /** Grow a rect around (startX,startY), honoring bounds/blockers/aspect/min sizes */
183
+ export function expandRectFromSeed(
184
+ startX: number,
185
+ startY: number,
186
+ gridSize: number,
187
+ bounds: XYRect,
188
+ blockers: XYRect[],
189
+ initialCellRatio: number,
190
+ maxAspectRatio: number | null | undefined,
191
+ minReq: { width: number; height: number },
192
+ ): XYRect | null {
193
+ const minSide = Math.max(1e-9, gridSize * initialCellRatio)
194
+ const initialW = Math.max(minSide, minReq.width)
195
+ const initialH = Math.max(minSide, minReq.height)
196
+
197
+ const strategies = [
198
+ { ox: 0, oy: 0 },
199
+ { ox: -initialW, oy: 0 },
200
+ { ox: 0, oy: -initialH },
201
+ { ox: -initialW, oy: -initialH },
202
+ { ox: -initialW / 2, oy: -initialH / 2 },
203
+ ]
204
+
205
+ let best: XYRect | null = null
206
+ let bestArea = 0
207
+
208
+ STRATS: for (const s of strategies) {
209
+ let r: XYRect = { x: startX + s.ox, y: startY + s.oy, width: initialW, height: initialH }
210
+
211
+ // keep initial inside board
212
+ if (lt(r.x, bounds.x) || lt(r.y, bounds.y) ||
213
+ gt(r.x + r.width, bounds.x + bounds.width) ||
214
+ gt(r.y + r.height, bounds.y + bounds.height)) {
215
+ continue
216
+ }
217
+
218
+ // no initial overlap
219
+ for (const b of blockers) if (overlaps(r, b)) continue STRATS
220
+
221
+ // greedy expansions in 4 directions
222
+ let improved = true
223
+ while (improved) {
224
+ improved = false
225
+ const eR = maxExpandRight(r, bounds, blockers, maxAspectRatio)
226
+ if (eR > 0) { r = { ...r, width: r.width + eR }; improved = true }
227
+
228
+ const eD = maxExpandDown(r, bounds, blockers, maxAspectRatio)
229
+ if (eD > 0) { r = { ...r, height: r.height + eD }; improved = true }
230
+
231
+ const eL = maxExpandLeft(r, bounds, blockers, maxAspectRatio)
232
+ if (eL > 0) { r = { x: r.x - eL, y: r.y, width: r.width + eL, height: r.height }; improved = true }
233
+
234
+ const eU = maxExpandUp(r, bounds, blockers, maxAspectRatio)
235
+ if (eU > 0) { r = { x: r.x, y: r.y - eU, width: r.width, height: r.height + eU }; improved = true }
236
+ }
237
+
238
+ if (r.width + EPS >= minReq.width && r.height + EPS >= minReq.height) {
239
+ const area = r.width * r.height
240
+ if (area > bestArea) { best = r; bestArea = area }
241
+ }
242
+ }
243
+
244
+ return best
245
+ }
246
+
247
+ export function intersect1D(a0: number, a1: number, b0: number, b1: number) {
248
+ const lo = Math.max(a0, b0)
249
+ const hi = Math.min(a1, b1)
250
+ return hi > lo + EPS ? [lo, hi] as const : null
251
+ }
252
+
253
+ /** Return A \ B as up to 4 non-overlapping rectangles (or [A] if no overlap). */
254
+ export function subtractRect2D(A: XYRect, B: XYRect): XYRect[] {
255
+ if (!overlaps(A, B)) return [A]
256
+
257
+ const Xi = intersect1D(A.x, A.x + A.width, B.x, B.x + B.width)
258
+ const Yi = intersect1D(A.y, A.y + A.height, B.y, B.y + B.height)
259
+ if (!Xi || !Yi) return [A]
260
+
261
+ const [X0, X1] = Xi
262
+ const [Y0, Y1] = Yi
263
+ const out: XYRect[] = []
264
+
265
+ // Left strip
266
+ if (X0 > A.x + EPS) {
267
+ out.push({ x: A.x, y: A.y, width: X0 - A.x, height: A.height })
268
+ }
269
+ // Right strip
270
+ if (A.x + A.width > X1 + EPS) {
271
+ out.push({ x: X1, y: A.y, width: (A.x + A.width) - X1, height: A.height })
272
+ }
273
+ // Top wedge in the middle band
274
+ const midW = Math.max(0, X1 - X0)
275
+ if (midW > EPS && Y0 > A.y + EPS) {
276
+ out.push({ x: X0, y: A.y, width: midW, height: Y0 - A.y })
277
+ }
278
+ // Bottom wedge in the middle band
279
+ if (midW > EPS && A.y + A.height > Y1 + EPS) {
280
+ out.push({ x: X0, y: Y1, width: midW, height: (A.y + A.height) - Y1 })
281
+ }
282
+
283
+ return out.filter((r) => r.width > EPS && r.height > EPS)
284
+ }
@@ -0,0 +1,48 @@
1
+ // lib/solvers/rectdiff/layers.ts
2
+ import type { SimpleRouteJson, Obstacle } from "../../types/srj-types"
3
+ import type { XYRect } from "./types"
4
+
5
+ function layerSortKey(n: string) {
6
+ const L = n.toLowerCase()
7
+ if (L === "top") return -1_000_000
8
+ if (L === "bottom") return 1_000_000
9
+ const m = /^inner(\d+)$/i.exec(L)
10
+ if (m) return parseInt(m[1]!, 10) || 0
11
+ return 100 + L.charCodeAt(0)
12
+ }
13
+
14
+ export function canonicalizeLayerOrder(names: string[]) {
15
+ return Array.from(new Set(names)).sort((a, b) => {
16
+ const ka = layerSortKey(a)
17
+ const kb = layerSortKey(b)
18
+ if (ka !== kb) return ka - kb
19
+ return a.localeCompare(b)
20
+ })
21
+ }
22
+
23
+ export function buildZIndexMap(srj: SimpleRouteJson) {
24
+ const names = canonicalizeLayerOrder((srj.obstacles ?? []).flatMap((o) => o.layers ?? []))
25
+ const fallback = Array.from(
26
+ { length: Math.max(1, srj.layerCount || 1) },
27
+ (_, i) => (i === 0 ? "top" : i === (srj.layerCount || 1) - 1 ? "bottom" : `inner${i}`),
28
+ )
29
+ const layerNames = names.length ? names : fallback
30
+ const map = new Map<string, number>()
31
+ layerNames.forEach((n, i) => map.set(n, i))
32
+ return { layerNames, zIndexByName: map }
33
+ }
34
+
35
+ export function obstacleZs(ob: Obstacle, zIndexByName: Map<string, number>) {
36
+ if (ob.zLayers?.length) return Array.from(new Set(ob.zLayers)).sort((a, b) => a - b)
37
+ const fromNames = (ob.layers ?? [])
38
+ .map((n) => zIndexByName.get(n))
39
+ .filter((v): v is number => typeof v === "number")
40
+ return Array.from(new Set(fromNames)).sort((a, b) => a - b)
41
+ }
42
+
43
+ export function obstacleToXYRect(ob: Obstacle): XYRect | null {
44
+ const w = ob.width as any
45
+ const h = ob.height as any
46
+ if (typeof w !== "number" || typeof h !== "number") return null
47
+ return { x: ob.center.x - w / 2, y: ob.center.y - h / 2, width: w, height: h }
48
+ }
@@ -0,0 +1,22 @@
1
+ // lib/solvers/rectdiff/rectsToMeshNodes.ts
2
+ import type { Rect3d } from "./types"
3
+ import type { CapacityMeshNode } from "../../types/capacity-mesh-types"
4
+
5
+ export function rectsToMeshNodes(rects: Rect3d[]): CapacityMeshNode[] {
6
+ let id = 0
7
+ const out: CapacityMeshNode[] = []
8
+ for (const r of rects) {
9
+ const w = Math.max(0, r.maxX - r.minX)
10
+ const h = Math.max(0, r.maxY - r.minY)
11
+ if (w <= 0 || h <= 0 || r.zLayers.length === 0) continue
12
+ out.push({
13
+ capacityMeshNodeId: `cmn_${id++}`,
14
+ center: { x: (r.minX + r.maxX) / 2, y: (r.minY + r.maxY) / 2 },
15
+ width: w,
16
+ height: h,
17
+ layer: "top",
18
+ availableZ: r.zLayers.slice(),
19
+ })
20
+ }
21
+ return out
22
+ }