@tscircuit/rectdiff 0.0.6 → 0.0.7

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.
@@ -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
- // Initialize gap fill subsolver if needed
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
- if (
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 ob of this.srj.obstacles ?? []) {
227
- if (ob.type === "rect" || ob.type === "oval") {
167
+ for (const obstacle of this.srj.obstacles ?? []) {
168
+ if (obstacle.type === "rect" || obstacle.type === "oval") {
228
169
  rects.push({
229
- center: { x: ob.center.x, y: ob.center.y },
230
- width: ob.width,
231
- height: ob.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
- for (const ob of srj.obstacles ?? []) {
48
- const r = obstacleToXYRect(ob)
49
- if (!r) continue
50
- const zs = obstacleZs(ob, zIndexByName)
51
- const invalidZs = zs.filter((z) => z < 0 || z >= layerCount)
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 ((!ob.zLayers || ob.zLayers.length === 0) && zs.length) ob.zLayers = zs
61
- for (const z of zs) obstaclesByLayer[z]!.push(r)
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
+ }
@@ -49,6 +49,7 @@ export type RectDiffState = {
49
49
  maxMultiLayerSpan: number | undefined
50
50
  }
51
51
  obstaclesByLayer: XYRect[][]
52
+ boardVoidRects: XYRect[] // newly added for viz
52
53
 
53
54
  // evolving
54
55
  phase: Phase
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tscircuit/rectdiff",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {
@@ -0,0 +1,59 @@
1
+ <svg width="640" height="640" viewBox="0 0 640 640" xmlns="http://www.w3.org/2000/svg"><rect width="100%" height="100%" fill="white"/><g><polyline data-points="-8,-6 -2,-6 -2,2 2,2 2,-6 8,-6 8,6 -8,6 -8,-6" data-type="line" data-label="outline" points="40,530 250,530 250,250.00000000000006 390,250.00000000000006 390,530 600,530 600,110.00000000000006 40,110.00000000000006 40,530" fill="none" stroke="#111827" stroke-width="0.35000000000000003"/></g><g><rect data-type="rect" data-label="obstacle" data-x="-5.51" data-y="-4" x="117.70000000000002" y="448.80000000000007" width="18.899999999999977" height="22.399999999999977" fill="#fee2e2" stroke="#ef4444" stroke-width="0.02857142857142857"/></g><g><rect data-type="rect" data-label="obstacle" data-x="-4.49" data-y="-4" x="153.4" y="448.80000000000007" width="18.899999999999977" height="22.399999999999977" fill="#fee2e2" stroke="#ef4444" stroke-width="0.02857142857142857"/></g><g><rect data-type="rect" data-label="obstacle" data-x="4.49" data-y="-4" x="467.70000000000005" y="448.80000000000007" width="18.899999999999977" height="22.399999999999977" fill="#fee2e2" stroke="#ef4444" stroke-width="0.02857142857142857"/></g><g><rect data-type="rect" data-label="obstacle" data-x="5.51" data-y="-4" x="503.4" y="448.80000000000007" width="18.899999999999977" height="22.399999999999977" fill="#fee2e2" stroke="#ef4444" stroke-width="0.02857142857142857"/></g><g><rect data-type="rect" data-label="void" data-x="0" data-y="-2" x="250" y="250.00000000000006" width="140" height="279.99999999999994" fill="rgba(0, 0, 0, 0.5)" stroke="none" stroke-width="0.02857142857142857"/></g><g><rect data-type="rect" data-label="free
2
+ z:0,1" data-x="-5" data-y="1.1600000000000006" x="40" y="110" width="210" height="338.80000000000007" fill="#dbeafe" stroke="#3b82f6" stroke-width="0.02857142857142857"/></g><g><rect data-type="rect" data-label="free
3
+ z:0,1" data-x="5" data-y="1.1600000000000006" x="390" y="110" width="210" height="338.80000000000007" fill="#dbeafe" stroke="#3b82f6" stroke-width="0.02857142857142857"/></g><g><rect data-type="rect" data-label="free
4
+ z:0,1" data-x="0" data-y="4" x="250" y="110.00000000000006" width="140" height="140" fill="#dbeafe" stroke="#3b82f6" stroke-width="0.02857142857142857"/></g><g><rect data-type="rect" data-label="free
5
+ z:0,1" data-x="-5.48" data-y="-5.16" x="40" y="471.20000000000005" width="176.39999999999998" height="58.799999999999955" fill="#dbeafe" stroke="#3b82f6" stroke-width="0.02857142857142857"/></g><g><rect data-type="rect" data-label="free
6
+ z:0,1" data-x="5.48" data-y="-5.16" x="423.6" y="471.20000000000005" width="176.39999999999998" height="58.799999999999955" fill="#dbeafe" stroke="#3b82f6" stroke-width="0.02857142857142857"/></g><g><rect data-type="rect" data-label="free
7
+ z:0,1" data-x="-2.4800000000000004" data-y="-4.84" x="216.39999999999998" y="448.80000000000007" width="33.60000000000002" height="81.19999999999993" fill="#dbeafe" stroke="#3b82f6" stroke-width="0.02857142857142857"/></g><g><rect data-type="rect" data-label="free
8
+ z:0,1" data-x="2.4800000000000004" data-y="-4.84" x="390" y="448.80000000000007" width="33.60000000000002" height="81.19999999999993" fill="#dbeafe" stroke="#3b82f6" stroke-width="0.02857142857142857"/></g><g><rect data-type="rect" data-label="free
9
+ z:1" data-x="-5.48" data-y="-4" x="40" y="448.80000000000007" width="176.39999999999998" height="22.399999999999977" fill="#fef3c7" stroke="#f59e0b" stroke-width="0.02857142857142857"/></g><g><rect data-type="rect" data-label="free
10
+ z:1" data-x="5.48" data-y="-4" x="423.6" y="448.80000000000007" width="176.39999999999998" height="22.399999999999977" fill="#fef3c7" stroke="#f59e0b" stroke-width="0.02857142857142857"/></g><g><rect data-type="rect" data-label="free
11
+ z:0" data-x="6.890000000000001" data-y="-4" x="522.3" y="448.80000000000007" width="77.70000000000005" height="22.399999999999977" fill="#dbeafe" stroke="#3b82f6" stroke-width="0.02857142857142857"/></g><g><rect data-type="rect" data-label="free
12
+ z:0" data-x="-6.89" data-y="-4" x="40" y="448.80000000000007" width="77.70000000000002" height="22.399999999999977" fill="#dbeafe" stroke="#3b82f6" stroke-width="0.02857142857142857"/></g><g><rect data-type="rect" data-label="free
13
+ z:0" data-x="-5" data-y="-4" x="136.6" y="448.80000000000007" width="16.80000000000001" height="22.399999999999977" fill="#dbeafe" stroke="#3b82f6" stroke-width="0.02857142857142857"/></g><g><rect data-type="rect" data-label="free
14
+ z:0" data-x="-3.5900000000000003" data-y="-4" x="172.3" y="448.80000000000007" width="44.099999999999966" height="22.399999999999977" fill="#dbeafe" stroke="#3b82f6" stroke-width="0.02857142857142857"/></g><g><rect data-type="rect" data-label="free
15
+ z:0" data-x="3.5900000000000007" data-y="-4" x="423.6" y="448.80000000000007" width="44.10000000000002" height="22.399999999999977" fill="#dbeafe" stroke="#3b82f6" stroke-width="0.02857142857142857"/></g><g><rect data-type="rect" data-label="free
16
+ z:0" data-x="5" data-y="-4" x="486.6" y="448.80000000000007" width="16.799999999999955" height="22.399999999999977" fill="#dbeafe" stroke="#3b82f6" stroke-width="0.02857142857142857"/></g><g id="crosshair" style="display: none"><line id="crosshair-h" y1="0" y2="640" stroke="#666" stroke-width="0.5"/><line id="crosshair-v" x1="0" x2="640" stroke="#666" stroke-width="0.5"/><text id="coordinates" font-family="monospace" font-size="12" fill="#666"></text></g><script><![CDATA[
17
+ document.currentScript.parentElement.addEventListener('mousemove', (e) => {
18
+ const svg = e.currentTarget;
19
+ const rect = svg.getBoundingClientRect();
20
+ const x = e.clientX - rect.left;
21
+ const y = e.clientY - rect.top;
22
+ const crosshair = svg.getElementById('crosshair');
23
+ const h = svg.getElementById('crosshair-h');
24
+ const v = svg.getElementById('crosshair-v');
25
+ const coords = svg.getElementById('coordinates');
26
+
27
+ crosshair.style.display = 'block';
28
+ h.setAttribute('x1', '0');
29
+ h.setAttribute('x2', '640');
30
+ h.setAttribute('y1', y);
31
+ h.setAttribute('y2', y);
32
+ v.setAttribute('x1', x);
33
+ v.setAttribute('x2', x);
34
+ v.setAttribute('y1', '0');
35
+ v.setAttribute('y2', '640');
36
+
37
+ // Calculate real coordinates using inverse transformation
38
+ const matrix = {"a":35,"c":0,"e":320,"b":0,"d":-35,"f":320.00000000000006};
39
+ // Manually invert and apply the affine transform
40
+ // Since we only use translate and scale, we can directly compute:
41
+ // x' = (x - tx) / sx
42
+ // y' = (y - ty) / sy
43
+ const sx = matrix.a;
44
+ const sy = matrix.d;
45
+ const tx = matrix.e;
46
+ const ty = matrix.f;
47
+ const realPoint = {
48
+ x: (x - tx) / sx,
49
+ y: (y - ty) / sy // Flip y back since we used negative scale
50
+ }
51
+
52
+ coords.textContent = `(${realPoint.x.toFixed(2)}, ${realPoint.y.toFixed(2)})`;
53
+ coords.setAttribute('x', (x + 5).toString());
54
+ coords.setAttribute('y', (y - 5).toString());
55
+ });
56
+ document.currentScript.parentElement.addEventListener('mouseleave', () => {
57
+ document.currentScript.parentElement.getElementById('crosshair').style.display = 'none';
58
+ });
59
+ ]]></script></svg>
@@ -0,0 +1,18 @@
1
+ import { expect, test } from "bun:test"
2
+ import boardWithCutout from "../test-assets/board-with-cutout.json"
3
+ import { RectDiffSolver } from "../lib/solvers/RectDiffSolver"
4
+ import { getSvgFromGraphicsObject } from "graphics-debug"
5
+
6
+ test("board outline snapshot", async () => {
7
+ const solver = new RectDiffSolver({
8
+ simpleRouteJson: boardWithCutout as any,
9
+ })
10
+
11
+ // Run to completion
12
+ solver.solve()
13
+
14
+ const viz = solver.visualize()
15
+ const svg = getSvgFromGraphicsObject(viz)
16
+
17
+ await expect(svg).toMatchSvgSnapshot(import.meta.path)
18
+ })