@tscircuit/rectdiff 0.0.12 → 0.0.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/dist/index.d.ts +163 -27
  2. package/dist/index.js +1884 -1675
  3. package/lib/RectDiffPipeline.ts +18 -17
  4. package/lib/{solvers/rectdiff/types.ts → rectdiff-types.ts} +0 -34
  5. package/lib/{solvers/rectdiff/visualization.ts → rectdiff-visualization.ts} +2 -1
  6. package/lib/solvers/GapFillSolver/FindSegmentsWithAdjacentEmptySpaceSolver.ts +9 -12
  7. package/lib/solvers/GapFillSolver/visuallyOffsetLine.ts +5 -2
  8. package/lib/solvers/RectDiffExpansionSolver/RectDiffExpansionSolver.ts +205 -0
  9. package/lib/solvers/{rectdiff → RectDiffExpansionSolver}/rectsToMeshNodes.ts +1 -2
  10. package/lib/solvers/RectDiffGridSolverPipeline/RectDiffGridSolverPipeline.ts +87 -0
  11. package/lib/solvers/RectDiffSeedingSolver/RectDiffSeedingSolver.ts +516 -0
  12. package/lib/solvers/RectDiffSeedingSolver/computeCandidates3D.ts +109 -0
  13. package/lib/solvers/RectDiffSeedingSolver/computeDefaultGridSizes.ts +9 -0
  14. package/lib/solvers/{rectdiff/candidates.ts → RectDiffSeedingSolver/computeEdgeCandidates3D.ts} +39 -220
  15. package/lib/solvers/{rectdiff/geometry → RectDiffSeedingSolver}/computeInverseRects.ts +6 -2
  16. package/lib/solvers/{rectdiff → RectDiffSeedingSolver}/layers.ts +4 -3
  17. package/lib/solvers/RectDiffSeedingSolver/longestFreeSpanAroundZ.ts +52 -0
  18. package/lib/utils/buildHardPlacedByLayer.ts +14 -0
  19. package/lib/{solvers/rectdiff/geometry.ts → utils/expandRectFromSeed.ts} +21 -120
  20. package/lib/utils/finalizeRects.ts +49 -0
  21. package/lib/utils/isFullyOccupiedAtPoint.ts +21 -0
  22. package/lib/utils/rectdiff-geometry.ts +94 -0
  23. package/lib/utils/resizeSoftOverlaps.ts +74 -0
  24. package/package.json +1 -1
  25. package/tests/board-outline.test.ts +2 -1
  26. package/tests/obstacle-extra-layers.test.ts +1 -1
  27. package/tests/obstacle-zlayers.test.ts +1 -1
  28. package/utils/rectsEqual.ts +2 -2
  29. package/utils/rectsOverlap.ts +2 -2
  30. package/lib/solvers/RectDiffSolver.ts +0 -231
  31. package/lib/solvers/rectdiff/engine.ts +0 -481
  32. /package/lib/solvers/{rectdiff/geometry → RectDiffSeedingSolver}/isPointInPolygon.ts +0 -0
@@ -1,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
- }