@tscircuit/rectdiff 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/.claude/settings.local.json +9 -0
  2. package/.github/workflows/bun-formatcheck.yml +26 -0
  3. package/.github/workflows/bun-pver-release.yml +71 -0
  4. package/.github/workflows/bun-test.yml +31 -0
  5. package/.github/workflows/bun-typecheck.yml +26 -0
  6. package/CLAUDE.md +23 -0
  7. package/README.md +5 -0
  8. package/biome.json +93 -0
  9. package/bun.lock +29 -0
  10. package/bunfig.toml +5 -0
  11. package/components/SolverDebugger3d.tsx +833 -0
  12. package/cosmos.config.json +6 -0
  13. package/cosmos.decorator.tsx +21 -0
  14. package/dist/index.d.ts +111 -0
  15. package/dist/index.js +921 -0
  16. package/experiments/rect-fill-2d.tsx +983 -0
  17. package/experiments/rect3d_visualizer.html +640 -0
  18. package/global.d.ts +4 -0
  19. package/index.html +12 -0
  20. package/lib/index.ts +1 -0
  21. package/lib/solvers/RectDiffSolver.ts +158 -0
  22. package/lib/solvers/rectdiff/candidates.ts +397 -0
  23. package/lib/solvers/rectdiff/engine.ts +355 -0
  24. package/lib/solvers/rectdiff/geometry.ts +284 -0
  25. package/lib/solvers/rectdiff/layers.ts +48 -0
  26. package/lib/solvers/rectdiff/rectsToMeshNodes.ts +22 -0
  27. package/lib/solvers/rectdiff/types.ts +63 -0
  28. package/lib/types/capacity-mesh-types.ts +33 -0
  29. package/lib/types/srj-types.ts +37 -0
  30. package/package.json +33 -0
  31. package/pages/example01.page.tsx +11 -0
  32. package/test-assets/example01.json +933 -0
  33. package/tests/__snapshots__/svg.snap.svg +3 -0
  34. package/tests/examples/__snapshots__/example01.snap.svg +121 -0
  35. package/tests/examples/example01.test.tsx +65 -0
  36. package/tests/fixtures/preload.ts +1 -0
  37. package/tests/incremental-solver.test.ts +100 -0
  38. package/tests/rect-diff-solver.test.ts +154 -0
  39. package/tests/svg.test.ts +12 -0
  40. package/tsconfig.json +30 -0
