@tscircuit/rectdiff 0.0.24 → 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.
package/AGENTS.md ADDED
@@ -0,0 +1,23 @@
1
+ This is an algorithm solver repo designed to be integrated into the tscircuit
2
+ autorouter.
3
+
4
+ It uses the SimpleRouteJson type as input (see lib/types/srj-types.ts)
5
+
6
+ We use svg snapshots to create visual snapshots as our primary method of
7
+ ensuring the algorithm works correctly. We use `export BUN_UPDATE_SNAPSHOTS=1`
8
+ and `bun test path/to/test.test.ts` to update the snapshots.
9
+
10
+ We use the `graphics-debug` package to turn `GraphicsObject` into SVGs using
11
+
12
+ ```tsx
13
+ import { getSvgFromGraphicsObject } from "graphics-debug"
14
+ ```
15
+
16
+ We use `cosmos` `*.page.tsx` files inside the `pages` directory to create
17
+ examples that can be debugged for a human.
18
+
19
+ We discovered the algorithm via `experiments/rect3d_visualizer.html` and we're
20
+ now formalizing it into a solver pattern and binding to our normal types.
21
+
22
+ The `test-assets` directory contains assets for test, typically simple route
23
+ json files with test cases that can be importing into pages or tests.
package/dist/index.d.ts CHANGED
@@ -242,6 +242,7 @@ declare class RectDiffSeedingSolver extends BaseSolver {
242
242
  private candidates;
243
243
  private placed;
244
244
  private placedIndexByLayer;
245
+ private hardPlacedByLayer;
245
246
  private expansionIndex;
246
247
  private edgeAnalysisDone;
247
248
  private totalSeedsThisGrid;
package/dist/index.js CHANGED
@@ -688,24 +688,18 @@ function containsPoint(r, p) {
688
688
  return p.x >= r.x - EPS4 && p.x <= r.x + r.width + EPS4 && p.y >= r.y - EPS4 && p.y <= r.y + r.height + EPS4;
689
689
  }
690
690
  function distancePointToRectEdges(p, r) {
691
- const edges = [
692
- [r.x, r.y, r.x + r.width, r.y],
693
- [r.x + r.width, r.y, r.x + r.width, r.y + r.height],
694
- [r.x + r.width, r.y + r.height, r.x, r.y + r.height],
695
- [r.x, r.y + r.height, r.x, r.y]
696
- ];
697
- let best = Infinity;
698
- for (const [x1, y1, x2, y2] of edges) {
699
- const A = p.x - x1, B = p.y - y1, C = x2 - x1, D = y2 - y1;
700
- const dot = A * C + B * D;
701
- const lenSq = C * C + D * D;
702
- let t = lenSq !== 0 ? dot / lenSq : 0;
703
- t = clamp(t, 0, 1);
704
- const xx = x1 + t * C;
705
- const yy = y1 + t * D;
706
- best = Math.min(best, Math.hypot(p.x - xx, p.y - yy));
707
- }
708
- return best;
691
+ const minX = r.x;
692
+ const maxX = r.x + r.width;
693
+ const minY = r.y;
694
+ const maxY = r.y + r.height;
695
+ if (p.x >= minX && p.x <= maxX && p.y >= minY && p.y <= maxY) {
696
+ return Math.min(p.x - minX, maxX - p.x, p.y - minY, maxY - p.y);
697
+ }
698
+ const dx = p.x < minX ? minX - p.x : p.x > maxX ? p.x - maxX : 0;
699
+ const dy = p.y < minY ? minY - p.y : p.y > maxY ? p.y - maxY : 0;
700
+ if (dx === 0) return dy;
701
+ if (dy === 0) return dx;
702
+ return Math.hypot(dx, dy);
709
703
  }
710
704
  function intersect1D(r1, r2) {
711
705
  const lo = Math.max(r1[0], r2[0]);
@@ -1051,17 +1045,10 @@ function maxExpandUp(params) {
1051
1045
  }
1052
1046
  return Math.max(0, e);
1053
1047
  }
1054
- var toRect = (tree) => ({
1055
- x: tree.minX,
1056
- y: tree.minY,
1057
- width: tree.maxX - tree.minX,
1058
- height: tree.maxY - tree.minY
1059
- });
1060
1048
  var addBlocker = (params) => {
1061
1049
  const { rect, seen, blockers } = params;
1062
- const key = `${rect.x}|${rect.y}|${rect.width}|${rect.height}`;
1063
- if (seen.has(key)) return;
1064
- seen.add(key);
1050
+ if (seen.has(rect)) return;
1051
+ seen.add(rect);
1065
1052
  blockers.push(rect);
1066
1053
  };
1067
1054
  var toQueryRect = (params) => {
@@ -1098,23 +1085,22 @@ function expandRectFromSeed(params) {
1098
1085
  const blockersIndex = obsticalIndexByLayer[z];
1099
1086
  if (blockersIndex) {
1100
1087
  for (const entry of blockersIndex.search(query))
1101
- addBlocker({ rect: toRect(entry), seen, blockers });
1088
+ addBlocker({ rect: entry, seen, blockers });
1102
1089
  }
1103
1090
  const placedLayer = placedIndexByLayer[z];
1104
1091
  if (placedLayer) {
1105
1092
  for (const entry of placedLayer.search(query)) {
1106
1093
  const isFullStack = entry.zLayers.length >= totalLayers;
1107
1094
  if (!isFullStack) continue;
1108
- const rect = toRect(entry);
1109
1095
  if (isSelfRect({
1110
- rect,
1096
+ rect: entry,
1111
1097
  startX,
1112
1098
  startY,
1113
1099
  initialW,
1114
1100
  initialH
1115
1101
  }))
1116
1102
  continue;
1117
- addBlocker({ rect, seen, blockers });
1103
+ addBlocker({ rect: entry, seen, blockers });
1118
1104
  }
1119
1105
  }
1120
1106
  }
@@ -1205,9 +1191,9 @@ function isFullyOccupiedAtPoint(params) {
1205
1191
  };
1206
1192
  for (let z = 0; z < params.layerCount; z++) {
1207
1193
  const obstacleIdx = params.obstacleIndexByLayer[z];
1208
- const hasObstacle = !!obstacleIdx && obstacleIdx.search(query).length > 0;
1194
+ const hasObstacle = !!obstacleIdx && obstacleIdx.collides(query);
1209
1195
  const placedIdx = params.placedIndexByLayer[z];
1210
- const hasPlaced = !!placedIdx && placedIdx.search(query).length > 0;
1196
+ const hasPlaced = !!placedIdx && placedIdx.collides(query);
1211
1197
  if (!hasObstacle && !hasPlaced) return false;
1212
1198
  }
1213
1199
  return true;
@@ -1233,7 +1219,7 @@ function longestFreeSpanAroundZ(params) {
1233
1219
  maxY: y
1234
1220
  };
1235
1221
  const obstacleIdx = obstacleIndexByLayer[layer];
1236
- if (obstacleIdx && obstacleIdx.search(query).length > 0) return false;
1222
+ if (obstacleIdx && obstacleIdx.collides(query)) return false;
1237
1223
  const extras = additionalBlockersByLayer?.[layer] ?? [];
1238
1224
  return !extras.some((b) => containsPoint(b, { x, y }));
1239
1225
  };
@@ -1265,6 +1251,10 @@ function computeCandidates3D(params) {
1265
1251
  hardPlacedByLayer
1266
1252
  } = params;
1267
1253
  const out = /* @__PURE__ */ new Map();
1254
+ const hardRectsByLayer = Array.from({ length: layerCount }, (_, z) => [
1255
+ ...obstacleIndexByLayer[z]?.all() ?? [],
1256
+ ...hardPlacedByLayer[z] ?? []
1257
+ ]);
1268
1258
  for (let x = bounds.x; x < bounds.x + bounds.width; x += gridSize) {
1269
1259
  for (let y = bounds.y; y < bounds.y + bounds.height; y += gridSize) {
1270
1260
  if (Math.abs(x - bounds.x) < EPS4 || Math.abs(y - bounds.y) < EPS4 || x > bounds.x + bounds.width - gridSize - EPS4 || y > bounds.y + bounds.height - gridSize - EPS4) {
@@ -1296,14 +1286,11 @@ function computeCandidates3D(params) {
1296
1286
  }
1297
1287
  }
1298
1288
  const anchorZ = bestSpan.length ? bestSpan[Math.floor(bestSpan.length / 2)] : bestZ;
1299
- const hardAtZ = [
1300
- ...obstacleIndexByLayer[anchorZ]?.all() ?? [],
1301
- ...hardPlacedByLayer[anchorZ] ?? []
1302
- ];
1303
- const d = Math.min(
1304
- distancePointToRectEdges({ x, y }, bounds),
1305
- ...hardAtZ.length ? hardAtZ.map((b) => distancePointToRectEdges({ x, y }, b)) : [Infinity]
1306
- );
1289
+ const hardAtZ = hardRectsByLayer[anchorZ] ?? [];
1290
+ let d = distancePointToRectEdges({ x, y }, bounds);
1291
+ for (const blocker of hardAtZ) {
1292
+ d = Math.min(d, distancePointToRectEdges({ x, y }, blocker));
1293
+ }
1307
1294
  const distance = quantize2(d);
1308
1295
  const k = `${x.toFixed(6)}|${y.toFixed(6)}`;
1309
1296
  const cand = {
@@ -1328,6 +1315,12 @@ function computeCandidates3D(params) {
1328
1315
 
1329
1316
  // lib/solvers/RectDiffSeedingSolver/computeEdgeCandidates3D.ts
1330
1317
  var quantize3 = (value, precision = 1e-6) => Math.round(value / precision) * precision;
1318
+ var toRect = (rect) => "minX" in rect ? {
1319
+ x: rect.minX,
1320
+ y: rect.minY,
1321
+ width: rect.maxX - rect.minX,
1322
+ height: rect.maxY - rect.minY
1323
+ } : rect;
1331
1324
  function computeUncoveredSegments(params) {
1332
1325
  const { lineStart, lineEnd, coveringIntervals, minSegmentLength } = params;
1333
1326
  const lineStartQ = quantize3(lineStart);
@@ -1390,6 +1383,13 @@ function computeEdgeCandidates3D(params) {
1390
1383
  const out = [];
1391
1384
  const \u03B4 = Math.max(minSize * 0.15, EPS4 * 3);
1392
1385
  const dedup = /* @__PURE__ */ new Set();
1386
+ const hardRectsByLayer = Array.from(
1387
+ { length: layerCount },
1388
+ (_, z) => [
1389
+ ...obstacleIndexByLayer[z]?.all() ?? [],
1390
+ ...hardPlacedByLayer[z] ?? []
1391
+ ].map(toRect)
1392
+ );
1393
1393
  const key = (p) => `${p.z}|${p.x.toFixed(6)}|${p.y.toFixed(6)}`;
1394
1394
  function fullyOcc(p) {
1395
1395
  return isFullyOccupiedAtPoint({
@@ -1408,19 +1408,11 @@ function computeEdgeCandidates3D(params) {
1408
1408
  if (x < bounds.x + EPS4 || y < bounds.y + EPS4 || x > bounds.x + bounds.width - EPS4 || y > bounds.y + bounds.height - EPS4)
1409
1409
  return;
1410
1410
  if (fullyOcc({ x, y })) return;
1411
- const hard = [
1412
- ...obstacleIndexByLayer[z]?.all() ?? [],
1413
- ...hardPlacedByLayer[z] ?? []
1414
- ].map((b) => ({
1415
- x: quantize3(b.x),
1416
- y: quantize3(b.y),
1417
- width: quantize3(b.width),
1418
- height: quantize3(b.height)
1419
- }));
1420
- const d = Math.min(
1421
- distancePointToRectEdges({ x, y }, bounds),
1422
- ...hard.length ? hard.map((b) => distancePointToRectEdges({ x, y }, b)) : [Infinity]
1423
- );
1411
+ const hard = hardRectsByLayer[z] ?? [];
1412
+ let d = distancePointToRectEdges({ x, y }, bounds);
1413
+ for (const blocker of hard) {
1414
+ d = Math.min(d, distancePointToRectEdges({ x, y }, blocker));
1415
+ }
1424
1416
  const distance = quantize3(d);
1425
1417
  const k = key({ x, y, z });
1426
1418
  if (dedup.has(k)) return;
@@ -1445,10 +1437,7 @@ function computeEdgeCandidates3D(params) {
1445
1437
  });
1446
1438
  }
1447
1439
  for (let z = 0; z < layerCount; z++) {
1448
- const blockers = [
1449
- ...obstacleIndexByLayer[z]?.all() ?? [],
1450
- ...hardPlacedByLayer[z] ?? []
1451
- ].map((b) => ({
1440
+ const blockers = (hardRectsByLayer[z] ?? []).map((b) => ({
1452
1441
  x: quantize3(b.x),
1453
1442
  y: quantize3(b.y),
1454
1443
  width: quantize3(b.width),
@@ -1746,6 +1735,7 @@ var RectDiffSeedingSolver = class extends BaseSolver3 {
1746
1735
  candidates;
1747
1736
  placed;
1748
1737
  placedIndexByLayer;
1738
+ hardPlacedByLayer;
1749
1739
  expansionIndex;
1750
1740
  edgeAnalysisDone;
1751
1741
  totalSeedsThisGrid;
@@ -1801,6 +1791,7 @@ var RectDiffSeedingSolver = class extends BaseSolver3 {
1801
1791
  { length: layerCount },
1802
1792
  () => new RBush3()
1803
1793
  );
1794
+ this.hardPlacedByLayer = Array.from({ length: layerCount }, () => []);
1804
1795
  this.expansionIndex = 0;
1805
1796
  this.edgeAnalysisDone = false;
1806
1797
  this.totalSeedsThisGrid = 0;
@@ -1829,25 +1820,22 @@ var RectDiffSeedingSolver = class extends BaseSolver3 {
1829
1820
  maxMultiLayerSpan
1830
1821
  } = this.options;
1831
1822
  const grid = gridSizes[this.gridIndex];
1832
- const hardPlacedByLayer = allLayerNode({
1833
- layerCount: this.layerCount,
1834
- placed: this.placed
1835
- });
1836
1823
  if (this.candidates.length === 0 && this.consumedSeedsThisGrid === 0) {
1837
1824
  this.candidates = computeCandidates3D({
1838
1825
  bounds: this.bounds,
1839
1826
  gridSize: grid,
1840
1827
  layerCount: this.layerCount,
1841
- hardPlacedByLayer,
1828
+ hardPlacedByLayer: this.hardPlacedByLayer,
1842
1829
  obstacleIndexByLayer: this.input.obstacleIndexByLayer,
1843
1830
  placedIndexByLayer: this.placedIndexByLayer
1844
1831
  });
1845
1832
  this.totalSeedsThisGrid = this.candidates.length;
1846
1833
  this.consumedSeedsThisGrid = 0;
1847
1834
  }
1848
- if (this.candidates.length === 0) {
1835
+ if (this.consumedSeedsThisGrid >= this.candidates.length) {
1849
1836
  if (this.gridIndex + 1 < gridSizes.length) {
1850
1837
  this.gridIndex += 1;
1838
+ this.candidates = [];
1851
1839
  this.totalSeedsThisGrid = 0;
1852
1840
  this.consumedSeedsThisGrid = 0;
1853
1841
  return;
@@ -1860,20 +1848,28 @@ var RectDiffSeedingSolver = class extends BaseSolver3 {
1860
1848
  layerCount: this.layerCount,
1861
1849
  obstacleIndexByLayer: this.input.obstacleIndexByLayer,
1862
1850
  placedIndexByLayer: this.placedIndexByLayer,
1863
- hardPlacedByLayer
1851
+ hardPlacedByLayer: this.hardPlacedByLayer
1864
1852
  });
1865
1853
  this.edgeAnalysisDone = true;
1866
1854
  this.totalSeedsThisGrid = this.candidates.length;
1867
1855
  this.consumedSeedsThisGrid = 0;
1868
1856
  return;
1869
1857
  }
1858
+ this.candidates = [];
1870
1859
  this.solved = true;
1871
1860
  this.expansionIndex = 0;
1872
1861
  return;
1873
1862
  }
1874
1863
  }
1875
- const cand = this.candidates.shift();
1876
- this.consumedSeedsThisGrid += 1;
1864
+ const cand = this.candidates[this.consumedSeedsThisGrid++];
1865
+ if (isFullyOccupiedAtPoint({
1866
+ layerCount: this.layerCount,
1867
+ obstacleIndexByLayer: this.input.obstacleIndexByLayer,
1868
+ placedIndexByLayer: this.placedIndexByLayer,
1869
+ point: { x: cand.x, y: cand.y }
1870
+ })) {
1871
+ return;
1872
+ }
1877
1873
  const span = longestFreeSpanAroundZ({
1878
1874
  x: cand.x,
1879
1875
  y: cand.y,
@@ -1882,7 +1878,7 @@ var RectDiffSeedingSolver = class extends BaseSolver3 {
1882
1878
  minSpan: minMulti.minLayers,
1883
1879
  maxSpan: maxMultiLayerSpan,
1884
1880
  obstacleIndexByLayer: this.input.obstacleIndexByLayer,
1885
- additionalBlockersByLayer: hardPlacedByLayer
1881
+ additionalBlockersByLayer: this.hardPlacedByLayer
1886
1882
  });
1887
1883
  const attempts = [];
1888
1884
  if (span.length >= minMulti.minLayers) {
@@ -1929,14 +1925,10 @@ var RectDiffSeedingSolver = class extends BaseSolver3 {
1929
1925
  },
1930
1926
  newIndex
1931
1927
  );
1932
- this.candidates = this.candidates.filter(
1933
- (c) => !isFullyOccupiedAtPoint({
1934
- layerCount: this.layerCount,
1935
- obstacleIndexByLayer: this.input.obstacleIndexByLayer,
1936
- placedIndexByLayer: this.placedIndexByLayer,
1937
- point: { x: c.x, y: c.y }
1938
- })
1939
- );
1928
+ this.hardPlacedByLayer = allLayerNode({
1929
+ layerCount: this.layerCount,
1930
+ placed: this.placed
1931
+ });
1940
1932
  return;
1941
1933
  }
1942
1934
  }
@@ -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
  }
@@ -27,6 +27,10 @@ export function computeCandidates3D(params: {
27
27
  hardPlacedByLayer,
28
28
  } = params
29
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
+ ])
30
34
 
31
35
  for (let x = bounds.x; x < bounds.x + bounds.width; x += gridSize) {
32
36
  for (let y = bounds.y; y < bounds.y + bounds.height; y += gridSize) {
@@ -75,16 +79,11 @@ export function computeCandidates3D(params: {
75
79
  : bestZ
76
80
 
77
81
  // Distance heuristic against hard blockers only (obstacles + full-stack)
78
- const hardAtZ = [
79
- ...(obstacleIndexByLayer[anchorZ]?.all() ?? []),
80
- ...(hardPlacedByLayer[anchorZ] ?? []),
81
- ]
82
- const d = Math.min(
83
- distancePointToRectEdges({ x, y }, bounds),
84
- ...(hardAtZ.length
85
- ? hardAtZ.map((b) => distancePointToRectEdges({ x, y }, b))
86
- : [Infinity]),
87
- )
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
+ }
88
87
  const distance = quantize(d)
89
88
 
90
89
  const k = `${x.toFixed(6)}|${y.toFixed(6)}`
@@ -7,6 +7,16 @@ import type { RTreeRect } from "lib/types/capacity-mesh-types"
7
7
  const quantize = (value: number, precision = 1e-6) =>
8
8
  Math.round(value / precision) * precision
9
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
19
+
10
20
  /**
11
21
  * Compute exact uncovered segments along a 1D line.
12
22
  */
@@ -109,6 +119,12 @@ export function computeEdgeCandidates3D(params: {
109
119
  // Use small inset from edges for placement
110
120
  const δ = Math.max(minSize * 0.15, EPS * 3)
111
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
+ )
112
128
  const key = (p: { x: number; y: number; z: number }) =>
113
129
  `${p.z}|${p.x.toFixed(6)}|${p.y.toFixed(6)}`
114
130
 
@@ -137,21 +153,11 @@ export function computeEdgeCandidates3D(params: {
137
153
  if (fullyOcc({ x, y })) return // new rule: only drop if truly impossible
138
154
 
139
155
  // Distance uses obstacles + hard nodes (soft nodes ignored for ranking)
140
- const hard = [
141
- ...(obstacleIndexByLayer[z]?.all() ?? []),
142
- ...(hardPlacedByLayer[z] ?? []),
143
- ].map((b) => ({
144
- x: quantize(b.x),
145
- y: quantize(b.y),
146
- width: quantize(b.width),
147
- height: quantize(b.height),
148
- }))
149
- const d = Math.min(
150
- distancePointToRectEdges({ x, y }, bounds),
151
- ...(hard.length
152
- ? hard.map((b) => distancePointToRectEdges({ x, y }, b))
153
- : [Infinity]),
154
- )
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
+ }
155
161
  const distance = quantize(d)
156
162
 
157
163
  const k = key({ x, y, z })
@@ -180,10 +186,7 @@ export function computeEdgeCandidates3D(params: {
180
186
  }
181
187
 
182
188
  for (let z = 0; z < layerCount; z++) {
183
- const blockers = [
184
- ...(obstacleIndexByLayer[z]?.all() ?? []),
185
- ...(hardPlacedByLayer[z] ?? []),
186
- ].map((b) => ({
189
+ const blockers = (hardRectsByLayer[z] ?? []).map((b) => ({
187
190
  x: quantize(b.x),
188
191
  y: quantize(b.y),
189
192
  width: quantize(b.width),
@@ -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 }))
@@ -175,14 +175,13 @@ const toRect = (tree: RTreeRect): XYRect => ({
175
175
  })
176
176
 
177
177
  const addBlocker = (params: {
178
- rect: XYRect
179
- seen: Set<string>
178
+ rect: RTreeRect
179
+ seen: Set<RTreeRect>
180
180
  blockers: XYRect[]
181
181
  }) => {
182
182
  const { rect, seen, blockers } = params
183
- const key = `${rect.x}|${rect.y}|${rect.width}|${rect.height}`
184
- if (seen.has(key)) return
185
- seen.add(key)
183
+ if (seen.has(rect)) return
184
+ seen.add(rect)
186
185
  blockers.push(rect)
187
186
  }
188
187
 
@@ -225,7 +224,7 @@ export function expandRectFromSeed(params: {
225
224
  const initialW = Math.max(minSide, minReq.width)
226
225
  const initialH = Math.max(minSide, minReq.height)
227
226
  const blockers: XYRect[] = []
228
- const seen = new Set<string>()
227
+ const seen = new Set<RTreeRect>()
229
228
  const totalLayers = placedIndexByLayer.length
230
229
 
231
230
  // Ignore the existing placement we are expanding so it doesn't self-block.
@@ -237,7 +236,7 @@ export function expandRectFromSeed(params: {
237
236
  const blockersIndex = obsticalIndexByLayer[z]
238
237
  if (blockersIndex) {
239
238
  for (const entry of blockersIndex.search(query))
240
- addBlocker({ rect: toRect(entry), seen, blockers })
239
+ addBlocker({ rect: entry, seen, blockers })
241
240
  }
242
241
 
243
242
  const placedLayer = placedIndexByLayer[z]
@@ -245,10 +244,9 @@ export function expandRectFromSeed(params: {
245
244
  for (const entry of placedLayer.search(query)) {
246
245
  const isFullStack = entry.zLayers.length >= totalLayers
247
246
  if (!isFullStack) continue
248
- const rect = toRect(entry)
249
247
  if (
250
248
  isSelfRect({
251
- rect,
249
+ rect: entry,
252
250
  startX,
253
251
  startY,
254
252
  initialW,
@@ -256,7 +254,7 @@ export function expandRectFromSeed(params: {
256
254
  })
257
255
  )
258
256
  continue
259
- addBlocker({ rect, seen, blockers })
257
+ addBlocker({ rect: entry, seen, blockers })
260
258
  }
261
259
  }
262
260
  }
@@ -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. */