@tscircuit/rectdiff 0.0.23 → 0.0.25

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 (47) hide show
  1. package/AGENTS.md +23 -0
  2. package/dist/index.d.ts +1 -0
  3. package/dist/index.js +195 -144
  4. package/lib/RectDiffPipeline.ts +4 -37
  5. package/lib/buildFinalRectDiffVisualization.ts +46 -0
  6. package/lib/solvers/RectDiffSeedingSolver/RectDiffSeedingSolver.ts +23 -48
  7. package/lib/solvers/RectDiffSeedingSolver/computeCandidates3D.ts +21 -12
  8. package/lib/solvers/RectDiffSeedingSolver/computeEdgeCandidates3D.ts +68 -25
  9. package/lib/solvers/RectDiffSeedingSolver/longestFreeSpanAroundZ.ts +1 -1
  10. package/lib/utils/buildOutlineGraphics.ts +39 -0
  11. package/lib/utils/expandRectFromSeed.ts +19 -11
  12. package/lib/utils/isFullyOccupiedAtPoint.ts +2 -2
  13. package/lib/utils/rectdiff-geometry.ts +13 -20
  14. package/package.json +2 -1
  15. package/scripts/benchmark-slow-problem.ts +94 -0
  16. package/test-assets/keyboard4.json +16165 -0
  17. package/tests/solver/__snapshots__/rectDiffGridSolverPipeline.snap.svg +1 -1
  18. package/tests/solver/bugreport01-be84eb/__snapshots__/bugreport01-be84eb.snap.svg +2 -2
  19. package/tests/solver/bugreport02-bc4361/__snapshots__/bugreport02-bc4361.snap.svg +2 -2
  20. package/tests/solver/bugreport03-fe4a17/__snapshots__/bugreport03-fe4a17.snap.svg +1 -1
  21. package/tests/solver/bugreport07-d3f3be/__snapshots__/bugreport07-d3f3be.snap.svg +1 -1
  22. package/tests/solver/bugreport08-e3ec95/__snapshots__/bugreport08-e3ec95.snap.svg +1 -1
  23. package/tests/solver/bugreport09-618e09/__snapshots__/bugreport09-618e09.snap.svg +2 -2
  24. package/tests/solver/bugreport10-71239a/__snapshots__/bugreport10-71239a.snap.svg +2 -2
  25. package/tests/solver/bugreport11-b2de3c/__snapshots__/bugreport11-b2de3c.snap.svg +1 -1
  26. package/tests/solver/bugreport11-b2de3c/bugreport11-b2de3c-clearance-equivalence.test.ts +52 -0
  27. package/tests/solver/bugreport12-35ce1c/__snapshots__/bugreport12-35ce1c.snap.svg +1 -1
  28. package/tests/solver/bugreport13-b9a758/__snapshots__/bugreport13-b9a758.snap.svg +2 -2
  29. package/tests/solver/bugreport16-d95f38/__snapshots__/bugreport16-d95f38.snap.svg +2 -2
  30. package/tests/solver/bugreport19/__snapshots__/bugreport19.snap.svg +1 -1
  31. package/tests/solver/bugreport22-2a75ce/__snapshots__/bugreport22-2a75ce.snap.svg +1 -1
  32. package/tests/solver/bugreport23-LGA15x4/__snapshots__/bugreport23-LGA15x4.snap.svg +1 -1
  33. package/tests/solver/bugreport24-05597c/__snapshots__/bugreport24-05597c.snap.svg +1 -1
  34. package/tests/solver/bugreport26-66b0b2/__snapshots__/bugreport26-66b0b2.snap.svg +2 -2
  35. package/tests/solver/bugreport27-dd3734/__snapshots__/bugreport27-dd3734.snap.svg +2 -2
  36. package/tests/solver/bugreport28-18a9ef/__snapshots__/bugreport28-18a9ef.snap.svg +2 -2
  37. package/tests/solver/bugreport29-7deae8/__snapshots__/bugreport29-7deae8.snap.svg +2 -2
  38. package/tests/solver/bugreport30-2174c8/__snapshots__/bugreport30-2174c8.snap.svg +1 -1
  39. package/tests/solver/bugreport33-213d45/__snapshots__/bugreport33-213d45.snap.svg +2 -2
  40. package/tests/solver/bugreport34-e9dea2/__snapshots__/bugreport34-e9dea2.snap.svg +2 -2
  41. package/tests/solver/bugreport35-191db9/__snapshots__/bugreport35-191db9.snap.svg +2 -2
  42. package/tests/solver/bugreport36-bf8303/__snapshots__/bugreport36-bf8303.snap.svg +1 -1
  43. package/tests/solver/interaction/__snapshots__/interaction.snap.svg +1 -1
  44. package/tests/solver/multi-point/__snapshots__/multi-point.snap.svg +1 -1
  45. package/tests/solver/pcb_trace_id-should-return-root-connection-name/__snapshots__/pcb_trace_id-should-return-root-connection-name.snap.svg +1 -1
  46. package/tests/solver/bugreport11-b2de3c/__snapshots__/bugreport11-b2de3c-clearance.snap.svg +0 -44
  47. package/tests/solver/bugreport11-b2de3c/bugreport11-b2de3c-clearance.test.ts +0 -97
