@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.
- package/AGENTS.md +23 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +195 -144
- package/lib/RectDiffPipeline.ts +4 -37
- package/lib/buildFinalRectDiffVisualization.ts +46 -0
- package/lib/solvers/RectDiffSeedingSolver/RectDiffSeedingSolver.ts +23 -48
- package/lib/solvers/RectDiffSeedingSolver/computeCandidates3D.ts +21 -12
- package/lib/solvers/RectDiffSeedingSolver/computeEdgeCandidates3D.ts +68 -25
- package/lib/solvers/RectDiffSeedingSolver/longestFreeSpanAroundZ.ts +1 -1
- package/lib/utils/buildOutlineGraphics.ts +39 -0
- package/lib/utils/expandRectFromSeed.ts +19 -11
- package/lib/utils/isFullyOccupiedAtPoint.ts +2 -2
- package/lib/utils/rectdiff-geometry.ts +13 -20
- package/package.json +2 -1
- package/scripts/benchmark-slow-problem.ts +94 -0
- package/test-assets/keyboard4.json +16165 -0
- package/tests/solver/__snapshots__/rectDiffGridSolverPipeline.snap.svg +1 -1
- package/tests/solver/bugreport01-be84eb/__snapshots__/bugreport01-be84eb.snap.svg +2 -2
- package/tests/solver/bugreport02-bc4361/__snapshots__/bugreport02-bc4361.snap.svg +2 -2
- package/tests/solver/bugreport03-fe4a17/__snapshots__/bugreport03-fe4a17.snap.svg +1 -1
- package/tests/solver/bugreport07-d3f3be/__snapshots__/bugreport07-d3f3be.snap.svg +1 -1
- package/tests/solver/bugreport08-e3ec95/__snapshots__/bugreport08-e3ec95.snap.svg +1 -1
- package/tests/solver/bugreport09-618e09/__snapshots__/bugreport09-618e09.snap.svg +2 -2
- package/tests/solver/bugreport10-71239a/__snapshots__/bugreport10-71239a.snap.svg +2 -2
- package/tests/solver/bugreport11-b2de3c/__snapshots__/bugreport11-b2de3c.snap.svg +1 -1
- package/tests/solver/bugreport11-b2de3c/bugreport11-b2de3c-clearance-equivalence.test.ts +52 -0
- package/tests/solver/bugreport12-35ce1c/__snapshots__/bugreport12-35ce1c.snap.svg +1 -1
- package/tests/solver/bugreport13-b9a758/__snapshots__/bugreport13-b9a758.snap.svg +2 -2
- package/tests/solver/bugreport16-d95f38/__snapshots__/bugreport16-d95f38.snap.svg +2 -2
- package/tests/solver/bugreport19/__snapshots__/bugreport19.snap.svg +1 -1
- package/tests/solver/bugreport22-2a75ce/__snapshots__/bugreport22-2a75ce.snap.svg +1 -1
- package/tests/solver/bugreport23-LGA15x4/__snapshots__/bugreport23-LGA15x4.snap.svg +1 -1
- package/tests/solver/bugreport24-05597c/__snapshots__/bugreport24-05597c.snap.svg +1 -1
- package/tests/solver/bugreport26-66b0b2/__snapshots__/bugreport26-66b0b2.snap.svg +2 -2
- package/tests/solver/bugreport27-dd3734/__snapshots__/bugreport27-dd3734.snap.svg +2 -2
- package/tests/solver/bugreport28-18a9ef/__snapshots__/bugreport28-18a9ef.snap.svg +2 -2
- package/tests/solver/bugreport29-7deae8/__snapshots__/bugreport29-7deae8.snap.svg +2 -2
- package/tests/solver/bugreport30-2174c8/__snapshots__/bugreport30-2174c8.snap.svg +1 -1
- package/tests/solver/bugreport33-213d45/__snapshots__/bugreport33-213d45.snap.svg +2 -2
- package/tests/solver/bugreport34-e9dea2/__snapshots__/bugreport34-e9dea2.snap.svg +2 -2
- package/tests/solver/bugreport35-191db9/__snapshots__/bugreport35-191db9.snap.svg +2 -2
- package/tests/solver/bugreport36-bf8303/__snapshots__/bugreport36-bf8303.snap.svg +1 -1
- package/tests/solver/interaction/__snapshots__/interaction.snap.svg +1 -1
- package/tests/solver/multi-point/__snapshots__/multi-point.snap.svg +1 -1
- package/tests/solver/pcb_trace_id-should-return-root-connection-name/__snapshots__/pcb_trace_id-should-return-root-connection-name.snap.svg +1 -1
- package/tests/solver/bugreport11-b2de3c/__snapshots__/bugreport11-b2de3c-clearance.snap.svg +0 -44
- 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
|
|
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.
|
|
218
|
-
|
|
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
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
|
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(
|
|
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 (
|
|
20
|
-
const center = (
|
|
21
|
-
return [{ start:
|
|
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 = [...
|
|
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 >
|
|
49
|
-
const start =
|
|
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 <
|
|
87
|
+
if (merged[merged.length - 1]!.end < lineEndQ - EPS) {
|
|
67
88
|
const start = merged[merged.length - 1]!.end
|
|
68
|
-
const end =
|
|
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
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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({
|
|
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
|
-
|
|
157
|
-
|
|
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(
|
|
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.
|
|
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:
|
|
169
|
-
seen: Set<
|
|
178
|
+
rect: RTreeRect
|
|
179
|
+
seen: Set<RTreeRect>
|
|
170
180
|
blockers: XYRect[]
|
|
171
181
|
}) => {
|
|
172
182
|
const { rect, seen, blockers } = params
|
|
173
|
-
|
|
174
|
-
|
|
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<
|
|
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:
|
|
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.
|
|
20
|
+
const hasObstacle = !!obstacleIdx && obstacleIdx.collides(query)
|
|
21
21
|
|
|
22
22
|
const placedIdx = params.placedIndexByLayer[z]
|
|
23
|
-
const hasPlaced = !!placedIdx && placedIdx.
|
|
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
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
},
|