@tscircuit/rectdiff 0.0.11 → 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.
- package/dist/index.d.ts +163 -27
- package/dist/index.js +1885 -1676
- package/lib/RectDiffPipeline.ts +18 -17
- package/lib/{solvers/rectdiff/types.ts → rectdiff-types.ts} +0 -34
- package/lib/{solvers/rectdiff/visualization.ts → rectdiff-visualization.ts} +2 -1
- package/lib/solvers/GapFillSolver/FindSegmentsWithAdjacentEmptySpaceSolver.ts +9 -12
- package/lib/solvers/GapFillSolver/GapFillSolverPipeline.ts +1 -1
- package/lib/solvers/GapFillSolver/visuallyOffsetLine.ts +5 -2
- package/lib/solvers/RectDiffExpansionSolver/RectDiffExpansionSolver.ts +205 -0
- package/lib/solvers/{rectdiff → RectDiffExpansionSolver}/rectsToMeshNodes.ts +1 -2
- package/lib/solvers/RectDiffGridSolverPipeline/RectDiffGridSolverPipeline.ts +87 -0
- package/lib/solvers/RectDiffSeedingSolver/RectDiffSeedingSolver.ts +516 -0
- package/lib/solvers/RectDiffSeedingSolver/computeCandidates3D.ts +109 -0
- package/lib/solvers/RectDiffSeedingSolver/computeDefaultGridSizes.ts +9 -0
- package/lib/solvers/{rectdiff/candidates.ts → RectDiffSeedingSolver/computeEdgeCandidates3D.ts} +39 -220
- package/lib/solvers/{rectdiff/geometry → RectDiffSeedingSolver}/computeInverseRects.ts +6 -2
- package/lib/solvers/{rectdiff → RectDiffSeedingSolver}/layers.ts +4 -3
- package/lib/solvers/RectDiffSeedingSolver/longestFreeSpanAroundZ.ts +52 -0
- package/lib/utils/buildHardPlacedByLayer.ts +14 -0
- package/lib/{solvers/rectdiff/geometry.ts → utils/expandRectFromSeed.ts} +21 -120
- package/lib/utils/finalizeRects.ts +49 -0
- package/lib/utils/isFullyOccupiedAtPoint.ts +21 -0
- package/lib/utils/rectdiff-geometry.ts +94 -0
- package/lib/utils/resizeSoftOverlaps.ts +74 -0
- package/package.json +1 -1
- package/tests/board-outline.test.ts +2 -1
- package/tests/obstacle-extra-layers.test.ts +1 -1
- package/tests/obstacle-zlayers.test.ts +1 -1
- package/utils/rectsEqual.ts +2 -2
- package/utils/rectsOverlap.ts +2 -2
- package/lib/solvers/RectDiffSolver.ts +0 -231
- package/lib/solvers/rectdiff/engine.ts +0 -481
- /package/lib/solvers/{rectdiff/geometry → RectDiffSeedingSolver}/isPointInPolygon.ts +0 -0
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
import { BaseSolver } from "@tscircuit/solver-utils"
|
|
2
|
+
import type { SimpleRouteJson } from "../../types/srj-types"
|
|
3
|
+
import type { GraphicsObject } from "graphics-debug"
|
|
4
|
+
import type {
|
|
5
|
+
GridFill3DOptions,
|
|
6
|
+
Candidate3D,
|
|
7
|
+
Placed3D,
|
|
8
|
+
XYRect,
|
|
9
|
+
} from "../../rectdiff-types"
|
|
10
|
+
import { computeInverseRects } from "./computeInverseRects"
|
|
11
|
+
import { buildZIndexMap, obstacleToXYRect, obstacleZs } from "./layers"
|
|
12
|
+
import { overlaps } from "../../utils/rectdiff-geometry"
|
|
13
|
+
import { expandRectFromSeed } from "../../utils/expandRectFromSeed"
|
|
14
|
+
import { computeDefaultGridSizes } from "./computeDefaultGridSizes"
|
|
15
|
+
import { computeCandidates3D } from "./computeCandidates3D"
|
|
16
|
+
import { computeEdgeCandidates3D } from "./computeEdgeCandidates3D"
|
|
17
|
+
import { longestFreeSpanAroundZ } from "./longestFreeSpanAroundZ"
|
|
18
|
+
import { allLayerNode } from "../../utils/buildHardPlacedByLayer"
|
|
19
|
+
import { isFullyOccupiedAtPoint } from "../../utils/isFullyOccupiedAtPoint"
|
|
20
|
+
import { resizeSoftOverlaps } from "../../utils/resizeSoftOverlaps"
|
|
21
|
+
|
|
22
|
+
export type RectDiffSeedingSolverInput = {
|
|
23
|
+
simpleRouteJson: SimpleRouteJson
|
|
24
|
+
gridOptions?: Partial<GridFill3DOptions>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* First phase of RectDiff: grid-based seeding and placement.
|
|
29
|
+
*
|
|
30
|
+
* This solver is responsible for walking all grid sizes and producing
|
|
31
|
+
* an initial set of placed rectangles.
|
|
32
|
+
*/
|
|
33
|
+
export class RectDiffSeedingSolver extends BaseSolver {
|
|
34
|
+
// Engine fields (mirrors initState / engine.ts)
|
|
35
|
+
|
|
36
|
+
private srj!: SimpleRouteJson
|
|
37
|
+
private layerNames!: string[]
|
|
38
|
+
private layerCount!: number
|
|
39
|
+
private bounds!: XYRect
|
|
40
|
+
private options!: Required<
|
|
41
|
+
Omit<GridFill3DOptions, "gridSizes" | "maxMultiLayerSpan">
|
|
42
|
+
> & {
|
|
43
|
+
gridSizes: number[]
|
|
44
|
+
maxMultiLayerSpan: number | undefined
|
|
45
|
+
}
|
|
46
|
+
private obstaclesByLayer!: XYRect[][]
|
|
47
|
+
private boardVoidRects!: XYRect[]
|
|
48
|
+
private gridIndex!: number
|
|
49
|
+
private candidates!: Candidate3D[]
|
|
50
|
+
private placed!: Placed3D[]
|
|
51
|
+
private placedByLayer!: XYRect[][]
|
|
52
|
+
private expansionIndex!: number
|
|
53
|
+
private edgeAnalysisDone!: boolean
|
|
54
|
+
private totalSeedsThisGrid!: number
|
|
55
|
+
private consumedSeedsThisGrid!: number
|
|
56
|
+
|
|
57
|
+
constructor(private input: RectDiffSeedingSolverInput) {
|
|
58
|
+
super()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
override _setup() {
|
|
62
|
+
const srj = this.input.simpleRouteJson
|
|
63
|
+
const opts = this.input.gridOptions ?? {}
|
|
64
|
+
|
|
65
|
+
const { layerNames, zIndexByName } = buildZIndexMap(srj)
|
|
66
|
+
const layerCount = Math.max(1, layerNames.length, srj.layerCount || 1)
|
|
67
|
+
|
|
68
|
+
const bounds: XYRect = {
|
|
69
|
+
x: srj.bounds.minX,
|
|
70
|
+
y: srj.bounds.minY,
|
|
71
|
+
width: srj.bounds.maxX - srj.bounds.minX,
|
|
72
|
+
height: srj.bounds.maxY - srj.bounds.minY,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const obstaclesByLayer: XYRect[][] = Array.from(
|
|
76
|
+
{ length: layerCount },
|
|
77
|
+
() => [],
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
let boardVoidRects: XYRect[] = []
|
|
81
|
+
if (srj.outline && srj.outline.length > 2) {
|
|
82
|
+
boardVoidRects = computeInverseRects(bounds, srj.outline as any)
|
|
83
|
+
for (const voidR of boardVoidRects) {
|
|
84
|
+
for (let z = 0; z < layerCount; z++) {
|
|
85
|
+
obstaclesByLayer[z]!.push(voidR)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const obstacle of srj.obstacles ?? []) {
|
|
91
|
+
const rect = obstacleToXYRect(obstacle as any)
|
|
92
|
+
if (!rect) continue
|
|
93
|
+
const zLayers = obstacleZs(obstacle as any, zIndexByName)
|
|
94
|
+
const invalidZs = zLayers.filter((z) => z < 0 || z >= layerCount)
|
|
95
|
+
if (invalidZs.length) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`RectDiff: obstacle uses z-layer indices ${invalidZs.join(",")} outside 0-${
|
|
98
|
+
layerCount - 1
|
|
99
|
+
}`,
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
if (
|
|
103
|
+
(!obstacle.zLayers || obstacle.zLayers.length === 0) &&
|
|
104
|
+
zLayers.length
|
|
105
|
+
) {
|
|
106
|
+
obstacle.zLayers = zLayers
|
|
107
|
+
}
|
|
108
|
+
for (const z of zLayers) obstaclesByLayer[z]!.push(rect)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const trace = Math.max(0.01, srj.minTraceWidth || 0.15)
|
|
112
|
+
const defaults: Required<
|
|
113
|
+
Omit<GridFill3DOptions, "gridSizes" | "maxMultiLayerSpan">
|
|
114
|
+
> & {
|
|
115
|
+
gridSizes: number[]
|
|
116
|
+
maxMultiLayerSpan: number | undefined
|
|
117
|
+
} = {
|
|
118
|
+
gridSizes: [],
|
|
119
|
+
initialCellRatio: 0.2,
|
|
120
|
+
maxAspectRatio: 3,
|
|
121
|
+
minSingle: { width: 2 * trace, height: 2 * trace },
|
|
122
|
+
minMulti: {
|
|
123
|
+
width: 4 * trace,
|
|
124
|
+
height: 4 * trace,
|
|
125
|
+
minLayers: Math.min(2, Math.max(1, srj.layerCount || 1)),
|
|
126
|
+
},
|
|
127
|
+
preferMultiLayer: true,
|
|
128
|
+
maxMultiLayerSpan: undefined,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const options = {
|
|
132
|
+
...defaults,
|
|
133
|
+
...opts,
|
|
134
|
+
gridSizes:
|
|
135
|
+
(opts.gridSizes as number[] | undefined) ??
|
|
136
|
+
// re-use the helper that was previously in engine
|
|
137
|
+
computeDefaultGridSizes(bounds),
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const placedByLayer: XYRect[][] = Array.from(
|
|
141
|
+
{ length: layerCount },
|
|
142
|
+
() => [],
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
this.srj = srj
|
|
146
|
+
this.layerNames = layerNames
|
|
147
|
+
this.layerCount = layerCount
|
|
148
|
+
this.bounds = bounds
|
|
149
|
+
this.options = options
|
|
150
|
+
this.obstaclesByLayer = obstaclesByLayer
|
|
151
|
+
this.boardVoidRects = boardVoidRects
|
|
152
|
+
this.gridIndex = 0
|
|
153
|
+
this.candidates = []
|
|
154
|
+
this.placed = []
|
|
155
|
+
this.placedByLayer = placedByLayer
|
|
156
|
+
this.expansionIndex = 0
|
|
157
|
+
this.edgeAnalysisDone = false
|
|
158
|
+
this.totalSeedsThisGrid = 0
|
|
159
|
+
this.consumedSeedsThisGrid = 0
|
|
160
|
+
|
|
161
|
+
this.stats = {
|
|
162
|
+
gridIndex: this.gridIndex,
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Exactly ONE grid candidate step per call. */
|
|
167
|
+
override _step() {
|
|
168
|
+
this._stepGrid()
|
|
169
|
+
|
|
170
|
+
this.stats.gridIndex = this.gridIndex
|
|
171
|
+
this.stats.placed = this.placed.length
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* One micro-step during the GRID phase: handle exactly one candidate.
|
|
176
|
+
*/
|
|
177
|
+
private _stepGrid(): void {
|
|
178
|
+
const {
|
|
179
|
+
gridSizes,
|
|
180
|
+
initialCellRatio,
|
|
181
|
+
maxAspectRatio,
|
|
182
|
+
minSingle,
|
|
183
|
+
minMulti,
|
|
184
|
+
preferMultiLayer,
|
|
185
|
+
maxMultiLayerSpan,
|
|
186
|
+
} = this.options
|
|
187
|
+
const grid = gridSizes[this.gridIndex]!
|
|
188
|
+
|
|
189
|
+
// Build hard-placed map once per micro-step (cheap)
|
|
190
|
+
const hardPlacedByLayer = allLayerNode({
|
|
191
|
+
layerCount: this.layerCount,
|
|
192
|
+
placed: this.placed,
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
// Ensure candidates exist for this grid
|
|
196
|
+
if (this.candidates.length === 0 && this.consumedSeedsThisGrid === 0) {
|
|
197
|
+
this.candidates = computeCandidates3D({
|
|
198
|
+
bounds: this.bounds,
|
|
199
|
+
gridSize: grid,
|
|
200
|
+
layerCount: this.layerCount,
|
|
201
|
+
obstaclesByLayer: this.obstaclesByLayer,
|
|
202
|
+
placedByLayer: this.placedByLayer,
|
|
203
|
+
hardPlacedByLayer,
|
|
204
|
+
})
|
|
205
|
+
this.totalSeedsThisGrid = this.candidates.length
|
|
206
|
+
this.consumedSeedsThisGrid = 0
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// If no candidates remain, advance grid or run edge pass or switch phase
|
|
210
|
+
if (this.candidates.length === 0) {
|
|
211
|
+
if (this.gridIndex + 1 < gridSizes.length) {
|
|
212
|
+
this.gridIndex += 1
|
|
213
|
+
this.totalSeedsThisGrid = 0
|
|
214
|
+
this.consumedSeedsThisGrid = 0
|
|
215
|
+
return
|
|
216
|
+
} else {
|
|
217
|
+
if (!this.edgeAnalysisDone) {
|
|
218
|
+
const minSize = Math.min(minSingle.width, minSingle.height)
|
|
219
|
+
this.candidates = computeEdgeCandidates3D({
|
|
220
|
+
bounds: this.bounds,
|
|
221
|
+
minSize,
|
|
222
|
+
layerCount: this.layerCount,
|
|
223
|
+
obstaclesByLayer: this.obstaclesByLayer,
|
|
224
|
+
placedByLayer: this.placedByLayer,
|
|
225
|
+
hardPlacedByLayer,
|
|
226
|
+
})
|
|
227
|
+
this.edgeAnalysisDone = true
|
|
228
|
+
this.totalSeedsThisGrid = this.candidates.length
|
|
229
|
+
this.consumedSeedsThisGrid = 0
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
this.solved = true
|
|
233
|
+
this.expansionIndex = 0
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Consume exactly one candidate
|
|
239
|
+
const cand = this.candidates.shift()!
|
|
240
|
+
this.consumedSeedsThisGrid += 1
|
|
241
|
+
|
|
242
|
+
// Evaluate attempts — multi-layer span first (computed ignoring soft nodes)
|
|
243
|
+
const span = longestFreeSpanAroundZ({
|
|
244
|
+
x: cand.x,
|
|
245
|
+
y: cand.y,
|
|
246
|
+
z: cand.z,
|
|
247
|
+
layerCount: this.layerCount,
|
|
248
|
+
minSpan: minMulti.minLayers,
|
|
249
|
+
maxSpan: maxMultiLayerSpan,
|
|
250
|
+
obstaclesByLayer: this.obstaclesByLayer,
|
|
251
|
+
placedByLayer: hardPlacedByLayer,
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
const attempts: Array<{
|
|
255
|
+
kind: "multi" | "single"
|
|
256
|
+
layers: number[]
|
|
257
|
+
minReq: { width: number; height: number }
|
|
258
|
+
}> = []
|
|
259
|
+
|
|
260
|
+
if (span.length >= minMulti.minLayers) {
|
|
261
|
+
attempts.push({
|
|
262
|
+
kind: "multi",
|
|
263
|
+
layers: span,
|
|
264
|
+
minReq: { width: minMulti.width, height: minMulti.height },
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
attempts.push({
|
|
268
|
+
kind: "single",
|
|
269
|
+
layers: [cand.z],
|
|
270
|
+
minReq: { width: minSingle.width, height: minSingle.height },
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
const ordered = preferMultiLayer ? attempts : attempts.reverse()
|
|
274
|
+
|
|
275
|
+
for (const attempt of ordered) {
|
|
276
|
+
// HARD blockers only: obstacles on those layers + full-stack nodes
|
|
277
|
+
const hardBlockers: XYRect[] = []
|
|
278
|
+
for (const z of attempt.layers) {
|
|
279
|
+
if (this.obstaclesByLayer[z])
|
|
280
|
+
hardBlockers.push(...this.obstaclesByLayer[z]!)
|
|
281
|
+
if (hardPlacedByLayer[z]) hardBlockers.push(...hardPlacedByLayer[z]!)
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const rect = expandRectFromSeed({
|
|
285
|
+
startX: cand.x,
|
|
286
|
+
startY: cand.y,
|
|
287
|
+
gridSize: grid,
|
|
288
|
+
bounds: this.bounds,
|
|
289
|
+
blockers: hardBlockers,
|
|
290
|
+
initialCellRatio,
|
|
291
|
+
maxAspectRatio,
|
|
292
|
+
minReq: attempt.minReq,
|
|
293
|
+
})
|
|
294
|
+
if (!rect) continue
|
|
295
|
+
|
|
296
|
+
// Place the new node
|
|
297
|
+
const placed: Placed3D = { rect, zLayers: [...attempt.layers] }
|
|
298
|
+
const newIndex = this.placed.push(placed) - 1
|
|
299
|
+
for (const z of attempt.layers) this.placedByLayer[z]!.push(rect)
|
|
300
|
+
|
|
301
|
+
// New: carve overlapped soft nodes
|
|
302
|
+
resizeSoftOverlaps(
|
|
303
|
+
{
|
|
304
|
+
layerCount: this.layerCount,
|
|
305
|
+
placed: this.placed,
|
|
306
|
+
placedByLayer: this.placedByLayer,
|
|
307
|
+
options: this.options,
|
|
308
|
+
},
|
|
309
|
+
newIndex,
|
|
310
|
+
)
|
|
311
|
+
|
|
312
|
+
// New: relax candidate culling — only drop seeds that became fully occupied
|
|
313
|
+
this.candidates = this.candidates.filter(
|
|
314
|
+
(c) =>
|
|
315
|
+
!isFullyOccupiedAtPoint(
|
|
316
|
+
{
|
|
317
|
+
layerCount: this.layerCount,
|
|
318
|
+
obstaclesByLayer: this.obstaclesByLayer,
|
|
319
|
+
placedByLayer: this.placedByLayer,
|
|
320
|
+
},
|
|
321
|
+
{ x: c.x, y: c.y },
|
|
322
|
+
),
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
return // processed one candidate
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Neither attempt worked; drop this candidate for now.
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Compute solver progress (0 to 1) during GRID phase. */
|
|
332
|
+
computeProgress(): number {
|
|
333
|
+
if (this.solved) {
|
|
334
|
+
return 1
|
|
335
|
+
}
|
|
336
|
+
const grids = this.options.gridSizes.length
|
|
337
|
+
const g = this.gridIndex
|
|
338
|
+
const base = g / (grids + 1) // reserve final slice for expansion
|
|
339
|
+
const denom = Math.max(1, this.totalSeedsThisGrid)
|
|
340
|
+
const frac = denom ? this.consumedSeedsThisGrid / denom : 1
|
|
341
|
+
return Math.min(0.999, base + frac * (1 / (grids + 1)))
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Output the intermediate RectDiff engine data to feed into the
|
|
346
|
+
* expansion phase solver.
|
|
347
|
+
*/
|
|
348
|
+
override getOutput() {
|
|
349
|
+
return {
|
|
350
|
+
srj: this.srj,
|
|
351
|
+
layerNames: this.layerNames,
|
|
352
|
+
layerCount: this.layerCount,
|
|
353
|
+
bounds: this.bounds,
|
|
354
|
+
options: this.options,
|
|
355
|
+
obstaclesByLayer: this.obstaclesByLayer,
|
|
356
|
+
boardVoidRects: this.boardVoidRects,
|
|
357
|
+
gridIndex: this.gridIndex,
|
|
358
|
+
candidates: this.candidates,
|
|
359
|
+
placed: this.placed,
|
|
360
|
+
placedByLayer: this.placedByLayer,
|
|
361
|
+
expansionIndex: this.expansionIndex,
|
|
362
|
+
edgeAnalysisDone: this.edgeAnalysisDone,
|
|
363
|
+
totalSeedsThisGrid: this.totalSeedsThisGrid,
|
|
364
|
+
consumedSeedsThisGrid: this.consumedSeedsThisGrid,
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Get color based on z layer for visualization. */
|
|
369
|
+
private getColorForZLayer(zLayers: number[]): {
|
|
370
|
+
fill: string
|
|
371
|
+
stroke: string
|
|
372
|
+
} {
|
|
373
|
+
const minZ = Math.min(...zLayers)
|
|
374
|
+
const colors = [
|
|
375
|
+
{ fill: "#dbeafe", stroke: "#3b82f6" },
|
|
376
|
+
{ fill: "#fef3c7", stroke: "#f59e0b" },
|
|
377
|
+
{ fill: "#d1fae5", stroke: "#10b981" },
|
|
378
|
+
{ fill: "#e9d5ff", stroke: "#a855f7" },
|
|
379
|
+
{ fill: "#fed7aa", stroke: "#f97316" },
|
|
380
|
+
{ fill: "#fecaca", stroke: "#ef4444" },
|
|
381
|
+
] as const
|
|
382
|
+
return colors[minZ % colors.length]!
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Visualization focused on the grid seeding phase. */
|
|
386
|
+
override visualize(): GraphicsObject {
|
|
387
|
+
const rects: NonNullable<GraphicsObject["rects"]> = []
|
|
388
|
+
const points: NonNullable<GraphicsObject["points"]> = []
|
|
389
|
+
const lines: NonNullable<GraphicsObject["lines"]> = []
|
|
390
|
+
|
|
391
|
+
const srj = this.srj ?? this.input.simpleRouteJson
|
|
392
|
+
|
|
393
|
+
// Board bounds - use srj bounds which is always available
|
|
394
|
+
const boardBounds = {
|
|
395
|
+
minX: srj.bounds.minX,
|
|
396
|
+
maxX: srj.bounds.maxX,
|
|
397
|
+
minY: srj.bounds.minY,
|
|
398
|
+
maxY: srj.bounds.maxY,
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// board or outline
|
|
402
|
+
if (srj.outline && srj.outline.length > 1) {
|
|
403
|
+
lines.push({
|
|
404
|
+
points: [...srj.outline, srj.outline[0] as { x: number; y: number }],
|
|
405
|
+
strokeColor: "#111827",
|
|
406
|
+
strokeWidth: 0.01,
|
|
407
|
+
label: "outline",
|
|
408
|
+
})
|
|
409
|
+
} else {
|
|
410
|
+
rects.push({
|
|
411
|
+
center: {
|
|
412
|
+
x: (boardBounds.minX + boardBounds.maxX) / 2,
|
|
413
|
+
y: (boardBounds.minY + boardBounds.maxY) / 2,
|
|
414
|
+
},
|
|
415
|
+
width: boardBounds.maxX - boardBounds.minX,
|
|
416
|
+
height: boardBounds.maxY - boardBounds.minY,
|
|
417
|
+
fill: "none",
|
|
418
|
+
stroke: "#111827",
|
|
419
|
+
label: "board",
|
|
420
|
+
})
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// obstacles (rect & oval as bounding boxes)
|
|
424
|
+
for (const obstacle of srj.obstacles ?? []) {
|
|
425
|
+
if (obstacle.type === "rect" || obstacle.type === "oval") {
|
|
426
|
+
rects.push({
|
|
427
|
+
center: { x: obstacle.center.x, y: obstacle.center.y },
|
|
428
|
+
width: obstacle.width,
|
|
429
|
+
height: obstacle.height,
|
|
430
|
+
fill: "#fee2e2",
|
|
431
|
+
stroke: "#ef4444",
|
|
432
|
+
layer: "obstacle",
|
|
433
|
+
label: "obstacle",
|
|
434
|
+
})
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// board void rects (early visualization of mask)
|
|
439
|
+
if (this.boardVoidRects) {
|
|
440
|
+
let outlineBBox: {
|
|
441
|
+
x: number
|
|
442
|
+
y: number
|
|
443
|
+
width: number
|
|
444
|
+
height: number
|
|
445
|
+
} | null = null
|
|
446
|
+
|
|
447
|
+
if (srj.outline && srj.outline.length > 0) {
|
|
448
|
+
const xs = srj.outline.map((p: { x: number; y: number }) => p.x)
|
|
449
|
+
const ys = srj.outline.map((p: { x: number; y: number }) => p.y)
|
|
450
|
+
const minX = Math.min(...xs)
|
|
451
|
+
const minY = Math.min(...ys)
|
|
452
|
+
outlineBBox = {
|
|
453
|
+
x: minX,
|
|
454
|
+
y: minY,
|
|
455
|
+
width: Math.max(...xs) - minX,
|
|
456
|
+
height: Math.max(...ys) - minY,
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
for (const r of this.boardVoidRects) {
|
|
461
|
+
if (outlineBBox && !overlaps(r, outlineBBox)) {
|
|
462
|
+
continue
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
rects.push({
|
|
466
|
+
center: { x: r.x + r.width / 2, y: r.y + r.height / 2 },
|
|
467
|
+
width: r.width,
|
|
468
|
+
height: r.height,
|
|
469
|
+
fill: "rgba(0, 0, 0, 0.5)",
|
|
470
|
+
stroke: "none",
|
|
471
|
+
label: "void",
|
|
472
|
+
})
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// candidate positions (where expansion will later start from)
|
|
477
|
+
if (this.candidates?.length) {
|
|
478
|
+
for (const cand of this.candidates) {
|
|
479
|
+
points.push({
|
|
480
|
+
x: cand.x,
|
|
481
|
+
y: cand.y,
|
|
482
|
+
fill: "#9333ea",
|
|
483
|
+
stroke: "#6b21a8",
|
|
484
|
+
label: `z:${cand.z}`,
|
|
485
|
+
} as any)
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// current placements (streaming) during grid fill
|
|
490
|
+
if (this.placed?.length) {
|
|
491
|
+
for (const placement of this.placed) {
|
|
492
|
+
const colors = this.getColorForZLayer(placement.zLayers)
|
|
493
|
+
rects.push({
|
|
494
|
+
center: {
|
|
495
|
+
x: placement.rect.x + placement.rect.width / 2,
|
|
496
|
+
y: placement.rect.y + placement.rect.height / 2,
|
|
497
|
+
},
|
|
498
|
+
width: placement.rect.width,
|
|
499
|
+
height: placement.rect.height,
|
|
500
|
+
fill: colors.fill,
|
|
501
|
+
stroke: colors.stroke,
|
|
502
|
+
layer: `z${placement.zLayers.join(",")}`,
|
|
503
|
+
label: `free\nz:${placement.zLayers.join(",")}`,
|
|
504
|
+
})
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
return {
|
|
509
|
+
title: "RectDiff Grid",
|
|
510
|
+
coordinateSystem: "cartesian",
|
|
511
|
+
rects,
|
|
512
|
+
points,
|
|
513
|
+
lines,
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
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"
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Compute candidate seed points for a given grid size.
|
|
8
|
+
*/
|
|
9
|
+
export function computeCandidates3D(params: {
|
|
10
|
+
bounds: XYRect
|
|
11
|
+
gridSize: number
|
|
12
|
+
layerCount: number
|
|
13
|
+
obstaclesByLayer: XYRect[][]
|
|
14
|
+
placedByLayer: XYRect[][]
|
|
15
|
+
hardPlacedByLayer: XYRect[][]
|
|
16
|
+
}): Candidate3D[] {
|
|
17
|
+
const {
|
|
18
|
+
bounds,
|
|
19
|
+
gridSize,
|
|
20
|
+
layerCount,
|
|
21
|
+
obstaclesByLayer,
|
|
22
|
+
placedByLayer,
|
|
23
|
+
hardPlacedByLayer,
|
|
24
|
+
} = params
|
|
25
|
+
const out = new Map<string, Candidate3D>() // key by (x,y)
|
|
26
|
+
|
|
27
|
+
for (let x = bounds.x; x < bounds.x + bounds.width; x += gridSize) {
|
|
28
|
+
for (let y = bounds.y; y < bounds.y + bounds.height; y += gridSize) {
|
|
29
|
+
// Skip outermost row/col (stable with prior behavior)
|
|
30
|
+
if (
|
|
31
|
+
Math.abs(x - bounds.x) < EPS ||
|
|
32
|
+
Math.abs(y - bounds.y) < EPS ||
|
|
33
|
+
x > bounds.x + bounds.width - gridSize - EPS ||
|
|
34
|
+
y > bounds.y + bounds.height - gridSize - EPS
|
|
35
|
+
) {
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// New rule: Only drop if EVERY layer is occupied (by obstacle or node)
|
|
40
|
+
if (
|
|
41
|
+
isFullyOccupiedAtPoint(
|
|
42
|
+
{
|
|
43
|
+
layerCount,
|
|
44
|
+
obstaclesByLayer,
|
|
45
|
+
placedByLayer,
|
|
46
|
+
},
|
|
47
|
+
{ x, y },
|
|
48
|
+
)
|
|
49
|
+
)
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
// Find the best (longest) free contiguous Z span at (x,y) ignoring soft nodes.
|
|
53
|
+
let bestSpan: number[] = []
|
|
54
|
+
let bestZ = 0
|
|
55
|
+
for (let z = 0; z < layerCount; z++) {
|
|
56
|
+
const s = longestFreeSpanAroundZ({
|
|
57
|
+
x,
|
|
58
|
+
y,
|
|
59
|
+
z,
|
|
60
|
+
layerCount,
|
|
61
|
+
minSpan: 1,
|
|
62
|
+
maxSpan: undefined,
|
|
63
|
+
obstaclesByLayer,
|
|
64
|
+
placedByLayer: hardPlacedByLayer,
|
|
65
|
+
})
|
|
66
|
+
if (s.length > bestSpan.length) {
|
|
67
|
+
bestSpan = s
|
|
68
|
+
bestZ = z
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const anchorZ = bestSpan.length
|
|
72
|
+
? bestSpan[Math.floor(bestSpan.length / 2)]!
|
|
73
|
+
: bestZ
|
|
74
|
+
|
|
75
|
+
// Distance heuristic against hard blockers only (obstacles + full-stack)
|
|
76
|
+
const hardAtZ = [
|
|
77
|
+
...(obstaclesByLayer[anchorZ] ?? []),
|
|
78
|
+
...(hardPlacedByLayer[anchorZ] ?? []),
|
|
79
|
+
]
|
|
80
|
+
const d = Math.min(
|
|
81
|
+
distancePointToRectEdges({ x, y }, bounds),
|
|
82
|
+
...(hardAtZ.length
|
|
83
|
+
? hardAtZ.map((b) => distancePointToRectEdges({ x, y }, b))
|
|
84
|
+
: [Infinity]),
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
const k = `${x.toFixed(6)}|${y.toFixed(6)}`
|
|
88
|
+
const cand: Candidate3D = {
|
|
89
|
+
x,
|
|
90
|
+
y,
|
|
91
|
+
z: anchorZ,
|
|
92
|
+
distance: d,
|
|
93
|
+
zSpanLen: bestSpan.length,
|
|
94
|
+
}
|
|
95
|
+
const prev = out.get(k)
|
|
96
|
+
if (
|
|
97
|
+
!prev ||
|
|
98
|
+
cand.zSpanLen! > (prev.zSpanLen ?? 0) ||
|
|
99
|
+
(cand.zSpanLen === prev.zSpanLen && cand.distance > prev.distance)
|
|
100
|
+
) {
|
|
101
|
+
out.set(k, cand)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const arr = Array.from(out.values())
|
|
107
|
+
arr.sort((a, b) => b.zSpanLen! - a.zSpanLen! || b.distance - a.distance)
|
|
108
|
+
return arr
|
|
109
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { XYRect } from "../../rectdiff-types"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Compute default grid sizes based on bounds.
|
|
5
|
+
*/
|
|
6
|
+
export function computeDefaultGridSizes(bounds: XYRect): number[] {
|
|
7
|
+
const ref = Math.max(bounds.width, bounds.height)
|
|
8
|
+
return [ref / 8, ref / 16, ref / 32]
|
|
9
|
+
}
|