@tscircuit/rectdiff 0.0.12 → 0.0.14

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 (37) hide show
  1. package/dist/index.d.ts +169 -27
  2. package/dist/index.js +2012 -1672
  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 +252 -0
  9. package/lib/solvers/{rectdiff → RectDiffExpansionSolver}/rectsToMeshNodes.ts +1 -2
  10. package/lib/solvers/RectDiffGridSolverPipeline/RectDiffGridSolverPipeline.ts +106 -0
  11. package/lib/solvers/RectDiffGridSolverPipeline/buildObstacleIndexes.ts +70 -0
  12. package/lib/solvers/RectDiffSeedingSolver/RectDiffSeedingSolver.ts +487 -0
  13. package/lib/solvers/RectDiffSeedingSolver/computeCandidates3D.ts +109 -0
  14. package/lib/solvers/RectDiffSeedingSolver/computeDefaultGridSizes.ts +9 -0
  15. package/lib/solvers/{rectdiff/candidates.ts → RectDiffSeedingSolver/computeEdgeCandidates3D.ts} +44 -225
  16. package/lib/solvers/{rectdiff/geometry → RectDiffSeedingSolver}/computeInverseRects.ts +6 -2
  17. package/lib/solvers/{rectdiff → RectDiffSeedingSolver}/layers.ts +4 -3
  18. package/lib/solvers/RectDiffSeedingSolver/longestFreeSpanAroundZ.ts +60 -0
  19. package/lib/types/capacity-mesh-types.ts +9 -0
  20. package/lib/utils/buildHardPlacedByLayer.ts +14 -0
  21. package/lib/{solvers/rectdiff/geometry.ts → utils/expandRectFromSeed.ts} +21 -120
  22. package/lib/utils/finalizeRects.ts +54 -0
  23. package/lib/utils/isFullyOccupiedAtPoint.ts +28 -0
  24. package/lib/utils/rectToTree.ts +10 -0
  25. package/lib/utils/rectdiff-geometry.ts +94 -0
  26. package/lib/utils/resizeSoftOverlaps.ts +103 -0
  27. package/lib/utils/sameTreeRect.ts +7 -0
  28. package/package.json +1 -1
  29. package/tests/board-outline.test.ts +2 -1
  30. package/tests/examples/example01.test.tsx +18 -1
  31. package/tests/obstacle-extra-layers.test.ts +1 -1
  32. package/tests/obstacle-zlayers.test.ts +1 -1
  33. package/utils/rectsEqual.ts +2 -2
  34. package/utils/rectsOverlap.ts +2 -2
  35. package/lib/solvers/RectDiffSolver.ts +0 -231
  36. package/lib/solvers/rectdiff/engine.ts +0 -481
  37. /package/lib/solvers/{rectdiff/geometry → RectDiffSeedingSolver}/isPointInPolygon.ts +0 -0
