@tscircuit/rectdiff 0.0.6 → 0.0.8
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/dist/index.d.ts +0 -97
- package/dist/index.js +168 -655
- package/lib/solvers/RectDiffSolver.ts +48 -67
- package/lib/solvers/rectdiff/engine.ts +26 -7
- package/lib/solvers/rectdiff/geometry/computeInverseRects.ts +108 -0
- package/lib/solvers/rectdiff/geometry/isPointInPolygon.ts +20 -0
- package/lib/solvers/rectdiff/types.ts +1 -0
- package/package.json +1 -1
- package/pages/keyboard-bugreport04.page.tsx +17 -0
- package/test-assets/bugreport04-aa1d41.json +5378 -0
- package/tests/__snapshots__/board-outline.snap.svg +59 -0
- package/tests/board-outline.test.ts +18 -0
|
@@ -13,12 +13,12 @@ import {
|
|
|
13
13
|
computeProgress,
|
|
14
14
|
} from "./rectdiff/engine"
|
|
15
15
|
import { rectsToMeshNodes } from "./rectdiff/rectsToMeshNodes"
|
|
16
|
+
import { overlaps } from "./rectdiff/geometry"
|
|
16
17
|
import type { GapFillOptions } from "./rectdiff/gapfill/types"
|
|
17
18
|
import {
|
|
18
19
|
findUncoveredPoints,
|
|
19
20
|
calculateCoverage,
|
|
20
21
|
} from "./rectdiff/gapfill/engine"
|
|
21
|
-
import { GapFillSubSolver } from "./rectdiff/subsolvers/GapFillSubSolver"
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* A streaming, one-step-per-iteration solver for capacity mesh generation.
|
|
@@ -26,23 +26,16 @@ import { GapFillSubSolver } from "./rectdiff/subsolvers/GapFillSubSolver"
|
|
|
26
26
|
export class RectDiffSolver extends BaseSolver {
|
|
27
27
|
private srj: SimpleRouteJson
|
|
28
28
|
private gridOptions: Partial<GridFill3DOptions>
|
|
29
|
-
private gapFillOptions: Partial<GapFillOptions>
|
|
30
29
|
private state!: RectDiffState
|
|
31
30
|
private _meshNodes: CapacityMeshNode[] = []
|
|
32
31
|
|
|
33
|
-
/** Active subsolver for GAP_FILL phases. */
|
|
34
|
-
declare activeSubSolver: GapFillSubSolver | null
|
|
35
|
-
|
|
36
32
|
constructor(opts: {
|
|
37
33
|
simpleRouteJson: SimpleRouteJson
|
|
38
34
|
gridOptions?: Partial<GridFill3DOptions>
|
|
39
|
-
gapFillOptions?: Partial<GapFillOptions>
|
|
40
35
|
}) {
|
|
41
36
|
super()
|
|
42
37
|
this.srj = opts.simpleRouteJson
|
|
43
38
|
this.gridOptions = opts.gridOptions ?? {}
|
|
44
|
-
this.gapFillOptions = opts.gapFillOptions ?? {}
|
|
45
|
-
this.activeSubSolver = null
|
|
46
39
|
}
|
|
47
40
|
|
|
48
41
|
override _setup() {
|
|
@@ -60,44 +53,7 @@ export class RectDiffSolver extends BaseSolver {
|
|
|
60
53
|
} else if (this.state.phase === "EXPANSION") {
|
|
61
54
|
stepExpansion(this.state)
|
|
62
55
|
} else if (this.state.phase === "GAP_FILL") {
|
|
63
|
-
|
|
64
|
-
if (
|
|
65
|
-
!this.activeSubSolver ||
|
|
66
|
-
!(this.activeSubSolver instanceof GapFillSubSolver)
|
|
67
|
-
) {
|
|
68
|
-
const minTrace = this.srj.minTraceWidth || 0.15
|
|
69
|
-
const minGapSize = Math.max(0.01, minTrace / 10)
|
|
70
|
-
const boundsSize = Math.min(
|
|
71
|
-
this.state.bounds.width,
|
|
72
|
-
this.state.bounds.height,
|
|
73
|
-
)
|
|
74
|
-
this.activeSubSolver = new GapFillSubSolver({
|
|
75
|
-
placed: this.state.placed,
|
|
76
|
-
options: {
|
|
77
|
-
minWidth: minGapSize,
|
|
78
|
-
minHeight: minGapSize,
|
|
79
|
-
scanResolution: Math.max(0.05, boundsSize / 100),
|
|
80
|
-
...this.gapFillOptions,
|
|
81
|
-
},
|
|
82
|
-
layerCtx: {
|
|
83
|
-
bounds: this.state.bounds,
|
|
84
|
-
layerCount: this.state.layerCount,
|
|
85
|
-
obstaclesByLayer: this.state.obstaclesByLayer,
|
|
86
|
-
placedByLayer: this.state.placedByLayer,
|
|
87
|
-
},
|
|
88
|
-
})
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
this.activeSubSolver.step()
|
|
92
|
-
|
|
93
|
-
if (this.activeSubSolver.solved) {
|
|
94
|
-
// Transfer results back to main state
|
|
95
|
-
const output = this.activeSubSolver.getOutput()
|
|
96
|
-
this.state.placed = output.placed
|
|
97
|
-
this.state.placedByLayer = output.placedByLayer
|
|
98
|
-
this.activeSubSolver = null
|
|
99
|
-
this.state.phase = "DONE"
|
|
100
|
-
}
|
|
56
|
+
this.state.phase = "DONE"
|
|
101
57
|
} else if (this.state.phase === "DONE") {
|
|
102
58
|
// Finalize once
|
|
103
59
|
if (!this.solved) {
|
|
@@ -112,10 +68,6 @@ export class RectDiffSolver extends BaseSolver {
|
|
|
112
68
|
this.stats.phase = this.state.phase
|
|
113
69
|
this.stats.gridIndex = this.state.gridIndex
|
|
114
70
|
this.stats.placed = this.state.placed.length
|
|
115
|
-
if (this.activeSubSolver instanceof GapFillSubSolver) {
|
|
116
|
-
const output = this.activeSubSolver.getOutput()
|
|
117
|
-
this.stats.gapsFilled = output.filledCount
|
|
118
|
-
}
|
|
119
71
|
}
|
|
120
72
|
|
|
121
73
|
/** Compute solver progress (0 to 1). */
|
|
@@ -123,13 +75,7 @@ export class RectDiffSolver extends BaseSolver {
|
|
|
123
75
|
if (this.solved || this.state.phase === "DONE") {
|
|
124
76
|
return 1
|
|
125
77
|
}
|
|
126
|
-
|
|
127
|
-
this.state.phase === "GAP_FILL" &&
|
|
128
|
-
this.activeSubSolver instanceof GapFillSubSolver
|
|
129
|
-
) {
|
|
130
|
-
return 0.85 + 0.1 * this.activeSubSolver.computeProgress()
|
|
131
|
-
}
|
|
132
|
-
return computeProgress(this.state) * 0.85
|
|
78
|
+
return computeProgress(this.state)
|
|
133
79
|
}
|
|
134
80
|
|
|
135
81
|
override getOutput(): { meshNodes: CapacityMeshNode[] } {
|
|
@@ -183,11 +129,6 @@ export class RectDiffSolver extends BaseSolver {
|
|
|
183
129
|
|
|
184
130
|
/** Streaming visualization: board + obstacles + current placements. */
|
|
185
131
|
override visualize(): GraphicsObject {
|
|
186
|
-
// If a subsolver is active, delegate to its visualization
|
|
187
|
-
if (this.activeSubSolver) {
|
|
188
|
-
return this.activeSubSolver.visualize()
|
|
189
|
-
}
|
|
190
|
-
|
|
191
132
|
const rects: NonNullable<GraphicsObject["rects"]> = []
|
|
192
133
|
const points: NonNullable<GraphicsObject["points"]> = []
|
|
193
134
|
const lines: NonNullable<GraphicsObject["lines"]> = [] // Initialize lines array
|
|
@@ -223,12 +164,12 @@ export class RectDiffSolver extends BaseSolver {
|
|
|
223
164
|
}
|
|
224
165
|
|
|
225
166
|
// obstacles (rect & oval as bounding boxes)
|
|
226
|
-
for (const
|
|
227
|
-
if (
|
|
167
|
+
for (const obstacle of this.srj.obstacles ?? []) {
|
|
168
|
+
if (obstacle.type === "rect" || obstacle.type === "oval") {
|
|
228
169
|
rects.push({
|
|
229
|
-
center: { x:
|
|
230
|
-
width:
|
|
231
|
-
height:
|
|
170
|
+
center: { x: obstacle.center.x, y: obstacle.center.y },
|
|
171
|
+
width: obstacle.width,
|
|
172
|
+
height: obstacle.height,
|
|
232
173
|
fill: "#fee2e2",
|
|
233
174
|
stroke: "#ef4444",
|
|
234
175
|
layer: "obstacle",
|
|
@@ -237,6 +178,46 @@ export class RectDiffSolver extends BaseSolver {
|
|
|
237
178
|
}
|
|
238
179
|
}
|
|
239
180
|
|
|
181
|
+
// board void rects
|
|
182
|
+
if (this.state?.boardVoidRects) {
|
|
183
|
+
// If outline exists, compute its bbox to hide outer padding voids
|
|
184
|
+
let outlineBBox: {
|
|
185
|
+
x: number
|
|
186
|
+
y: number
|
|
187
|
+
width: number
|
|
188
|
+
height: number
|
|
189
|
+
} | null = null
|
|
190
|
+
|
|
191
|
+
if (this.srj.outline && this.srj.outline.length > 0) {
|
|
192
|
+
const xs = this.srj.outline.map((p) => p.x)
|
|
193
|
+
const ys = this.srj.outline.map((p) => p.y)
|
|
194
|
+
const minX = Math.min(...xs)
|
|
195
|
+
const minY = Math.min(...ys)
|
|
196
|
+
outlineBBox = {
|
|
197
|
+
x: minX,
|
|
198
|
+
y: minY,
|
|
199
|
+
width: Math.max(...xs) - minX,
|
|
200
|
+
height: Math.max(...ys) - minY,
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
for (const r of this.state.boardVoidRects) {
|
|
205
|
+
// If we have an outline, only show voids that overlap its bbox (hides outer padding)
|
|
206
|
+
if (outlineBBox && !overlaps(r, outlineBBox)) {
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
rects.push({
|
|
211
|
+
center: { x: r.x + r.width / 2, y: r.y + r.height / 2 },
|
|
212
|
+
width: r.width,
|
|
213
|
+
height: r.height,
|
|
214
|
+
fill: "rgba(0, 0, 0, 0.5)",
|
|
215
|
+
stroke: "none",
|
|
216
|
+
label: "void",
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
240
221
|
// candidate positions (where expansion started from)
|
|
241
222
|
if (this.state?.candidates?.length) {
|
|
242
223
|
for (const cand of this.state.candidates) {
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
overlaps,
|
|
21
21
|
subtractRect2D,
|
|
22
22
|
} from "./geometry"
|
|
23
|
+
import { computeInverseRects } from "./geometry/computeInverseRects"
|
|
23
24
|
import { buildZIndexMap, obstacleToXYRect, obstacleZs } from "./layers"
|
|
24
25
|
|
|
25
26
|
/**
|
|
@@ -44,11 +45,24 @@ export function initState(
|
|
|
44
45
|
{ length: layerCount },
|
|
45
46
|
() => [],
|
|
46
47
|
)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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)
|
|
52
66
|
if (invalidZs.length) {
|
|
53
67
|
throw new Error(
|
|
54
68
|
`RectDiffSolver: obstacle uses z-layer indices ${invalidZs.join(
|
|
@@ -57,8 +71,9 @@ export function initState(
|
|
|
57
71
|
)
|
|
58
72
|
}
|
|
59
73
|
// Persist normalized zLayers back onto the shared SRJ so downstream solvers see them.
|
|
60
|
-
if ((!
|
|
61
|
-
|
|
74
|
+
if ((!obstacle.zLayers || obstacle.zLayers.length === 0) && zLayers.length)
|
|
75
|
+
obstacle.zLayers = zLayers
|
|
76
|
+
for (const z of zLayers) obstaclesByLayer[z]!.push(rect)
|
|
62
77
|
}
|
|
63
78
|
|
|
64
79
|
const trace = Math.max(0.01, srj.minTraceWidth || 0.15)
|
|
@@ -97,6 +112,7 @@ export function initState(
|
|
|
97
112
|
bounds,
|
|
98
113
|
options,
|
|
99
114
|
obstaclesByLayer,
|
|
115
|
+
boardVoidRects,
|
|
100
116
|
phase: "GRID",
|
|
101
117
|
gridIndex: 0,
|
|
102
118
|
candidates: [],
|
|
@@ -426,7 +442,10 @@ export function finalizeRects(state: RectDiffState): Rect3d[] {
|
|
|
426
442
|
})
|
|
427
443
|
|
|
428
444
|
// Append obstacle nodes to the output
|
|
445
|
+
const voidSet = new Set(state.boardVoidRects || [])
|
|
429
446
|
for (const [rect, layerIndices] of layersByObstacleRect.entries()) {
|
|
447
|
+
if (voidSet.has(rect)) continue // Skip void rects
|
|
448
|
+
|
|
430
449
|
out.push({
|
|
431
450
|
minX: rect.x,
|
|
432
451
|
minY: rect.y,
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import type { XYRect } from "../types"
|
|
2
|
+
import { isPointInPolygon } from "./isPointInPolygon"
|
|
3
|
+
import { EPS } from "../geometry" // Import EPS from common geometry file
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Decompose the empty space inside 'bounds' but outside 'polygon' into rectangles.
|
|
7
|
+
* This uses a coordinate grid approach, ideal for rectilinear polygons.
|
|
8
|
+
*/
|
|
9
|
+
export function computeInverseRects(
|
|
10
|
+
bounds: XYRect,
|
|
11
|
+
polygon: Array<{ x: number; y: number }>,
|
|
12
|
+
): XYRect[] {
|
|
13
|
+
if (!polygon || polygon.length < 3) return []
|
|
14
|
+
|
|
15
|
+
// 1. Collect unique sorted X and Y coordinates
|
|
16
|
+
const xs = new Set<number>([bounds.x, bounds.x + bounds.width])
|
|
17
|
+
const ys = new Set<number>([bounds.y, bounds.y + bounds.height])
|
|
18
|
+
for (const p of polygon) {
|
|
19
|
+
xs.add(p.x)
|
|
20
|
+
ys.add(p.y)
|
|
21
|
+
}
|
|
22
|
+
const xSorted = Array.from(xs).sort((a, b) => a - b)
|
|
23
|
+
const ySorted = Array.from(ys).sort((a, b) => a - b)
|
|
24
|
+
|
|
25
|
+
// 2. Generate grid cells and classify them
|
|
26
|
+
const rawRects: XYRect[] = []
|
|
27
|
+
for (let i = 0; i < xSorted.length - 1; i++) {
|
|
28
|
+
for (let j = 0; j < ySorted.length - 1; j++) {
|
|
29
|
+
const x0 = xSorted[i]!
|
|
30
|
+
const x1 = xSorted[i + 1]!
|
|
31
|
+
const y0 = ySorted[j]!
|
|
32
|
+
const y1 = ySorted[j + 1]!
|
|
33
|
+
|
|
34
|
+
// Check center point
|
|
35
|
+
const cx = (x0 + x1) / 2
|
|
36
|
+
const cy = (y0 + y1) / 2
|
|
37
|
+
|
|
38
|
+
// If NOT in polygon, it's a void rect
|
|
39
|
+
if (
|
|
40
|
+
cx >= bounds.x &&
|
|
41
|
+
cx <= bounds.x + bounds.width &&
|
|
42
|
+
cy >= bounds.y &&
|
|
43
|
+
cy <= bounds.y + bounds.height
|
|
44
|
+
) {
|
|
45
|
+
if (!isPointInPolygon({ x: cx, y: cy }, polygon)) {
|
|
46
|
+
rawRects.push({ x: x0, y: y0, width: x1 - x0, height: y1 - y0 })
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// 3. Simple merge pass (horizontal)
|
|
53
|
+
const finalRects: XYRect[] = []
|
|
54
|
+
|
|
55
|
+
// Sort by y then x
|
|
56
|
+
rawRects.sort((a, b) => {
|
|
57
|
+
if (Math.abs(a.y - b.y) > EPS) return a.y - b.y
|
|
58
|
+
return a.x - b.x
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
let current: XYRect | null = null
|
|
62
|
+
for (const r of rawRects) {
|
|
63
|
+
if (!current) {
|
|
64
|
+
current = r
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const sameY = Math.abs(current.y - r.y) < EPS
|
|
69
|
+
const sameHeight = Math.abs(current.height - r.height) < EPS
|
|
70
|
+
const touchesX = Math.abs(current.x + current.width - r.x) < EPS
|
|
71
|
+
|
|
72
|
+
if (sameY && sameHeight && touchesX) {
|
|
73
|
+
current.width += r.width
|
|
74
|
+
} else {
|
|
75
|
+
finalRects.push(current)
|
|
76
|
+
current = r
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (current) finalRects.push(current)
|
|
80
|
+
|
|
81
|
+
// 4. Vertical merge pass
|
|
82
|
+
finalRects.sort((a, b) => {
|
|
83
|
+
if (Math.abs(a.x - b.x) > EPS) return a.x - b.x
|
|
84
|
+
return a.y - b.y
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const mergedVertical: XYRect[] = []
|
|
88
|
+
current = null
|
|
89
|
+
for (const r of finalRects) {
|
|
90
|
+
if (!current) {
|
|
91
|
+
current = r
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
const sameX = Math.abs(current.x - r.x) < EPS
|
|
95
|
+
const sameWidth = Math.abs(current.width - r.width) < EPS
|
|
96
|
+
const touchesY = Math.abs(current.y + current.height - r.y) < EPS
|
|
97
|
+
|
|
98
|
+
if (sameX && sameWidth && touchesY) {
|
|
99
|
+
current.height += r.height
|
|
100
|
+
} else {
|
|
101
|
+
mergedVertical.push(current)
|
|
102
|
+
current = r
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (current) mergedVertical.push(current)
|
|
106
|
+
|
|
107
|
+
return mergedVertical
|
|
108
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check if a point is inside a polygon using ray casting.
|
|
3
|
+
*/
|
|
4
|
+
export function isPointInPolygon(
|
|
5
|
+
p: { x: number; y: number },
|
|
6
|
+
polygon: Array<{ x: number; y: number }>,
|
|
7
|
+
): boolean {
|
|
8
|
+
let inside = false
|
|
9
|
+
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
|
|
10
|
+
const xi = polygon[i]!.x,
|
|
11
|
+
yi = polygon[i]!.y
|
|
12
|
+
const xj = polygon[j]!.x,
|
|
13
|
+
yj = polygon[j]!.y
|
|
14
|
+
|
|
15
|
+
const intersect =
|
|
16
|
+
yi > p.y !== yj > p.y && p.x < ((xj - xi) * (p.y - yi)) / (yj - yi) + xi
|
|
17
|
+
if (intersect) inside = !inside
|
|
18
|
+
}
|
|
19
|
+
return inside
|
|
20
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { GenericSolverDebugger } from "@tscircuit/solver-utils/react"
|
|
2
|
+
import simpleRouteJson from "../test-assets/bugreport04-aa1d41.json"
|
|
3
|
+
import { RectDiffSolver } from "../lib/solvers/RectDiffSolver"
|
|
4
|
+
import { useMemo } from "react"
|
|
5
|
+
import { SolverDebugger3d } from "../components/SolverDebugger3d"
|
|
6
|
+
|
|
7
|
+
export default () => {
|
|
8
|
+
const solver = useMemo(
|
|
9
|
+
() =>
|
|
10
|
+
new RectDiffSolver({
|
|
11
|
+
simpleRouteJson: simpleRouteJson.simple_route_json,
|
|
12
|
+
}),
|
|
13
|
+
[],
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
return <SolverDebugger3d solver={solver} />
|
|
17
|
+
}
|