@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.
- package/.claude/settings.local.json +9 -0
- package/.github/workflows/bun-formatcheck.yml +26 -0
- package/.github/workflows/bun-pver-release.yml +71 -0
- package/.github/workflows/bun-test.yml +31 -0
- package/.github/workflows/bun-typecheck.yml +26 -0
- package/CLAUDE.md +23 -0
- package/README.md +5 -0
- package/biome.json +93 -0
- package/bun.lock +29 -0
- package/bunfig.toml +5 -0
- package/components/SolverDebugger3d.tsx +833 -0
- package/cosmos.config.json +6 -0
- package/cosmos.decorator.tsx +21 -0
- package/dist/index.d.ts +111 -0
- package/dist/index.js +921 -0
- package/experiments/rect-fill-2d.tsx +983 -0
- package/experiments/rect3d_visualizer.html +640 -0
- package/global.d.ts +4 -0
- package/index.html +12 -0
- package/lib/index.ts +1 -0
- package/lib/solvers/RectDiffSolver.ts +158 -0
- package/lib/solvers/rectdiff/candidates.ts +397 -0
- package/lib/solvers/rectdiff/engine.ts +355 -0
- package/lib/solvers/rectdiff/geometry.ts +284 -0
- package/lib/solvers/rectdiff/layers.ts +48 -0
- package/lib/solvers/rectdiff/rectsToMeshNodes.ts +22 -0
- package/lib/solvers/rectdiff/types.ts +63 -0
- package/lib/types/capacity-mesh-types.ts +33 -0
- package/lib/types/srj-types.ts +37 -0
- package/package.json +33 -0
- package/pages/example01.page.tsx +11 -0
- package/test-assets/example01.json +933 -0
- package/tests/__snapshots__/svg.snap.svg +3 -0
- package/tests/examples/__snapshots__/example01.snap.svg +121 -0
- package/tests/examples/example01.test.tsx +65 -0
- package/tests/fixtures/preload.ts +1 -0
- package/tests/incremental-solver.test.ts +100 -0
- package/tests/rect-diff-solver.test.ts +154 -0
- package/tests/svg.test.ts +12 -0
- 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
|
+
}
|