@@ -0,0 +1,833 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
2
+ import { GenericSolverDebugger } from "@tscircuit/solver-utils/react"
3
+ import type { SimpleRouteJson } from "../lib/types/srj-types"
4
+ import type { CapacityMeshNode } from "../lib/types/capacity-mesh-types"
5
+ import * as THREE from "three"
6
+ import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"
7
+ import type { BaseSolver } from "@tscircuit/solver-utils"
8
+
9
+ type SolverDebugger3dProps = {
10
+ solver: BaseSolver
11
+ /** Optional SRJ to show board bounds & rectangular obstacles */
12
+ simpleRouteJson?: SimpleRouteJson
13
+ /** Visual Z thickness per layer (world units) */
14
+ layerThickness?: number
15
+ /** Canvas height */
16
+ height?: number
17
+ /** Initial toggles (user can change in UI) */
18
+ defaultShowRoot?: boolean
19
+ defaultShowObstacles?: boolean
20
+ defaultShowOutput?: boolean
21
+ defaultWireframeOutput?: boolean
22
+ /** Wrap styles */
23
+ style?: React.CSSProperties
24
+ }
25
+
26
+ /* ----------------------------- helpers ----------------------------- */
27
+
28
+ function contiguousRuns(nums: number[]) {
29
+ const zs = [...new Set(nums)].sort((a, b) => a - b)
30
+ if (zs.length === 0) return [] as number[][]
31
+ const groups: number[][] = []
32
+ let run: number[] = [zs[0]!]
33
+ for (let i = 1; i < zs.length; i++) {
34
+ if (zs[i] === zs[i - 1]! + 1) run.push(zs[i]!)
35
+ else {
36
+ groups.push(run)
37
+ run = [zs[i]!]
38
+ }
39
+ }
40
+ groups.push(run)
41
+ return groups
42
+ }
43
+
44
+ /** Canonical layer order to mirror the solver & experiment */
45
+ function layerSortKey(name: string) {
46
+ const n = name.toLowerCase()
47
+ if (n === "top") return -1_000_000
48
+ if (n === "bottom") return 1_000_000
49
+ const m = /^inner(\d+)$/i.exec(n)
50
+ if (m) return parseInt(m[1]!, 10) || 0
51
+ return 100 + n.charCodeAt(0)
52
+ }
53
+ function canonicalizeLayerOrder(names: string[]) {
54
+ return [...new Set(names)].sort((a, b) => {
55
+ const ka = layerSortKey(a)
56
+ const kb = layerSortKey(b)
57
+ if (ka !== kb) return ka - kb
58
+ return a.localeCompare(b)
59
+ })
60
+ }
61
+
62
+ /** Build prisms by grouping identical XY nodes across contiguous Z */
63
+ function buildPrismsFromNodes(
64
+ nodes: CapacityMeshNode[],
65
+ fallbackLayerCount: number,
66
+ ): Array<{
67
+ minX: number
68
+ maxX: number
69
+ minY: number
70
+ maxY: number
71
+ z0: number
72
+ z1: number
73
+ }> {
74
+ const xyKey = (n: CapacityMeshNode) =>
75
+ `${n.center.x.toFixed(8)}|${n.center.y.toFixed(8)}|${n.width.toFixed(8)}|${n.height.toFixed(8)}`
76
+ const azKey = (n: CapacityMeshNode) => {
77
+ const zs = (
78
+ n.availableZ && n.availableZ.length ? [...new Set(n.availableZ)] : [0]
79
+ ).sort((a, b) => a - b)
80
+ return `zset:${zs.join(",")}`
81
+ }
82
+ const key = (n: CapacityMeshNode) => `${xyKey(n)}|${azKey(n)}`
83
+
84
+ const groups = new Map<
85
+ string,
86
+ { cx: number; cy: number; w: number; h: number; zs: number[] }
87
+ >()
88
+ for (const n of nodes) {
89
+ const k = key(n)
90
+ const zlist = n.availableZ?.length ? n.availableZ : [0]
91
+ const g = groups.get(k)
92
+ if (g) g.zs.push(...zlist)
93
+ else
94
+ groups.set(k, {
95
+ cx: n.center.x,
96
+ cy: n.center.y,
97
+ w: n.width,
98
+ h: n.height,
99
+ zs: [...zlist],
100
+ })
101
+ }
102
+
103
+ const prisms: Array<{
104
+ minX: number
105
+ maxX: number
106
+ minY: number
107
+ maxY: number
108
+ z0: number
109
+ z1: number
110
+ }> = []
111
+ for (const g of groups.values()) {
112
+ const minX = g.cx - g.w / 2
113
+ const maxX = g.cx + g.w / 2
114
+ const minY = g.cy - g.h / 2
115
+ const maxY = g.cy + g.h / 2
116
+ const runs = contiguousRuns(g.zs)
117
+ if (runs.length === 0) {
118
+ prisms.push({
119
+ minX,
120
+ maxX,
121
+ minY,
122
+ maxY,
123
+ z0: 0,
124
+ z1: Math.max(1, fallbackLayerCount),
125
+ })
126
+ } else {
127
+ for (const r of runs) {
128
+ prisms.push({
129
+ minX,
130
+ maxX,
131
+ minY,
132
+ maxY,
133
+ z0: r[0]!,
134
+ z1: r[r.length - 1]! + 1,
135
+ })
136
+ }
137
+ }
138
+ }
139
+ return prisms
140
+ }
141
+
142
+ function clamp01(x: number) {
143
+ return Math.max(0, Math.min(1, x))
144
+ }
145
+
146
+ function darkenColor(hex: number, factor = 0.6): number {
147
+ const r = ((hex >> 16) & 0xff) * factor
148
+ const g = ((hex >> 8) & 0xff) * factor
149
+ const b = (hex & 0xff) * factor
150
+ const cr = Math.max(0, Math.min(255, Math.round(r)))
151
+ const cg = Math.max(0, Math.min(255, Math.round(g)))
152
+ const cb = Math.max(0, Math.min(255, Math.round(b)))
153
+ return (cr << 16) | (cg << 8) | cb
154
+ }
155
+
156
+ /* ---------------------------- 3D Canvas ---------------------------- */
157
+
158
+ const ThreeBoardView: React.FC<{
159
+ nodes: CapacityMeshNode[]
160
+ srj?: SimpleRouteJson
161
+ layerThickness: number
162
+ height: number
163
+ showRoot: boolean
164
+ showObstacles: boolean
165
+ showOutput: boolean
166
+ wireframeOutput: boolean
167
+ meshOpacity: number
168
+ shrinkBoxes: boolean
169
+ boxShrinkAmount: number
170
+ showBorders: boolean
171
+ }> = ({
172
+ nodes,
173
+ srj,
174
+ layerThickness,
175
+ height,
176
+ showRoot,
177
+ showObstacles,
178
+ showOutput,
179
+ wireframeOutput,
180
+ meshOpacity,
181
+ shrinkBoxes,
182
+ boxShrinkAmount,
183
+ showBorders,
184
+ }) => {
185
+ const containerRef = useRef<HTMLDivElement>(null)
186
+ const destroyRef = useRef<() => void>(() => {})
187
+
188
+ const layerNames = useMemo(() => {
189
+ // Build from nodes (preferred, matches solver) and fall back to SRJ obstacle names
190
+ const fromNodes = canonicalizeLayerOrder(nodes.map((n) => n.layer))
191
+ if (fromNodes.length) return fromNodes
192
+ const fromObs = canonicalizeLayerOrder(
193
+ (srj?.obstacles ?? []).flatMap((o) => o.layers ?? []),
194
+ )
195
+ return fromObs.length ? fromObs : ["top"]
196
+ }, [nodes, srj])
197
+
198
+ const zIndexByLayerName = useMemo(() => {
199
+ const m = new Map<string, number>()
200
+ layerNames.forEach((n, i) => m.set(n, i))
201
+ return m
202
+ }, [layerNames])
203
+
204
+ const layerCount = layerNames.length || srj?.layerCount || 1
205
+
206
+ const prisms = useMemo(
207
+ () => buildPrismsFromNodes(nodes, layerCount),
208
+ [nodes, layerCount],
209
+ )
210
+
211
+ useEffect(() => {
212
+ let mounted = true
213
+ ;(async () => {
214
+ const el = containerRef.current
215
+ if (!el) return
216
+ if (!mounted) return
217
+
218
+ destroyRef.current?.()
219
+
220
+ const w = el.clientWidth || 800
221
+ const h = el.clientHeight || height
222
+
223
+ const renderer = new THREE.WebGLRenderer({
224
+ antialias: true,
225
+ alpha: true,
226
+ premultipliedAlpha: false,
227
+ })
228
+ // Increase pixel ratio for better alphaHash quality
229
+ renderer.setPixelRatio(window.devicePixelRatio)
230
+ renderer.setSize(w, h)
231
+ el.innerHTML = ""
232
+ el.appendChild(renderer.domElement)
233
+
234
+ const scene = new THREE.Scene()
235
+ scene.background = new THREE.Color(0xf7f8fa)
236
+
237
+ const camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 10000)
238
+ camera.position.set(80, 80, 120)
239
+
240
+ const controls = new OrbitControls(camera, renderer.domElement)
241
+ controls.enableDamping = true
242
+
243
+ const amb = new THREE.AmbientLight(0xffffff, 0.9)
244
+ scene.add(amb)
245
+ const dir = new THREE.DirectionalLight(0xffffff, 0.6)
246
+ dir.position.set(1, 2, 3)
247
+ scene.add(dir)
248
+
249
+ const rootGroup = new THREE.Group()
250
+ const obstaclesGroup = new THREE.Group()
251
+ const outputGroup = new THREE.Group()
252
+ scene.add(rootGroup, obstaclesGroup, outputGroup)
253
+
254
+ // Axes helper for orientation (similar to experiment)
255
+ const axes = new THREE.AxesHelper(50)
256
+ scene.add(axes)
257
+
258
+ const colorRoot = 0x111827
259
+ const colorOb = 0xef4444
260
+
261
+ // Palette for layer-span-based coloring
262
+ const spanPalette = [
263
+ 0x0ea5e9, // cyan-ish
264
+ 0x22c55e, // green
265
+ 0xf97316, // orange
266
+ 0xa855f7, // purple
267
+ 0xfacc15, // yellow
268
+ 0x38bdf8, // light blue
269
+ 0xec4899, // pink
270
+ 0x14b8a6, // teal
271
+ ]
272
+ const spanColorMap = new Map<string, number>()
273
+ let spanColorIndex = 0
274
+ const getSpanColor = (z0: number, z1: number) => {
275
+ const key = `${z0}-${z1}`
276
+ let c = spanColorMap.get(key)
277
+ if (c == null) {
278
+ c = spanPalette[spanColorIndex % spanPalette.length]!
279
+ spanColorMap.set(key, c)
280
+ spanColorIndex++
281
+ }
282
+ return c
283
+ }
284
+
285
+ function makeBoxMesh(
286
+ b: {
287
+ minX: number
288
+ maxX: number
289
+ minY: number
290
+ maxY: number
291
+ z0: number
292
+ z1: number
293
+ },
294
+ color: number,
295
+ wire: boolean,
296
+ opacity = 0.45,
297
+ borders = false,
298
+ ) {
299
+ const dx = b.maxX - b.minX
300
+ const dz = b.maxY - b.minY // map board Y -> three Z
301
+ const dy = (b.z1 - b.z0) * layerThickness
302
+ const cx = -((b.minX + b.maxX) / 2) // negate X to match expected orientation
303
+ const cz = (b.minY + b.maxY) / 2
304
+ // Negate Y so z=0 is at top, higher z goes down
305
+ const cy = -((b.z0 + b.z1) / 2) * layerThickness
306
+
307
+ const geom = new THREE.BoxGeometry(dx, dy, dz)
308
+ if (wire) {
309
+ const edges = new THREE.EdgesGeometry(geom)
310
+ const line = new THREE.LineSegments(
311
+ edges,
312
+ new THREE.LineBasicMaterial({ color }),
313
+ )
314
+ line.position.set(cx, cy, cz)
315
+ return line
316
+ }
317
+ const clampedOpacity = clamp01(opacity)
318
+ const mat = new THREE.MeshPhongMaterial({
319
+ color,
320
+ opacity: clampedOpacity,
321
+ transparent: clampedOpacity < 1,
322
+ alphaHash: clampedOpacity < 1,
323
+ alphaToCoverage: true,
324
+ })
325
+
326
+ const mesh = new THREE.Mesh(geom, mat)
327
+ mesh.position.set(cx, cy, cz)
328
+
329
+ if (!borders) return mesh
330
+
331
+ const edges = new THREE.EdgesGeometry(geom)
332
+ const borderColor = darkenColor(color, 0.6)
333
+ const line = new THREE.LineSegments(
334
+ edges,
335
+ new THREE.LineBasicMaterial({ color: borderColor }),
336
+ )
337
+ line.position.set(cx, cy, cz)
338
+
339
+ const group = new THREE.Group()
340
+ group.add(mesh)
341
+ group.add(line)
342
+ return group
343
+ }
344
+
345
+ // Root wireframe from SRJ bounds
346
+ if (srj && showRoot) {
347
+ const rootBox = {
348
+ minX: srj.bounds.minX,
349
+ maxX: srj.bounds.maxX,
350
+ minY: srj.bounds.minY,
351
+ maxY: srj.bounds.maxY,
352
+ z0: 0,
353
+ z1: layerCount,
354
+ }
355
+ rootGroup.add(makeBoxMesh(rootBox, colorRoot, true, 1))
356
+ }
357
+
358
+ // Obstacles — rectangular only — one slab per declared layer
359
+ if (srj && showObstacles) {
360
+ for (const ob of srj.obstacles ?? []) {
361
+ if (ob.type !== "rect") continue
362
+ const minX = ob.center.x - ob.width / 2
363
+ const maxX = ob.center.x + ob.width / 2
364
+ const minY = ob.center.y - ob.height / 2
365
+ const maxY = ob.center.y + ob.height / 2
366
+
367
+ // Prefer explicit zLayers; otherwise map layer names to indices
368
+ const zs =
369
+ ob.zLayers && ob.zLayers.length
370
+ ? [...new Set(ob.zLayers)]
371
+ : (ob.layers ?? [])
372
+ .map((name) => zIndexByLayerName.get(name))
373
+ .filter((z): z is number => typeof z === "number")
374
+
375
+ for (const z of zs) {
376
+ if (z < 0 || z >= layerCount) continue
377
+ obstaclesGroup.add(
378
+ makeBoxMesh(
379
+ { minX, maxX, minY, maxY, z0: z, z1: z + 1 },
380
+ colorOb,
381
+ false,
382
+ 0.35,
383
+ false,
384
+ ),
385
+ )
386
+ }
387
+ }
388
+ }
389
+
390
+ // Output prisms from nodes (wireframe toggle like the experiment)
391
+ if (showOutput) {
392
+ for (const p of prisms) {
393
+ let box = p
394
+ if (shrinkBoxes && boxShrinkAmount > 0) {
395
+ const s = boxShrinkAmount
396
+
397
+ const widthX = p.maxX - p.minX
398
+ const widthY = p.maxY - p.minY
399
+
400
+ // Never shrink more on a side than allowed by the configured shrink amount
401
+ // while ensuring we don't shrink past a minimum dimension of "s"
402
+ const maxShrinkEachSideX = Math.max(0, (widthX - s) / 2)
403
+ const maxShrinkEachSideY = Math.max(0, (widthY - s) / 2)
404
+
405
+ const shrinkX = Math.min(s, maxShrinkEachSideX)
406
+ const shrinkY = Math.min(s, maxShrinkEachSideY)
407
+
408
+ const minX = p.minX + shrinkX
409
+ const maxX = p.maxX - shrinkX
410
+ const minY = p.minY + shrinkY
411
+ const maxY = p.maxY - shrinkY
412
+
413
+ // Guard against any degenerate box
414
+ if (minX >= maxX || minY >= maxY) {
415
+ continue
416
+ }
417
+
418
+ box = { ...p, minX, maxX, minY, maxY }
419
+ }
420
+
421
+ const color = getSpanColor(p.z0, p.z1)
422
+ outputGroup.add(
423
+ makeBoxMesh(
424
+ box,
425
+ color,
426
+ wireframeOutput,
427
+ meshOpacity,
428
+ showBorders && !wireframeOutput,
429
+ ),
430
+ )
431
+ }
432
+ }
433
+
434
+ // Fit camera
435
+ const fitBox = srj
436
+ ? {
437
+ minX: srj.bounds.minX,
438
+ maxX: srj.bounds.maxX,
439
+ minY: srj.bounds.minY,
440
+ maxY: srj.bounds.maxY,
441
+ z0: 0,
442
+ z1: layerCount,
443
+ }
444
+ : (() => {
445
+ if (prisms.length === 0) {
446
+ return {
447
+ minX: -10,
448
+ maxX: 10,
449
+ minY: -10,
450
+ maxY: 10,
451
+ z0: 0,
452
+ z1: layerCount,
453
+ }
454
+ }
455
+ let minX = Infinity,
456
+ minY = Infinity,
457
+ maxX = -Infinity,
458
+ maxY = -Infinity
459
+ for (const p of prisms) {
460
+ minX = Math.min(minX, p.minX)
461
+ maxX = Math.max(maxX, p.maxX)
462
+ minY = Math.min(minY, p.minY)
463
+ maxY = Math.max(maxY, p.maxY)
464
+ }
465
+ return { minX, maxX, minY, maxY, z0: 0, z1: layerCount }
466
+ })()
467
+
468
+ const dx = fitBox.maxX - fitBox.minX
469
+ const dz = fitBox.maxY - fitBox.minY
470
+ const dy = (fitBox.z1 - fitBox.z0) * layerThickness
471
+ const size = Math.max(dx, dz, dy)
472
+ const dist = size * 2.0
473
+ // Camera looks from above-right-front, with negative Y being "up" (z=0 at top)
474
+ camera.position.set(
475
+ -(fitBox.maxX + dist * 0.6), // negate X to account for flipped axis
476
+ -dy / 2 + dist, // negative Y is up, so position above the center
477
+ fitBox.maxY + dist * 0.6,
478
+ )
479
+ camera.near = Math.max(0.1, size / 100)
480
+ camera.far = dist * 10 + size * 10
481
+ camera.updateProjectionMatrix()
482
+ controls.target.set(
483
+ -((fitBox.minX + fitBox.maxX) / 2), // negate X to account for flipped axis
484
+ -dy / 2, // center of the inverted Y range
485
+ (fitBox.minY + fitBox.maxY) / 2,
486
+ )
487
+ controls.update()
488
+
489
+ const onResize = () => {
490
+ const W = el.clientWidth || w
491
+ const H = el.clientHeight || h
492
+ camera.aspect = W / H
493
+ camera.updateProjectionMatrix()
494
+ renderer.setSize(W, H)
495
+ }
496
+ window.addEventListener("resize", onResize)
497
+
498
+ let raf = 0
499
+ const animate = () => {
500
+ raf = requestAnimationFrame(animate)
501
+ controls.update()
502
+ renderer.render(scene, camera)
503
+ }
504
+ animate()
505
+
506
+ destroyRef.current = () => {
507
+ cancelAnimationFrame(raf)
508
+ window.removeEventListener("resize", onResize)
509
+ renderer.dispose()
510
+ el.innerHTML = ""
511
+ }
512
+ })()
513
+
514
+ return () => {
515
+ mounted = false
516
+ destroyRef.current?.()
517
+ }
518
+ }, [
519
+ srj,
520
+ prisms,
521
+ layerCount,
522
+ layerThickness,
523
+ height,
524
+ showRoot,
525
+ showObstacles,
526
+ showOutput,
527
+ wireframeOutput,
528
+ zIndexByLayerName,
529
+ meshOpacity,
530
+ shrinkBoxes,
531
+ boxShrinkAmount,
532
+ showBorders,
533
+ ])
534
+
535
+ return (
536
+ <div
537
+ ref={containerRef}
538
+ style={{
539
+ width: "100%",
540
+ height,
541
+ border: "1px solid #e5e7eb",
542
+ borderRadius: 8,
543
+ overflow: "hidden",
544
+ background: "#f7f8fa",
545
+ }}
546
+ />
547
+ )
548
+ }
549
+
550
+ /* ----------------------- Public wrapper component ----------------------- */
551
+
552
+ export const SolverDebugger3d: React.FC<SolverDebugger3dProps> = ({
553
+ solver,
554
+ simpleRouteJson,
555
+ layerThickness = 1,
556
+ height = 600,
557
+ defaultShowRoot = true,
558
+ defaultShowObstacles = false, // don't show obstacles by default
559
+ defaultShowOutput = true,
560
+ defaultWireframeOutput = false,
561
+ style,
562
+ }) => {
563
+ const [show3d, setShow3d] = useState(false)
564
+ const [rebuildKey, setRebuildKey] = useState(0)
565
+
566
+ const [showRoot, setShowRoot] = useState(defaultShowRoot)
567
+ const [showObstacles, setShowObstacles] = useState(defaultShowObstacles)
568
+ const [showOutput, setShowOutput] = useState(defaultShowOutput)
569
+ const [wireframeOutput, setWireframeOutput] = useState(defaultWireframeOutput)
570
+
571
+ const [meshOpacity, setMeshOpacity] = useState(0.6)
572
+ const [shrinkBoxes, setShrinkBoxes] = useState(true)
573
+ const [boxShrinkAmount, setBoxShrinkAmount] = useState(0.1)
574
+ const [showBorders, setShowBorders] = useState(true)
575
+
576
+ // Mesh nodes state - updated when solver completes or during stepping
577
+ const [meshNodes, setMeshNodes] = useState<CapacityMeshNode[]>([])
578
+
579
+ // Update mesh nodes from solver output
580
+ const updateMeshNodes = useCallback(() => {
581
+ try {
582
+ const output = solver.getOutput()
583
+ const nodes = output.meshNodes ?? []
584
+ setMeshNodes(nodes)
585
+ } catch {
586
+ setMeshNodes([])
587
+ }
588
+ }, [solver])
589
+
590
+ // Initialize mesh nodes on mount (in case solver is already solved)
591
+ useEffect(() => {
592
+ updateMeshNodes()
593
+ // eslint-disable-next-line react-hooks/exhaustive-deps
594
+ }, [])
595
+
596
+ // Handle solver completion
597
+ const handleSolverCompleted = useCallback(() => {
598
+ updateMeshNodes()
599
+ }, [updateMeshNodes])
600
+
601
+ // Poll for updates during stepping (GenericSolverDebugger doesn't have onStep)
602
+ useEffect(() => {
603
+ const interval = setInterval(() => {
604
+ // Only update if solver has output available
605
+ if (solver.solved || (solver as any).stats?.placed > 0) {
606
+ updateMeshNodes()
607
+ }
608
+ }, 100) // Poll every 100ms during active solving
609
+
610
+ return () => clearInterval(interval)
611
+ }, [updateMeshNodes, solver])
612
+
613
+ const toggle3d = useCallback(() => setShow3d((s) => !s), [])
614
+ const rebuild = useCallback(() => setRebuildKey((k) => k + 1), [])
615
+
616
+ return (
617
+ <>
618
+ <div style={{ display: "grid", gap: 12, ...style }}>
619
+ <GenericSolverDebugger
620
+ solver={solver as any}
621
+ onSolverCompleted={handleSolverCompleted}
622
+ />
623
+
624
+ <div
625
+ style={{
626
+ display: "flex",
627
+ gap: 8,
628
+ alignItems: "center",
629
+ flexWrap: "wrap",
630
+ }}
631
+ >
632
+ <button
633
+ onClick={toggle3d}
634
+ style={{
635
+ padding: "8px 10px",
636
+ borderRadius: 6,
637
+ border: "1px solid #cbd5e1",
638
+ background: show3d ? "#1e293b" : "#2563eb",
639
+ color: "white",
640
+ cursor: "pointer",
641
+ }}
642
+ >
643
+ {show3d ? "Hide 3D" : "Show 3D"}
644
+ </button>
645
+ {show3d && (
646
+ <button
647
+ onClick={rebuild}
648
+ style={{
649
+ padding: "8px 10px",
650
+ borderRadius: 6,
651
+ border: "1px solid #cbd5e1",
652
+ background: "#0f766e",
653
+ color: "white",
654
+ cursor: "pointer",
655
+ }}
656
+ title="Rebuild 3D scene (use after changing solver params)"
657
+ >
658
+ Rebuild 3D
659
+ </button>
660
+ )}
661
+
662
+ {/* experiment-like toggles */}
663
+ <label
664
+ style={{
665
+ display: "inline-flex",
666
+ gap: 6,
667
+ alignItems: "center",
668
+ marginLeft: 8,
669
+ }}
670
+ >
671
+ <input
672
+ type="checkbox"
673
+ checked={showRoot}
674
+ onChange={(e) => setShowRoot(e.target.checked)}
675
+ />
676
+ Root
677
+ </label>
678
+ <label
679
+ style={{ display: "inline-flex", gap: 6, alignItems: "center" }}
680
+ >
681
+ <input
682
+ type="checkbox"
683
+ checked={showObstacles}
684
+ onChange={(e) => setShowObstacles(e.target.checked)}
685
+ />
686
+ Obstacles
687
+ </label>
688
+ <label
689
+ style={{ display: "inline-flex", gap: 6, alignItems: "center" }}
690
+ >
691
+ <input
692
+ type="checkbox"
693
+ checked={showOutput}
694
+ onChange={(e) => setShowOutput(e.target.checked)}
695
+ />
696
+ Output
697
+ </label>
698
+ <label
699
+ style={{ display: "inline-flex", gap: 6, alignItems: "center" }}
700
+ >
701
+ <input
702
+ type="checkbox"
703
+ checked={wireframeOutput}
704
+ onChange={(e) => setWireframeOutput(e.target.checked)}
705
+ />
706
+ Wireframe Output
707
+ </label>
708
+
709
+ {/* Mesh opacity slider */}
710
+ {show3d && (
711
+ <label
712
+ style={{
713
+ display: "inline-flex",
714
+ alignItems: "center",
715
+ gap: 6,
716
+ marginLeft: 8,
717
+ fontSize: 12,
718
+ }}
719
+ >
720
+ Opacity
721
+ <input
722
+ type="range"
723
+ min={0}
724
+ max={1}
725
+ step={0.05}
726
+ value={meshOpacity}
727
+ onChange={(e) => setMeshOpacity(parseFloat(e.target.value))}
728
+ />
729
+ <span style={{ width: 32, textAlign: "right" }}>
730
+ {meshOpacity.toFixed(2)}
731
+ </span>
732
+ </label>
733
+ )}
734
+
735
+ {/* Shrink boxes option */}
736
+ {show3d && (
737
+ <>
738
+ <label
739
+ style={{
740
+ display: "inline-flex",
741
+ gap: 6,
742
+ alignItems: "center",
743
+ fontSize: 12,
744
+ }}
745
+ >
746
+ <input
747
+ type="checkbox"
748
+ checked={shrinkBoxes}
749
+ onChange={(e) => setShrinkBoxes(e.target.checked)}
750
+ />
751
+ Shrink boxes
752
+ </label>
753
+ {shrinkBoxes && (
754
+ <label
755
+ style={{
756
+ display: "inline-flex",
757
+ gap: 4,
758
+ alignItems: "center",
759
+ fontSize: 12,
760
+ }}
761
+ >
762
+ amt
763
+ <input
764
+ type="number"
765
+ value={boxShrinkAmount}
766
+ step={0.05}
767
+ style={{ width: 60 }}
768
+ onChange={(e) => {
769
+ const v = parseFloat(e.target.value)
770
+ if (Number.isNaN(v)) return
771
+ setBoxShrinkAmount(Math.max(0, v))
772
+ }}
773
+ />
774
+ </label>
775
+ )}
776
+ </>
777
+ )}
778
+
779
+ {/* Show borders option */}
780
+ {show3d && (
781
+ <label
782
+ style={{
783
+ display: "inline-flex",
784
+ gap: 6,
785
+ alignItems: "center",
786
+ fontSize: 12,
787
+ }}
788
+ >
789
+ <input
790
+ type="checkbox"
791
+ checked={showBorders}
792
+ disabled={wireframeOutput}
793
+ onChange={(e) => setShowBorders(e.target.checked)}
794
+ />
795
+ <span
796
+ style={{
797
+ opacity: wireframeOutput ? 0.5 : 1,
798
+ }}
799
+ >
800
+ Show borders
801
+ </span>
802
+ </label>
803
+ )}
804
+
805
+ <div style={{ fontSize: 12, color: "#334155", marginLeft: 6 }}>
806
+ Drag to orbit · Wheel to zoom · Right-drag to pan
807
+ </div>
808
+ </div>
809
+
810
+ {show3d && (
811
+ <ThreeBoardView
812
+ key={rebuildKey}
813
+ nodes={meshNodes}
814
+ srj={simpleRouteJson}
815
+ layerThickness={layerThickness}
816
+ height={height}
817
+ showRoot={showRoot}
818
+ showObstacles={showObstacles}
819
+ showOutput={showOutput}
820
+ wireframeOutput={wireframeOutput}
821
+ meshOpacity={meshOpacity}
822
+ shrinkBoxes={shrinkBoxes}
823
+ boxShrinkAmount={boxShrinkAmount}
824
+ showBorders={showBorders}
825
+ />
826
+ )}
827
+ </div>
828
+
829
+ {/* White margin at bottom of the page */}
830
+ <div style={{ height: 200, background: "#ffffff" }} />
831
+ </>
832
+ )
833
+ }