@@ -0,0 +1,46 @@
1
+ import { mergeGraphics, type GraphicsObject } from "graphics-debug"
2
+ import type { CapacityMeshNode } from "./types/capacity-mesh-types"
3
+ import type { SimpleRouteJson } from "./types/srj-types"
4
+ import { getColorForZLayer } from "./utils/getColorForZLayer"
5
+ import { buildOutlineGraphics } from "./utils/buildOutlineGraphics"
6
+ import { buildObstacleClearanceGraphics } from "./utils/renderObstacleClearance"
7
+
8
+ type BuildFinalVisualizationParams = {
9
+ srj: SimpleRouteJson
10
+ meshNodes: CapacityMeshNode[]
11
+ obstacleClearance?: number
12
+ }
13
+
14
+ export const buildFinalRectDiffVisualization = ({
15
+ srj,
16
+ meshNodes,
17
+ obstacleClearance,
18
+ }: BuildFinalVisualizationParams): GraphicsObject => {
19
+ const outline = buildOutlineGraphics({ srj })
20
+ const clearance = buildObstacleClearanceGraphics({
21
+ srj,
22
+ clearance: obstacleClearance,
23
+ })
24
+ const rects = meshNodes.map((node) => ({
25
+ center: node.center,
26
+ width: node.width,
27
+ height: node.height,
28
+ stroke: getColorForZLayer(node.availableZ).stroke,
29
+ fill: node._containsObstacle
30
+ ? "#fca5a5"
31
+ : getColorForZLayer(node.availableZ).fill,
32
+ layer: `z${node.availableZ.join(",")}`,
33
+ label: `node ${node.capacityMeshNodeId}\nz:${node.availableZ.join(",")}`,
34
+ }))
35
+
36
+ const nodesGraphic: GraphicsObject = {
37
+ title: "RectDiffPipeline - Final",
38
+ coordinateSystem: "cartesian",
39
+ rects,
40
+ lines: [],
41
+ points: [],
42
+ texts: [],
43
+ }
44
+
45
+ return mergeGraphics(mergeGraphics(nodesGraphic, outline), clearance)
46
+ }
@@ -57,6 +57,7 @@ export class RectDiffSeedingSolver extends BaseSolver {
57
57
  private candidates!: Candidate3D[]
58
58
  private placed!: Placed3D[]
59
59
  private placedIndexByLayer!: Array<RBush<RTreeRect>>
60
+ private hardPlacedByLayer!: XYRect[][]
60
61
  private expansionIndex!: number
61
62
  private edgeAnalysisDone!: boolean
62
63
  private totalSeedsThisGrid!: number
@@ -131,6 +132,7 @@ export class RectDiffSeedingSolver extends BaseSolver {
131
132
  { length: layerCount },
132
133
  () => new RBush<RTreeRect>(),
133
134
  )
135
+ this.hardPlacedByLayer = Array.from({ length: layerCount }, () => [])
134
136
  this.expansionIndex = 0
135
137
  this.edgeAnalysisDone = false
136
138
  this.totalSeedsThisGrid = 0
@@ -164,19 +166,13 @@ export class RectDiffSeedingSolver extends BaseSolver {
164
166
  } = this.options
165
167
  const grid = gridSizes[this.gridIndex]!
166
168
 
167
- // Build hard-placed map once per micro-step (cheap)
168
- const hardPlacedByLayer = allLayerNode({
169
- layerCount: this.layerCount,
170
- placed: this.placed,
171
- })
172
-
173
169
  // Ensure candidates exist for this grid
174
170
  if (this.candidates.length === 0 && this.consumedSeedsThisGrid === 0) {
175
171
  this.candidates = computeCandidates3D({
176
172
  bounds: this.bounds,
177
173
  gridSize: grid,
178
174
  layerCount: this.layerCount,
179
- hardPlacedByLayer,
175
+ hardPlacedByLayer: this.hardPlacedByLayer,
180
176
  obstacleIndexByLayer: this.input.obstacleIndexByLayer,
181
177
  placedIndexByLayer: this.placedIndexByLayer,
182
178
  })
@@ -185,9 +181,10 @@ export class RectDiffSeedingSolver extends BaseSolver {
185
181
  }
186
182
 
187
183
  // If no candidates remain, advance grid or run edge pass or switch phase
188
- if (this.candidates.length === 0) {
184
+ if (this.consumedSeedsThisGrid >= this.candidates.length) {
189
185
  if (this.gridIndex + 1 < gridSizes.length) {
190
186
  this.gridIndex += 1
187
+ this.candidates = []
191
188
  this.totalSeedsThisGrid = 0
192
189
  this.consumedSeedsThisGrid = 0
193
190
  return
@@ -200,13 +197,14 @@ export class RectDiffSeedingSolver extends BaseSolver {
200
197
  layerCount: this.layerCount,
201
198
  obstacleIndexByLayer: this.input.obstacleIndexByLayer,
202
199
  placedIndexByLayer: this.placedIndexByLayer,
203
- hardPlacedByLayer,
200
+ hardPlacedByLayer: this.hardPlacedByLayer,
204
201
  })
205
202
  this.edgeAnalysisDone = true
206
203
  this.totalSeedsThisGrid = this.candidates.length
207
204
  this.consumedSeedsThisGrid = 0
208
205
  return
209
206
  }
207
+ this.candidates = []
210
208
  this.solved = true
211
209
  this.expansionIndex = 0
212
210
  return
@@ -214,8 +212,17 @@ export class RectDiffSeedingSolver extends BaseSolver {
214
212
  }
215
213
 
216
214
  // Consume exactly one candidate
217
- const cand = this.candidates.shift()!
218
- this.consumedSeedsThisGrid += 1
215
+ const cand = this.candidates[this.consumedSeedsThisGrid++]!
216
+ if (
217
+ isFullyOccupiedAtPoint({
218
+ layerCount: this.layerCount,
219
+ obstacleIndexByLayer: this.input.obstacleIndexByLayer,
220
+ placedIndexByLayer: this.placedIndexByLayer,
221
+ point: { x: cand.x, y: cand.y },
222
+ })
223
+ ) {
224
+ return
225
+ }
219
226
 
220
227
  // Evaluate attempts — multi-layer span first (computed ignoring soft nodes)
221
228
  const span = longestFreeSpanAroundZ({
@@ -226,7 +233,7 @@ export class RectDiffSeedingSolver extends BaseSolver {
226
233
  minSpan: minMulti.minLayers,
227
234
  maxSpan: maxMultiLayerSpan,
228
235
  obstacleIndexByLayer: this.input.obstacleIndexByLayer,
229
- additionalBlockersByLayer: hardPlacedByLayer,
236
+ additionalBlockersByLayer: this.hardPlacedByLayer,
230
237
  })
231
238
 
232
239
  const attempts: Array<{
@@ -285,17 +292,10 @@ export class RectDiffSeedingSolver extends BaseSolver {
285
292
  },
286
293
  newIndex,
287
294
  )
288
-
289
- // New: relax candidate culling — only drop seeds that became fully occupied
290
- this.candidates = this.candidates.filter(
291
- (c) =>
292
- !isFullyOccupiedAtPoint({
293
- layerCount: this.layerCount,
294
- obstacleIndexByLayer: this.input.obstacleIndexByLayer,
295
- placedIndexByLayer: this.placedIndexByLayer,
296
- point: { x: c.x, y: c.y },
297
- }),
298
- )
295
+ this.hardPlacedByLayer = allLayerNode({
296
+ layerCount: this.layerCount,
297
+ placed: this.placed,
298
+ })
299
299
 
300
300
  return // processed one candidate
301
301
  }
@@ -392,31 +392,6 @@ export class RectDiffSeedingSolver extends BaseSolver {
392
392
  }
393
393
  }
394
394
 
395
- // obstacle clearance visualization (expanded)
396
- if (this.input.obstacleClearance && this.input.obstacleClearance > 0) {
397
- for (const obstacle of srj.obstacles ?? []) {
398
- const pad = this.input.obstacleClearance
399
- const expanded = {
400
- x: obstacle.center.x - obstacle.width / 2 - pad,
401
- y: obstacle.center.y - obstacle.height / 2 - pad,
402
- width: obstacle.width + 2 * pad,
403
- height: obstacle.height + 2 * pad,
404
- }
405
- rects.push({
406
- center: {
407
- x: expanded.x + expanded.width / 2,
408
- y: expanded.y + expanded.height / 2,
409
- },
410
- width: expanded.width,
411
- height: expanded.height,
412
- fill: "rgba(234, 179, 8, 0.15)",
413
- stroke: "rgba(202, 138, 4, 0.9)",
414
- layer: "obstacle-clearance",
415
- label: "clearance",
416
- })
417
- }
418
- }
419
-
420
395
  // board void rects (early visualization of mask)
421
396
  if (this.boardVoidRects) {
422
397
  let outlineBBox: {
@@ -4,6 +4,8 @@ import { isFullyOccupiedAtPoint } from "../../utils/isFullyOccupiedAtPoint"
4
4
  import { longestFreeSpanAroundZ } from "./longestFreeSpanAroundZ"
5
5
  import type RBush from "rbush"
6
6
  import type { RTreeRect } from "lib/types/capacity-mesh-types"
7
+ const quantize = (value: number, precision = 1e-6) =>
8
+ Math.round(value / precision) * precision
7
9
 
8
10
  /**
9
11
  * Compute candidate seed points for a given grid size.
@@ -25,6 +27,10 @@ export function computeCandidates3D(params: {
25
27
  hardPlacedByLayer,
26
28
  } = params
27
29
  const out = new Map<string, Candidate3D>() // key by (x,y)
30
+ const hardRectsByLayer = Array.from({ length: layerCount }, (_, z) => [
31
+ ...(obstacleIndexByLayer[z]?.all() ?? []),
32
+ ...(hardPlacedByLayer[z] ?? []),
33
+ ])
28
34
 
29
35
  for (let x = bounds.x; x < bounds.x + bounds.width; x += gridSize) {
30
36
  for (let y = bounds.y; y < bounds.y + bounds.height; y += gridSize) {
@@ -73,23 +79,19 @@ export function computeCandidates3D(params: {
73
79
  : bestZ
74
80
 
75
81
  // Distance heuristic against hard blockers only (obstacles + full-stack)
76
- const hardAtZ = [
77
- ...(obstacleIndexByLayer[anchorZ]?.all() ?? []),
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
- )
82
+ const hardAtZ = hardRectsByLayer[anchorZ] ?? []
83
+ let d = distancePointToRectEdges({ x, y }, bounds)
84
+ for (const blocker of hardAtZ) {
85
+ d = Math.min(d, distancePointToRectEdges({ x, y }, blocker))
86
+ }
87
+ const distance = quantize(d)
86
88
 
87
89
  const k = `${x.toFixed(6)}|${y.toFixed(6)}`
88
90
  const cand: Candidate3D = {
89
91
  x,
90
92
  y,
91
93
  z: anchorZ,
92
- distance: d,
94
+ distance,
93
95
  zSpanLen: bestSpan.length,
94
96
  }
95
97
  const prev = out.get(k)
@@ -104,6 +106,13 @@ export function computeCandidates3D(params: {
104
106
  }
105
107
 
106
108
  const arr = Array.from(out.values())
107
- arr.sort((a, b) => b.zSpanLen! - a.zSpanLen! || b.distance - a.distance)
109
+ arr.sort(
110
+ (a, b) =>
111
+ b.zSpanLen! - a.zSpanLen! ||
112
+ b.distance - a.distance ||
113
+ a.z - b.z ||
114
+ a.x - b.x ||
115
+ a.y - b.y,
116
+ )
108
117
  return arr
109
118
  }
@@ -4,6 +4,18 @@ import { isFullyOccupiedAtPoint } from "../../utils/isFullyOccupiedAtPoint"
4
4
  import { longestFreeSpanAroundZ } from "./longestFreeSpanAroundZ"
5
5
  import type RBush from "rbush"
6
6
  import type { RTreeRect } from "lib/types/capacity-mesh-types"
7
+ const quantize = (value: number, precision = 1e-6) =>
8
+ Math.round(value / precision) * precision
9
+
10
+ const toRect = (rect: XYRect | RTreeRect): XYRect =>
11
+ "minX" in rect
12
+ ? {
13
+ x: rect.minX,
14
+ y: rect.minY,
15
+ width: rect.maxX - rect.minX,
16
+ height: rect.maxY - rect.minY,
17
+ }
18
+ : rect
7
19
 
8
20
  /**
9
21
  * Compute exact uncovered segments along a 1D line.
@@ -15,14 +27,23 @@ function computeUncoveredSegments(params: {
15
27
  minSegmentLength: number
16
28
  }): Array<{ start: number; end: number; center: number }> {
17
29
  const { lineStart, lineEnd, coveringIntervals, minSegmentLength } = params
30
+ const lineStartQ = quantize(lineStart)
31
+ const lineEndQ = quantize(lineEnd)
32
+ const normalizedIntervals = coveringIntervals
33
+ .map((i) => {
34
+ const s = quantize(i.start)
35
+ const e = quantize(i.end)
36
+ return { start: Math.min(s, e), end: Math.max(s, e) }
37
+ })
38
+ .filter((i) => i.end > i.start + EPS)
18
39
 
19
- if (coveringIntervals.length === 0) {
20
- const center = (lineStart + lineEnd) / 2
21
- return [{ start: lineStart, end: lineEnd, center }]
40
+ if (normalizedIntervals.length === 0) {
41
+ const center = (lineStartQ + lineEndQ) / 2
42
+ return [{ start: lineStartQ, end: lineEndQ, center }]
22
43
  }
23
44
 
24
45
  // Sort intervals by start position
25
- const sorted = [...coveringIntervals].sort((a, b) => a.start - b.start)
46
+ const sorted = [...normalizedIntervals].sort((a, b) => a.start - b.start)
26
47
 
27
48
  // Merge overlapping intervals
28
49
  const merged: Array<{ start: number; end: number }> = []
@@ -45,8 +66,8 @@ function computeUncoveredSegments(params: {
45
66
  const uncovered: Array<{ start: number; end: number; center: number }> = []
46
67
 
47
68
  // Check gap before first interval
48
- if (merged[0]!.start > lineStart + EPS) {
49
- const start = lineStart
69
+ if (merged[0]!.start > lineStartQ + EPS) {
70
+ const start = lineStartQ
50
71
  const end = merged[0]!.start
51
72
  if (end - start >= minSegmentLength) {
52
73
  uncovered.push({ start, end, center: (start + end) / 2 })
@@ -63,9 +84,9 @@ function computeUncoveredSegments(params: {
63
84
  }
64
85
 
65
86
  // Check gap after last interval
66
- if (merged[merged.length - 1]!.end < lineEnd - EPS) {
87
+ if (merged[merged.length - 1]!.end < lineEndQ - EPS) {
67
88
  const start = merged[merged.length - 1]!.end
68
- const end = lineEnd
89
+ const end = lineEndQ
69
90
  if (end - start >= minSegmentLength) {
70
91
  uncovered.push({ start, end, center: (start + end) / 2 })
71
92
  }
@@ -98,6 +119,12 @@ export function computeEdgeCandidates3D(params: {
98
119
  // Use small inset from edges for placement
99
120
  const δ = Math.max(minSize * 0.15, EPS * 3)
100
121
  const dedup = new Set<string>()
122
+ const hardRectsByLayer = Array.from({ length: layerCount }, (_, z) =>
123
+ [
124
+ ...(obstacleIndexByLayer[z]?.all() ?? []),
125
+ ...(hardPlacedByLayer[z] ?? []),
126
+ ].map(toRect),
127
+ )
101
128
  const key = (p: { x: number; y: number; z: number }) =>
102
129
  `${p.z}|${p.x.toFixed(6)}|${p.y.toFixed(6)}`
103
130
 
@@ -111,7 +138,11 @@ export function computeEdgeCandidates3D(params: {
111
138
  }
112
139
 
113
140
  function pushIfFree(p: { x: number; y: number; z: number }) {
114
- const { x, y, z } = p
141
+ const qx = quantize(p.x)
142
+ const qy = quantize(p.y)
143
+ const { z } = p
144
+ const x = qx
145
+ const y = qy
115
146
  if (
116
147
  x < bounds.x + EPS ||
117
148
  y < bounds.y + EPS ||
@@ -122,16 +153,12 @@ export function computeEdgeCandidates3D(params: {
122
153
  if (fullyOcc({ x, y })) return // new rule: only drop if truly impossible
123
154
 
124
155
  // Distance uses obstacles + hard nodes (soft nodes ignored for ranking)
125
- const hard = [
126
- ...(obstacleIndexByLayer[z]?.all() ?? []),
127
- ...(hardPlacedByLayer[z] ?? []),
128
- ]
129
- const d = Math.min(
130
- distancePointToRectEdges({ x, y }, bounds),
131
- ...(hard.length
132
- ? hard.map((b) => distancePointToRectEdges({ x, y }, b))
133
- : [Infinity]),
134
- )
156
+ const hard = hardRectsByLayer[z] ?? []
157
+ let d = distancePointToRectEdges({ x, y }, bounds)
158
+ for (const blocker of hard) {
159
+ d = Math.min(d, distancePointToRectEdges({ x, y }, blocker))
160
+ }
161
+ const distance = quantize(d)
135
162
 
136
163
  const k = key({ x, y, z })
137
164
  if (dedup.has(k)) return
@@ -148,14 +175,23 @@ export function computeEdgeCandidates3D(params: {
148
175
  obstacleIndexByLayer,
149
176
  additionalBlockersByLayer: hardPlacedByLayer,
150
177
  })
151
- out.push({ x, y, z, distance: d, zSpanLen: span.length, isEdgeSeed: true })
178
+ out.push({
179
+ x,
180
+ y,
181
+ z,
182
+ distance,
183
+ zSpanLen: span.length,
184
+ isEdgeSeed: true,
185
+ })
152
186
  }
153
187
 
154
188
  for (let z = 0; z < layerCount; z++) {
155
- const blockers = [
156
- ...(obstacleIndexByLayer[z]?.all() ?? []),
157
- ...(hardPlacedByLayer[z] ?? []),
158
- ]
189
+ const blockers = (hardRectsByLayer[z] ?? []).map((b) => ({
190
+ x: quantize(b.x),
191
+ y: quantize(b.y),
192
+ width: quantize(b.width),
193
+ height: quantize(b.height),
194
+ }))
159
195
 
160
196
  // 1) Board edges — find exact uncovered segments along each edge
161
197
 
@@ -372,6 +408,13 @@ export function computeEdgeCandidates3D(params: {
372
408
  }
373
409
 
374
410
  // Strong multi-layer preference then distance.
375
- out.sort((a, b) => b.zSpanLen! - a.zSpanLen! || b.distance - a.distance)
411
+ out.sort(
412
+ (a, b) =>
413
+ b.zSpanLen! - a.zSpanLen! ||
414
+ b.distance - a.distance ||
415
+ a.z - b.z ||
416
+ a.x - b.x ||
417
+ a.y - b.y,
418
+ )
376
419
  return out
377
420
  }
@@ -35,7 +35,7 @@ export function longestFreeSpanAroundZ(params: {
35
35
  maxY: y,
36
36
  }
37
37
  const obstacleIdx = obstacleIndexByLayer[layer]
38
- if (obstacleIdx && obstacleIdx.search(query).length > 0) return false
38
+ if (obstacleIdx && obstacleIdx.collides(query)) return false
39
39
 
40
40
  const extras = additionalBlockersByLayer?.[layer] ?? []
41
41
  return !extras.some((b) => containsPoint(b, { x, y }))
@@ -0,0 +1,39 @@
1
+ import type { GraphicsObject, Line } from "graphics-debug"
2
+ import type { SimpleRouteJson } from "../types/srj-types"
3
+
4
+ export type BuildOutlineParams = { srj: SimpleRouteJson }
5
+
6
+ export const buildOutlineGraphics = ({
7
+ srj,
8
+ }: BuildOutlineParams): GraphicsObject => {
9
+ const hasOutline = srj.outline && srj.outline.length > 1
10
+ const lines: NonNullable<Line[]> = hasOutline
11
+ ? [
12
+ {
13
+ points: [...srj.outline!, srj.outline![0]!],
14
+ strokeColor: "#111827",
15
+ strokeWidth: 0.1,
16
+ label: "outline",
17
+ },
18
+ ]
19
+ : [
20
+ {
21
+ points: [
22
+ { x: srj.bounds.minX, y: srj.bounds.minY },
23
+ { x: srj.bounds.maxX, y: srj.bounds.minY },
24
+ { x: srj.bounds.maxX, y: srj.bounds.maxY },
25
+ { x: srj.bounds.minX, y: srj.bounds.maxY },
26
+ { x: srj.bounds.minX, y: srj.bounds.minY },
27
+ ],
28
+ strokeColor: "#111827",
29
+ strokeWidth: 0.1,
30
+ label: "bounds",
31
+ },
32
+ ]
33
+
34
+ return {
35
+ title: "SimpleRoute Outline",
36
+ coordinateSystem: "cartesian",
37
+ lines,
38
+ }
39
+ }
@@ -17,6 +17,16 @@ type ExpandDirectionParams = {
17
17
  maxAspect: number | null | undefined
18
18
  }
19
19
 
20
+ const quantize = (value: number, precision = 1e-6) =>
21
+ Math.round(value / precision) * precision
22
+
23
+ const quantizeRect = (rect: XYRect): XYRect => ({
24
+ x: quantize(rect.x),
25
+ y: quantize(rect.y),
26
+ width: quantize(rect.width),
27
+ height: quantize(rect.height),
28
+ })
29
+
20
30
  function maxExpandRight(params: ExpandDirectionParams) {
21
31
  const { r, bounds, blockers, maxAspect } = params
22
32
  // Start with board boundary
@@ -165,14 +175,13 @@ const toRect = (tree: RTreeRect): XYRect => ({
165
175
  })
166
176
 
167
177
  const addBlocker = (params: {
168
- rect: XYRect
169
- seen: Set<string>
178
+ rect: RTreeRect
179
+ seen: Set<RTreeRect>
170
180
  blockers: XYRect[]
171
181
  }) => {
172
182
  const { rect, seen, blockers } = params
173
- const key = `${rect.x}|${rect.y}|${rect.width}|${rect.height}`
174
- if (seen.has(key)) return
175
- seen.add(key)
183
+ if (seen.has(rect)) return
184
+ seen.add(rect)
176
185
  blockers.push(rect)
177
186
  }
178
187
 
@@ -215,7 +224,7 @@ export function expandRectFromSeed(params: {
215
224
  const initialW = Math.max(minSide, minReq.width)
216
225
  const initialH = Math.max(minSide, minReq.height)
217
226
  const blockers: XYRect[] = []
218
- const seen = new Set<string>()
227
+ const seen = new Set<RTreeRect>()
219
228
  const totalLayers = placedIndexByLayer.length
220
229
 
221
230
  // Ignore the existing placement we are expanding so it doesn't self-block.
@@ -227,7 +236,7 @@ export function expandRectFromSeed(params: {
227
236
  const blockersIndex = obsticalIndexByLayer[z]
228
237
  if (blockersIndex) {
229
238
  for (const entry of blockersIndex.search(query))
230
- addBlocker({ rect: toRect(entry), seen, blockers })
239
+ addBlocker({ rect: entry, seen, blockers })
231
240
  }
232
241
 
233
242
  const placedLayer = placedIndexByLayer[z]
@@ -235,10 +244,9 @@ export function expandRectFromSeed(params: {
235
244
  for (const entry of placedLayer.search(query)) {
236
245
  const isFullStack = entry.zLayers.length >= totalLayers
237
246
  if (!isFullStack) continue
238
- const rect = toRect(entry)
239
247
  if (
240
248
  isSelfRect({
241
- rect,
249
+ rect: entry,
242
250
  startX,
243
251
  startY,
244
252
  initialW,
@@ -246,7 +254,7 @@ export function expandRectFromSeed(params: {
246
254
  })
247
255
  )
248
256
  continue
249
- addBlocker({ rect, seen, blockers })
257
+ addBlocker({ rect: entry, seen, blockers })
250
258
  }
251
259
  }
252
260
  }
@@ -333,7 +341,7 @@ export function expandRectFromSeed(params: {
333
341
  if (r.width + EPS >= minReq.width && r.height + EPS >= minReq.height) {
334
342
  const area = r.width * r.height
335
343
  if (area > bestArea) {
336
- best = r
344
+ best = quantizeRect(r)
337
345
  bestArea = area
338
346
  }
339
347
  }
@@ -17,10 +17,10 @@ export function isFullyOccupiedAtPoint(params: OccupancyParams): boolean {
17
17
  }
18
18
  for (let z = 0; z < params.layerCount; z++) {
19
19
  const obstacleIdx = params.obstacleIndexByLayer[z]
20
- const hasObstacle = !!obstacleIdx && obstacleIdx.search(query).length > 0
20
+ const hasObstacle = !!obstacleIdx && obstacleIdx.collides(query)
21
21
 
22
22
  const placedIdx = params.placedIndexByLayer[z]
23
- const hasPlaced = !!placedIdx && placedIdx.search(query).length > 0
23
+ const hasPlaced = !!placedIdx && placedIdx.collides(query)
24
24
 
25
25
  if (!hasObstacle && !hasPlaced) return false
26
26
  }
@@ -30,27 +30,20 @@ export function distancePointToRectEdges(
30
30
  p: { x: number; y: number },
31
31
  r: XYRect,
32
32
  ) {
33
- const edges: [number, number, number, number][] = [
34
- [r.x, r.y, r.x + r.width, r.y],
35
- [r.x + r.width, r.y, r.x + r.width, r.y + r.height],
36
- [r.x + r.width, r.y + r.height, r.x, r.y + r.height],
37
- [r.x, r.y + r.height, r.x, r.y],
38
- ]
39
- let best = Infinity
40
- for (const [x1, y1, x2, y2] of edges) {
41
- const A = p.x - x1,
42
- B = p.y - y1,
43
- C = x2 - x1,
44
- D = y2 - y1
45
- const dot = A * C + B * D
46
- const lenSq = C * C + D * D
47
- let t = lenSq !== 0 ? dot / lenSq : 0
48
- t = clamp(t, 0, 1)
49
- const xx = x1 + t * C
50
- const yy = y1 + t * D
51
- best = Math.min(best, Math.hypot(p.x - xx, p.y - yy))
33
+ const minX = r.x
34
+ const maxX = r.x + r.width
35
+ const minY = r.y
36
+ const maxY = r.y + r.height
37
+
38
+ if (p.x >= minX && p.x <= maxX && p.y >= minY && p.y <= maxY) {
39
+ return Math.min(p.x - minX, maxX - p.x, p.y - minY, maxY - p.y)
52
40
  }
53
- return best
41
+
42
+ const dx = p.x < minX ? minX - p.x : p.x > maxX ? p.x - maxX : 0
43
+ const dy = p.y < minY ? minY - p.y : p.y > maxY ? p.y - maxY : 0
44
+ if (dx === 0) return dy
45
+ if (dy === 0) return dx
46
+ return Math.hypot(dx, dy)
54
47
  }
55
48
 
56
49
  /** Find the intersection of two 1D intervals, or null if they don't overlap. */
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@tscircuit/rectdiff",
3
- "version": "0.0.23",
3
+ "version": "0.0.25",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {
7
7
  "start": "cosmos",
8
8
  "build": "tsup-node ./lib/index.ts --format esm --dts",
9
9
  "build:site": "cosmos-export",
10
+ "benchmark:slow-problem": "bun scripts/benchmark-slow-problem.ts test-assets/keyboard4.json",
10
11
  "format": "biome format --write .",
11
12
  "format:check": "biome format ."
12
13
  },