@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.
- package/.claude/settings.local.json +9 -0
- package/.github/workflows/bun-formatcheck.yml +26 -0
- package/.github/workflows/bun-pver-release.yml +71 -0
- package/.github/workflows/bun-test.yml +31 -0
- package/.github/workflows/bun-typecheck.yml +26 -0
- package/CLAUDE.md +23 -0
- package/README.md +5 -0
- package/biome.json +93 -0
- package/bun.lock +29 -0
- package/bunfig.toml +5 -0
- package/components/SolverDebugger3d.tsx +833 -0
- package/cosmos.config.json +6 -0
- package/cosmos.decorator.tsx +21 -0
- package/dist/index.d.ts +111 -0
- package/dist/index.js +921 -0
- package/experiments/rect-fill-2d.tsx +983 -0
- package/experiments/rect3d_visualizer.html +640 -0
- package/global.d.ts +4 -0
- package/index.html +12 -0
- package/lib/index.ts +1 -0
- package/lib/solvers/RectDiffSolver.ts +158 -0
- package/lib/solvers/rectdiff/candidates.ts +397 -0
- package/lib/solvers/rectdiff/engine.ts +355 -0
- package/lib/solvers/rectdiff/geometry.ts +284 -0
- package/lib/solvers/rectdiff/layers.ts +48 -0
- package/lib/solvers/rectdiff/rectsToMeshNodes.ts +22 -0
- package/lib/solvers/rectdiff/types.ts +63 -0
- package/lib/types/capacity-mesh-types.ts +33 -0
- package/lib/types/srj-types.ts +37 -0
- package/package.json +33 -0
- package/pages/example01.page.tsx +11 -0
- package/test-assets/example01.json +933 -0
- package/tests/__snapshots__/svg.snap.svg +3 -0
- package/tests/examples/__snapshots__/example01.snap.svg +121 -0
- package/tests/examples/example01.test.tsx +65 -0
- package/tests/fixtures/preload.ts +1 -0
- package/tests/incremental-solver.test.ts +100 -0
- package/tests/rect-diff-solver.test.ts +154 -0
- package/tests/svg.test.ts +12 -0
- 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
|
+
}
|