@@ -1,231 +0,0 @@
1
- // lib/solvers/RectDiffSolver.ts
2
- import { BaseSolver, BasePipelineSolver } from "@tscircuit/solver-utils"
3
- import type { SimpleRouteJson } from "../types/srj-types"
4
- import type { GraphicsObject } from "graphics-debug"
5
- import type { CapacityMeshNode } from "../types/capacity-mesh-types"
6
-
7
- import type { GridFill3DOptions, RectDiffState } from "./rectdiff/types"
8
- import {
9
- initState,
10
- stepGrid,
11
- stepExpansion,
12
- finalizeRects,
13
- computeProgress,
14
- } from "./rectdiff/engine"
15
- import { rectsToMeshNodes } from "./rectdiff/rectsToMeshNodes"
16
- import { overlaps } from "./rectdiff/geometry"
17
-
18
- /**
19
- * A streaming, one-step-per-iteration solver for capacity mesh generation.
20
- */
21
- export class RectDiffSolver extends BaseSolver {
22
- private srj: SimpleRouteJson
23
- private gridOptions: Partial<GridFill3DOptions>
24
- private state!: RectDiffState
25
- private _meshNodes: CapacityMeshNode[] = []
26
-
27
- constructor(opts: {
28
- simpleRouteJson: SimpleRouteJson
29
- gridOptions?: Partial<GridFill3DOptions>
30
- }) {
31
- super()
32
- this.srj = opts.simpleRouteJson
33
- this.gridOptions = opts.gridOptions ?? {}
34
- }
35
-
36
- override _setup() {
37
- this.state = initState(this.srj, this.gridOptions)
38
- this.stats = {
39
- phase: this.state.phase,
40
- gridIndex: this.state.gridIndex,
41
- }
42
- }
43
-
44
- /** Exactly ONE small step per call. */
45
- override _step() {
46
- if (this.state.phase === "GRID") {
47
- stepGrid(this.state)
48
- } else if (this.state.phase === "EXPANSION") {
49
- stepExpansion(this.state)
50
- } else if (this.state.phase === "GAP_FILL") {
51
- this.state.phase = "DONE"
52
- } else if (this.state.phase === "DONE") {
53
- // Finalize once
54
- if (!this.solved) {
55
- const rects = finalizeRects(this.state)
56
- this._meshNodes = rectsToMeshNodes(rects)
57
- this.solved = true
58
- }
59
- return
60
- }
61
-
62
- // Lightweight stats for debugger
63
- this.stats.phase = this.state.phase
64
- this.stats.gridIndex = this.state.gridIndex
65
- this.stats.placed = this.state.placed.length
66
- }
67
-
68
- /** Compute solver progress (0 to 1). */
69
- computeProgress(): number {
70
- if (this.solved || this.state.phase === "DONE") {
71
- return 1
72
- }
73
- return computeProgress(this.state)
74
- }
75
-
76
- override getOutput(): { meshNodes: CapacityMeshNode[] } {
77
- return { meshNodes: this._meshNodes }
78
- }
79
-
80
- /** Get color based on z layer for visualization. */
81
- private getColorForZLayer(zLayers: number[]): {
82
- fill: string
83
- stroke: string
84
- } {
85
- const minZ = Math.min(...zLayers)
86
- const colors = [
87
- { fill: "#dbeafe", stroke: "#3b82f6" },
88
- { fill: "#fef3c7", stroke: "#f59e0b" },
89
- { fill: "#d1fae5", stroke: "#10b981" },
90
- { fill: "#e9d5ff", stroke: "#a855f7" },
91
- { fill: "#fed7aa", stroke: "#f97316" },
92
- { fill: "#fecaca", stroke: "#ef4444" },
93
- ]
94
- return colors[minZ % colors.length]!
95
- }
96
-
97
- /** Streaming visualization: board + obstacles + current placements. */
98
- override visualize(): GraphicsObject {
99
- const rects: NonNullable<GraphicsObject["rects"]> = []
100
- const points: NonNullable<GraphicsObject["points"]> = []
101
- const lines: NonNullable<GraphicsObject["lines"]> = [] // Initialize lines array
102
-
103
- // Board bounds - use srj bounds which is always available
104
- const boardBounds = {
105
- minX: this.srj.bounds.minX,
106
- maxX: this.srj.bounds.maxX,
107
- minY: this.srj.bounds.minY,
108
- maxY: this.srj.bounds.maxY,
109
- }
110
-
111
- // board or outline
112
- if (this.srj.outline && this.srj.outline.length > 1) {
113
- lines.push({
114
- points: [...this.srj.outline, this.srj.outline[0]!], // Close the loop by adding the first point again
115
- strokeColor: "#111827",
116
- strokeWidth: 0.01,
117
- label: "outline",
118
- })
119
- } else {
120
- rects.push({
121
- center: {
122
- x: (boardBounds.minX + boardBounds.maxX) / 2,
123
- y: (boardBounds.minY + boardBounds.maxY) / 2,
124
- },
125
- width: boardBounds.maxX - boardBounds.minX,
126
- height: boardBounds.maxY - boardBounds.minY,
127
- fill: "none",
128
- stroke: "#111827",
129
- label: "board",
130
- })
131
- }
132
-
133
- // obstacles (rect & oval as bounding boxes)
134
- for (const obstacle of this.srj.obstacles ?? []) {
135
- if (obstacle.type === "rect" || obstacle.type === "oval") {
136
- rects.push({
137
- center: { x: obstacle.center.x, y: obstacle.center.y },
138
- width: obstacle.width,
139
- height: obstacle.height,
140
- fill: "#fee2e2",
141
- stroke: "#ef4444",
142
- layer: "obstacle",
143
- label: "obstacle",
144
- })
145
- }
146
- }
147
-
148
- // board void rects
149
- if (this.state?.boardVoidRects) {
150
- // If outline exists, compute its bbox to hide outer padding voids
151
- let outlineBBox: {
152
- x: number
153
- y: number
154
- width: number
155
- height: number
156
- } | null = null
157
-
158
- if (this.srj.outline && this.srj.outline.length > 0) {
159
- const xs = this.srj.outline.map((p) => p.x)
160
- const ys = this.srj.outline.map((p) => p.y)
161
- const minX = Math.min(...xs)
162
- const minY = Math.min(...ys)
163
- outlineBBox = {
164
- x: minX,
165
- y: minY,
166
- width: Math.max(...xs) - minX,
167
- height: Math.max(...ys) - minY,
168
- }
169
- }
170
-
171
- for (const r of this.state.boardVoidRects) {
172
- // If we have an outline, only show voids that overlap its bbox (hides outer padding)
173
- if (outlineBBox && !overlaps(r, outlineBBox)) {
174
- continue
175
- }
176
-
177
- rects.push({
178
- center: { x: r.x + r.width / 2, y: r.y + r.height / 2 },
179
- width: r.width,
180
- height: r.height,
181
- fill: "rgba(0, 0, 0, 0.5)",
182
- stroke: "none",
183
- label: "void",
184
- })
185
- }
186
- }
187
-
188
- // candidate positions (where expansion started from)
189
- if (this.state?.candidates?.length) {
190
- for (const cand of this.state.candidates) {
191
- points.push({
192
- x: cand.x,
193
- y: cand.y,
194
- fill: "#9333ea",
195
- stroke: "#6b21a8",
196
- label: `z:${cand.z}`,
197
- } as any)
198
- }
199
- }
200
-
201
- // current placements (streaming) if not yet solved
202
- if (this.state?.placed?.length) {
203
- for (const p of this.state.placed) {
204
- const colors = this.getColorForZLayer(p.zLayers)
205
- rects.push({
206
- center: {
207
- x: p.rect.x + p.rect.width / 2,
208
- y: p.rect.y + p.rect.height / 2,
209
- },
210
- width: p.rect.width,
211
- height: p.rect.height,
212
- fill: colors.fill,
213
- stroke: colors.stroke,
214
- layer: `z${p.zLayers.join(",")}`,
215
- label: `free\nz:${p.zLayers.join(",")}`,
216
- })
217
- }
218
- }
219
-
220
- return {
221
- title: `RectDiff (${this.state?.phase ?? "init"})`,
222
- coordinateSystem: "cartesian",
223
- rects,
224
- points,
225
- lines, // Include lines in the returned GraphicsObject
226
- }
227
- }
228
- }
229
-
230
- // Re-export types for convenience
231
- export type { GridFill3DOptions } from "./rectdiff/types"
@@ -1,481 +0,0 @@
1
- // lib/solvers/rectdiff/engine.ts
2
- import type {
3
- GridFill3DOptions,
4
- Placed3D,
5
- Rect3d,
6
- RectDiffState,
7
- XYRect,
8
- } from "./types"
9
- import type { SimpleRouteJson } from "../../types/srj-types"
10
- import {
11
- computeCandidates3D,
12
- computeDefaultGridSizes,
13
- computeEdgeCandidates3D,
14
- longestFreeSpanAroundZ,
15
- } from "./candidates"
16
- import {
17
- EPS,
18
- containsPoint,
19
- expandRectFromSeed,
20
- overlaps,
21
- subtractRect2D,
22
- } from "./geometry"
23
- import { computeInverseRects } from "./geometry/computeInverseRects"
24
- import { buildZIndexMap, obstacleToXYRect, obstacleZs } from "./layers"
25
-
26
- /**
27
- * Initialize the RectDiff solver state from SimpleRouteJson.
28
- */
29
- export function initState(
30
- srj: SimpleRouteJson,
31
- opts: Partial<GridFill3DOptions>,
32
- ): RectDiffState {
33
- const { layerNames, zIndexByName } = buildZIndexMap(srj)
34
- const layerCount = Math.max(1, layerNames.length, srj.layerCount || 1)
35
-
36
- const bounds: XYRect = {
37
- x: srj.bounds.minX,
38
- y: srj.bounds.minY,
39
- width: srj.bounds.maxX - srj.bounds.minX,
40
- height: srj.bounds.maxY - srj.bounds.minY,
41
- }
42
-
43
- // Obstacles per layer
44
- const obstaclesByLayer: XYRect[][] = Array.from(
45
- { length: layerCount },
46
- () => [],
47
- )
48
-
49
- // Compute void rects from outline if present
50
- let boardVoidRects: XYRect[] = []
51
- if (srj.outline && srj.outline.length > 2) {
52
- boardVoidRects = computeInverseRects(bounds, srj.outline)
53
- // Add void rects as obstacles to ALL layers
54
- for (const voidR of boardVoidRects) {
55
- for (let z = 0; z < layerCount; z++) {
56
- obstaclesByLayer[z]!.push(voidR)
57
- }
58
- }
59
- }
60
-
61
- for (const obstacle of srj.obstacles ?? []) {
62
- const rect = obstacleToXYRect(obstacle)
63
- if (!rect) continue
64
- const zLayers = obstacleZs(obstacle, zIndexByName)
65
- const invalidZs = zLayers.filter((z) => z < 0 || z >= layerCount)
66
- if (invalidZs.length) {
67
- throw new Error(
68
- `RectDiffSolver: obstacle uses z-layer indices ${invalidZs.join(
69
- ",",
70
- )} outside 0-${layerCount - 1}`,
71
- )
72
- }
73
- // Persist normalized zLayers back onto the shared SRJ so downstream solvers see them.
74
- if ((!obstacle.zLayers || obstacle.zLayers.length === 0) && zLayers.length)
75
- obstacle.zLayers = zLayers
76
- for (const z of zLayers) obstaclesByLayer[z]!.push(rect)
77
- }
78
-
79
- const trace = Math.max(0.01, srj.minTraceWidth || 0.15)
80
- const defaults: Required<
81
- Omit<GridFill3DOptions, "gridSizes" | "maxMultiLayerSpan">
82
- > & {
83
- gridSizes: number[]
84
- maxMultiLayerSpan: number | undefined
85
- } = {
86
- gridSizes: computeDefaultGridSizes(bounds),
87
- initialCellRatio: 0.2,
88
- maxAspectRatio: 3,
89
- minSingle: { width: 2 * trace, height: 2 * trace },
90
- minMulti: {
91
- width: 4 * trace,
92
- height: 4 * trace,
93
- minLayers: Math.min(2, Math.max(1, srj.layerCount || 1)),
94
- },
95
- preferMultiLayer: true,
96
- maxMultiLayerSpan: undefined,
97
- }
98
-
99
- const options = {
100
- ...defaults,
101
- ...opts,
102
- gridSizes: opts.gridSizes ?? defaults.gridSizes,
103
- }
104
-
105
- const placedByLayer: XYRect[][] = Array.from({ length: layerCount }, () => [])
106
-
107
- // Begin at the **first** grid level; candidates computed lazily on first step
108
- return {
109
- srj,
110
- layerNames,
111
- layerCount,
112
- bounds,
113
- options,
114
- obstaclesByLayer,
115
- boardVoidRects,
116
- phase: "GRID",
117
- gridIndex: 0,
118
- candidates: [],
119
- placed: [],
120
- placedByLayer,
121
- expansionIndex: 0,
122
- edgeAnalysisDone: false,
123
- totalSeedsThisGrid: 0,
124
- consumedSeedsThisGrid: 0,
125
- }
126
- }
127
-
128
- /**
129
- * Build per-layer list of "hard" placed rects (nodes spanning all layers).
130
- */
131
- function buildHardPlacedByLayer(state: RectDiffState): XYRect[][] {
132
- const out: XYRect[][] = Array.from({ length: state.layerCount }, () => [])
133
- for (const p of state.placed) {
134
- if (p.zLayers.length >= state.layerCount) {
135
- for (const z of p.zLayers) out[z]!.push(p.rect)
136
- }
137
- }
138
- return out
139
- }
140
-
141
- /**
142
- * Check if a point is occupied on ALL layers.
143
- */
144
- function isFullyOccupiedAtPoint(
145
- state: RectDiffState,
146
- point: { x: number; y: number },
147
- ): boolean {
148
- for (let z = 0; z < state.layerCount; z++) {
149
- const obs = state.obstaclesByLayer[z] ?? []
150
- const placed = state.placedByLayer[z] ?? []
151
- const occ =
152
- obs.some((b) => containsPoint(b, point.x, point.y)) ||
153
- placed.some((b) => containsPoint(b, point.x, point.y))
154
- if (!occ) return false
155
- }
156
- return true
157
- }
158
-
159
- /**
160
- * Shrink/split any soft (non-full-stack) nodes overlapped by the newcomer.
161
- */
162
- function resizeSoftOverlaps(state: RectDiffState, newIndex: number) {
163
- const newcomer = state.placed[newIndex]!
164
- const { rect: newR, zLayers: newZs } = newcomer
165
- const layerCount = state.layerCount
166
-
167
- const removeIdx: number[] = []
168
- const toAdd: typeof state.placed = []
169
-
170
- for (let i = 0; i < state.placed.length; i++) {
171
- if (i === newIndex) continue
172
- const old = state.placed[i]!
173
- // Protect full-stack nodes
174
- if (old.zLayers.length >= layerCount) continue
175
-
176
- const sharedZ = old.zLayers.filter((z) => newZs.includes(z))
177
- if (sharedZ.length === 0) continue
178
- if (!overlaps(old.rect, newR)) continue
179
-
180
- // Carve the overlap on the shared layers
181
- const parts = subtractRect2D(old.rect, newR)
182
-
183
- // We will replace `old` entirely; re-add unaffected layers (same rect object).
184
- removeIdx.push(i)
185
-
186
- const unaffectedZ = old.zLayers.filter((z) => !newZs.includes(z))
187
- if (unaffectedZ.length > 0) {
188
- toAdd.push({ rect: old.rect, zLayers: unaffectedZ })
189
- }
190
-
191
- // Re-add carved pieces for affected layers, dropping tiny slivers
192
- const minW = Math.min(
193
- state.options.minSingle.width,
194
- state.options.minMulti.width,
195
- )
196
- const minH = Math.min(
197
- state.options.minSingle.height,
198
- state.options.minMulti.height,
199
- )
200
- for (const p of parts) {
201
- if (p.width + EPS >= minW && p.height + EPS >= minH) {
202
- toAdd.push({ rect: p, zLayers: sharedZ.slice() })
203
- }
204
- }
205
- }
206
-
207
- // Remove (and clear placedByLayer)
208
- removeIdx
209
- .sort((a, b) => b - a)
210
- .forEach((idx) => {
211
- const rem = state.placed.splice(idx, 1)[0]!
212
- for (const z of rem.zLayers) {
213
- const arr = state.placedByLayer[z]!
214
- const j = arr.findIndex((r) => r === rem.rect)
215
- if (j >= 0) arr.splice(j, 1)
216
- }
217
- })
218
-
219
- // Add replacements
220
- for (const p of toAdd) {
221
- state.placed.push(p)
222
- for (const z of p.zLayers) state.placedByLayer[z]!.push(p.rect)
223
- }
224
- }
225
-
226
- /**
227
- * One micro-step during the GRID phase: handle exactly one candidate.
228
- */
229
- export function stepGrid(state: RectDiffState): void {
230
- const {
231
- gridSizes,
232
- initialCellRatio,
233
- maxAspectRatio,
234
- minSingle,
235
- minMulti,
236
- preferMultiLayer,
237
- maxMultiLayerSpan,
238
- } = state.options
239
- const grid = gridSizes[state.gridIndex]!
240
-
241
- // Build hard-placed map once per micro-step (cheap)
242
- const hardPlacedByLayer = buildHardPlacedByLayer(state)
243
-
244
- // Ensure candidates exist for this grid
245
- if (state.candidates.length === 0 && state.consumedSeedsThisGrid === 0) {
246
- state.candidates = computeCandidates3D({
247
- bounds: state.bounds,
248
- gridSize: grid,
249
- layerCount: state.layerCount,
250
- obstaclesByLayer: state.obstaclesByLayer,
251
- placedByLayer: state.placedByLayer,
252
- hardPlacedByLayer,
253
- })
254
- state.totalSeedsThisGrid = state.candidates.length
255
- state.consumedSeedsThisGrid = 0
256
- }
257
-
258
- // If no candidates remain, advance grid or run edge pass or switch phase
259
- if (state.candidates.length === 0) {
260
- if (state.gridIndex + 1 < gridSizes.length) {
261
- state.gridIndex += 1
262
- state.totalSeedsThisGrid = 0
263
- state.consumedSeedsThisGrid = 0
264
- return
265
- } else {
266
- if (!state.edgeAnalysisDone) {
267
- const minSize = Math.min(minSingle.width, minSingle.height)
268
- state.candidates = computeEdgeCandidates3D({
269
- bounds: state.bounds,
270
- minSize,
271
- layerCount: state.layerCount,
272
- obstaclesByLayer: state.obstaclesByLayer,
273
- placedByLayer: state.placedByLayer,
274
- hardPlacedByLayer,
275
- })
276
- state.edgeAnalysisDone = true
277
- state.totalSeedsThisGrid = state.candidates.length
278
- state.consumedSeedsThisGrid = 0
279
- return
280
- }
281
- state.phase = "EXPANSION"
282
- state.expansionIndex = 0
283
- return
284
- }
285
- }
286
-
287
- // Consume exactly one candidate
288
- const cand = state.candidates.shift()!
289
- state.consumedSeedsThisGrid += 1
290
-
291
- // Evaluate attempts — multi-layer span first (computed ignoring soft nodes)
292
- const span = longestFreeSpanAroundZ({
293
- x: cand.x,
294
- y: cand.y,
295
- z: cand.z,
296
- layerCount: state.layerCount,
297
- minSpan: minMulti.minLayers,
298
- maxSpan: maxMultiLayerSpan,
299
- obstaclesByLayer: state.obstaclesByLayer,
300
- placedByLayer: hardPlacedByLayer,
301
- })
302
-
303
- const attempts: Array<{
304
- kind: "multi" | "single"
305
- layers: number[]
306
- minReq: { width: number; height: number }
307
- }> = []
308
-
309
- if (span.length >= minMulti.minLayers) {
310
- attempts.push({
311
- kind: "multi",
312
- layers: span,
313
- minReq: { width: minMulti.width, height: minMulti.height },
314
- })
315
- }
316
- attempts.push({
317
- kind: "single",
318
- layers: [cand.z],
319
- minReq: { width: minSingle.width, height: minSingle.height },
320
- })
321
-
322
- const ordered = preferMultiLayer ? attempts : attempts.reverse()
323
-
324
- for (const attempt of ordered) {
325
- // HARD blockers only: obstacles on those layers + full-stack nodes
326
- const hardBlockers: XYRect[] = []
327
- for (const z of attempt.layers) {
328
- if (state.obstaclesByLayer[z])
329
- hardBlockers.push(...state.obstaclesByLayer[z]!)
330
- if (hardPlacedByLayer[z]) hardBlockers.push(...hardPlacedByLayer[z]!)
331
- }
332
-
333
- const rect = expandRectFromSeed({
334
- startX: cand.x,
335
- startY: cand.y,
336
- gridSize: grid,
337
- bounds: state.bounds,
338
- blockers: hardBlockers,
339
- initialCellRatio,
340
- maxAspectRatio,
341
- minReq: attempt.minReq,
342
- })
343
- if (!rect) continue
344
-
345
- // Place the new node
346
- const placed: Placed3D = { rect, zLayers: [...attempt.layers] }
347
- const newIndex = state.placed.push(placed) - 1
348
- for (const z of attempt.layers) state.placedByLayer[z]!.push(rect)
349
-
350
- // New: carve overlapped soft nodes
351
- resizeSoftOverlaps(state, newIndex)
352
-
353
- // New: relax candidate culling — only drop seeds that became fully occupied
354
- state.candidates = state.candidates.filter(
355
- (c) => !isFullyOccupiedAtPoint(state, { x: c.x, y: c.y }),
356
- )
357
-
358
- return // processed one candidate
359
- }
360
-
361
- // Neither attempt worked; drop this candidate for now.
362
- }
363
-
364
- /**
365
- * One micro-step during the EXPANSION phase: expand exactly one placed rect.
366
- */
367
- export function stepExpansion(state: RectDiffState): void {
368
- if (state.expansionIndex >= state.placed.length) {
369
- // Transition to gap fill phase instead of done
370
- state.phase = "GAP_FILL"
371
- return
372
- }
373
-
374
- const idx = state.expansionIndex
375
- const p = state.placed[idx]!
376
- const lastGrid = state.options.gridSizes[state.options.gridSizes.length - 1]!
377
-
378
- const hardPlacedByLayer = buildHardPlacedByLayer(state)
379
-
380
- // HARD blockers only: obstacles on p.zLayers + full-stack nodes
381
- const hardBlockers: XYRect[] = []
382
- for (const z of p.zLayers) {
383
- hardBlockers.push(...(state.obstaclesByLayer[z] ?? []))
384
- hardBlockers.push(...(hardPlacedByLayer[z] ?? []))
385
- }
386
-
387
- const oldRect = p.rect
388
- const expanded = expandRectFromSeed({
389
- startX: p.rect.x + p.rect.width / 2,
390
- startY: p.rect.y + p.rect.height / 2,
391
- gridSize: lastGrid,
392
- bounds: state.bounds,
393
- blockers: hardBlockers,
394
- initialCellRatio: 0,
395
- maxAspectRatio: null,
396
- minReq: { width: p.rect.width, height: p.rect.height },
397
- })
398
-
399
- if (expanded) {
400
- // Update placement + per-layer index (replace old rect object)
401
- state.placed[idx] = { rect: expanded, zLayers: p.zLayers }
402
- for (const z of p.zLayers) {
403
- const arr = state.placedByLayer[z]!
404
- const j = arr.findIndex((r) => r === oldRect)
405
- if (j >= 0) arr[j] = expanded
406
- }
407
-
408
- // Carve overlapped soft neighbors (respect full-stack nodes)
409
- resizeSoftOverlaps(state, idx)
410
- }
411
-
412
- state.expansionIndex += 1
413
- }
414
-
415
- /**
416
- * Finalize placed rectangles into output format.
417
- */
418
- export function finalizeRects(state: RectDiffState): Rect3d[] {
419
- // Convert all placed (free space) nodes to output format
420
- const out: Rect3d[] = state.placed.map((p) => ({
421
- minX: p.rect.x,
422
- minY: p.rect.y,
423
- maxX: p.rect.x + p.rect.width,
424
- maxY: p.rect.y + p.rect.height,
425
- zLayers: [...p.zLayers].sort((a, b) => a - b),
426
- }))
427
-
428
- /**
429
- * Recover obstacles as mesh nodes.
430
- * Obstacles are stored per-layer in `obstaclesByLayer`, but we want to emit
431
- * single 3D nodes for multi-layer obstacles if they share the same rect.
432
- * We use the `XYRect` object reference identity to group layers.
433
- */
434
- const layersByObstacleRect = new Map<XYRect, number[]>()
435
-
436
- state.obstaclesByLayer.forEach((layerObs, z) => {
437
- for (const rect of layerObs) {
438
- const layerIndices = layersByObstacleRect.get(rect) ?? []
439
- layerIndices.push(z)
440
- layersByObstacleRect.set(rect, layerIndices)
441
- }
442
- })
443
-
444
- // Append obstacle nodes to the output
445
- const voidSet = new Set(state.boardVoidRects || [])
446
- for (const [rect, layerIndices] of layersByObstacleRect.entries()) {
447
- if (voidSet.has(rect)) continue // Skip void rects
448
-
449
- out.push({
450
- minX: rect.x,
451
- minY: rect.y,
452
- maxX: rect.x + rect.width,
453
- maxY: rect.y + rect.height,
454
- zLayers: layerIndices.sort((a, b) => a - b),
455
- isObstacle: true,
456
- })
457
- }
458
-
459
- return out
460
- }
461
-
462
- /**
463
- * Calculate rough progress number for BaseSolver.progress.
464
- */
465
- export function computeProgress(state: RectDiffState): number {
466
- const grids = state.options.gridSizes.length
467
- if (state.phase === "GRID") {
468
- const g = state.gridIndex
469
- const base = g / (grids + 1) // reserve final slice for expansion
470
- const denom = Math.max(1, state.totalSeedsThisGrid)
471
- const frac = denom ? state.consumedSeedsThisGrid / denom : 1
472
- return Math.min(0.999, base + frac * (1 / (grids + 1)))
473
- }
474
- if (state.phase === "EXPANSION") {
475
- const base = grids / (grids + 1)
476
- const denom = Math.max(1, state.placed.length)
477
- const frac = denom ? state.expansionIndex / denom : 1
478
- return Math.min(0.999, base + frac * (1 / (grids + 1)))
479
- }
480
- return 1
481
- }