diff --git a/components/SolverDebugger3d.tsx b/components/SolverDebugger3d.tsx index e577f7c..16a55df 100644 --- a/components/SolverDebugger3d.tsx +++ b/components/SolverDebugger3d.tsx @@ -168,6 +168,7 @@ const ThreeBoardView: React.FC<{ shrinkBoxes: boolean boxShrinkAmount: number showBorders: boolean + fitKey: number }> = ({ nodes, srj, @@ -181,9 +182,18 @@ const ThreeBoardView: React.FC<{ shrinkBoxes, boxShrinkAmount, showBorders, + fitKey, }) => { const containerRef = useRef(null) const destroyRef = useRef<() => void>(() => {}) + const rendererRef = useRef(null) + const sceneRef = useRef(null) + const cameraRef = useRef(null) + const controlsRef = useRef(null) + const rootGroupRef = useRef(null) + const obstaclesGroupRef = useRef(null) + const outputGroupRef = useRef(null) + const shouldFitRef = useRef(true) const layerNames = useMemo(() => { // Build from nodes (preferred, matches solver) and fall back to SRJ obstacle names @@ -208,319 +218,393 @@ const ThreeBoardView: React.FC<{ [nodes, layerCount], ) + useEffect(() => { + shouldFitRef.current = true + }, [fitKey]) + useEffect(() => { let mounted = true - ;(async () => { - const el = containerRef.current - if (!el) return - if (!mounted) return + const el = containerRef.current + if (!el) return + if (!mounted) return - destroyRef.current?.() + if (rendererRef.current && sceneRef.current && cameraRef.current) { + return + } - const w = el.clientWidth || 800 - const h = el.clientHeight || height + const w = el.clientWidth || 800 + const h = el.clientHeight || height + + const renderer = new THREE.WebGLRenderer({ + antialias: true, + alpha: true, + premultipliedAlpha: false, + }) + // Increase pixel ratio for better alphaHash quality + renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5)) + renderer.setSize(w, h) + el.innerHTML = "" + el.appendChild(renderer.domElement) + + const scene = new THREE.Scene() + scene.background = new THREE.Color(0xf7f8fa) + + const camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 10000) + camera.position.set(80, 80, 120) + + const controls = new OrbitControls(camera, renderer.domElement) + controls.enableDamping = true + + const amb = new THREE.AmbientLight(0xffffff, 0.9) + scene.add(amb) + const dir = new THREE.DirectionalLight(0xffffff, 0.6) + dir.position.set(1, 2, 3) + scene.add(dir) + + const rootGroup = new THREE.Group() + const obstaclesGroup = new THREE.Group() + const outputGroup = new THREE.Group() + scene.add(rootGroup, obstaclesGroup, outputGroup) + + // Axes helper for orientation (similar to experiment) + const axes = new THREE.AxesHelper(50) + scene.add(axes) + + rendererRef.current = renderer + sceneRef.current = scene + cameraRef.current = camera + controlsRef.current = controls + rootGroupRef.current = rootGroup + obstaclesGroupRef.current = obstaclesGroup + outputGroupRef.current = outputGroup + + const onResize = () => { + const cam = cameraRef.current + const ren = rendererRef.current + if (!cam || !ren) return + const W = el.clientWidth || w + const H = el.clientHeight || h + cam.aspect = W / H + cam.updateProjectionMatrix() + ren.setSize(W, H) + } + window.addEventListener("resize", onResize) + + let raf = 0 + const animate = () => { + const ren = rendererRef.current + const scn = sceneRef.current + const cam = cameraRef.current + const ctrls = controlsRef.current + if (!ren || !scn || !cam || !ctrls) return + raf = requestAnimationFrame(animate) + ctrls.update() + ren.render(scn, cam) + } + animate() - const renderer = new THREE.WebGLRenderer({ - antialias: true, - alpha: true, - premultipliedAlpha: false, - }) - // Increase pixel ratio for better alphaHash quality - renderer.setPixelRatio(window.devicePixelRatio) - renderer.setSize(w, h) + destroyRef.current = () => { + cancelAnimationFrame(raf) + window.removeEventListener("resize", onResize) + renderer.dispose() el.innerHTML = "" - el.appendChild(renderer.domElement) - - const scene = new THREE.Scene() - scene.background = new THREE.Color(0xf7f8fa) - - const camera = new THREE.PerspectiveCamera(45, w / h, 0.1, 10000) - camera.position.set(80, 80, 120) - - const controls = new OrbitControls(camera, renderer.domElement) - controls.enableDamping = true - - const amb = new THREE.AmbientLight(0xffffff, 0.9) - scene.add(amb) - const dir = new THREE.DirectionalLight(0xffffff, 0.6) - dir.position.set(1, 2, 3) - scene.add(dir) - - const rootGroup = new THREE.Group() - const obstaclesGroup = new THREE.Group() - const outputGroup = new THREE.Group() - scene.add(rootGroup, obstaclesGroup, outputGroup) - - // Axes helper for orientation (similar to experiment) - const axes = new THREE.AxesHelper(50) - scene.add(axes) - - const colorRoot = 0x111827 - const colorOb = 0xef4444 - - // Palette for layer-span-based coloring - const spanPalette = [ - 0x0ea5e9, // cyan-ish - 0x22c55e, // green - 0xf97316, // orange - 0xa855f7, // purple - 0xfacc15, // yellow - 0x38bdf8, // light blue - 0xec4899, // pink - 0x14b8a6, // teal - ] - const spanColorMap = new Map() - let spanColorIndex = 0 - const getSpanColor = (z0: number, z1: number) => { - const key = `${z0}-${z1}` - let c = spanColorMap.get(key) - if (c == null) { - c = spanPalette[spanColorIndex % spanPalette.length]! - spanColorMap.set(key, c) - spanColorIndex++ - } - return c - } + rendererRef.current = null + sceneRef.current = null + cameraRef.current = null + controlsRef.current = null + rootGroupRef.current = null + obstaclesGroupRef.current = null + outputGroupRef.current = null + } - function makeBoxMesh( - b: { - minX: number - maxX: number - minY: number - maxY: number - z0: number - z1: number - }, - color: number, - wire: boolean, - opacity = 0.45, - borders = false, - ) { - const dx = b.maxX - b.minX - const dz = b.maxY - b.minY // map board Y -> three Z - const dy = (b.z1 - b.z0) * layerThickness - const cx = -((b.minX + b.maxX) / 2) // negate X to match expected orientation - const cz = (b.minY + b.maxY) / 2 - // Negate Y so z=0 is at top, higher z goes down - const cy = -((b.z0 + b.z1) / 2) * layerThickness - - const geom = new THREE.BoxGeometry(dx, dy, dz) - if (wire) { - const edges = new THREE.EdgesGeometry(geom) - const line = new THREE.LineSegments( - edges, - new THREE.LineBasicMaterial({ color }), - ) - line.position.set(cx, cy, cz) - return line - } - const clampedOpacity = clamp01(opacity) - const mat = new THREE.MeshPhongMaterial({ - color, - opacity: clampedOpacity, - transparent: clampedOpacity < 1, - alphaHash: clampedOpacity < 1, - alphaToCoverage: true, - }) + return () => { + mounted = false + destroyRef.current?.() + } + }, [height]) - const mesh = new THREE.Mesh(geom, mat) - mesh.position.set(cx, cy, cz) + useEffect(() => { + const scene = sceneRef.current + const camera = cameraRef.current + const controls = controlsRef.current + const rootGroup = rootGroupRef.current + const obstaclesGroup = obstaclesGroupRef.current + const outputGroup = outputGroupRef.current + if ( + !scene || + !camera || + !controls || + !rootGroup || + !obstaclesGroup || + !outputGroup + ) { + return + } - if (!borders) return mesh + const disposeObject = (obj: THREE.Object3D) => { + obj.traverse((child) => { + const mesh = child as THREE.Mesh + if (mesh.geometry) { + mesh.geometry.dispose() + } + const mat = (mesh as any).material + if (Array.isArray(mat)) { + mat.forEach((m) => m && m.dispose && m.dispose()) + } else if (mat && mat.dispose) { + mat.dispose() + } + }) + } + + rootGroup.children.forEach(disposeObject) + obstaclesGroup.children.forEach(disposeObject) + outputGroup.children.forEach(disposeObject) + rootGroup.clear() + obstaclesGroup.clear() + outputGroup.clear() + + const colorRoot = 0x111827 + const colorOb = 0xef4444 + const copperGold = 0x0ea5e9 + const largeScene = prisms.length > 1500 + const effectiveShowBorders = showBorders && !largeScene + + // Palette for layer-span-based coloring + const spanPalette = [ + 0x0ea5e9, // cyan-ish + 0x22c55e, // green + 0xf97316, // orange + 0xa855f7, // purple + 0xfacc15, // yellow + 0x38bdf8, // light blue + 0xec4899, // pink + 0x14b8a6, // teal + ] + const spanColorMap = new Map() + let spanColorIndex = 0 + const getSpanColor = (z0: number, z1: number) => { + const key = `${z0}-${z1}` + let c = spanColorMap.get(key) + if (c == null) { + c = spanPalette[spanColorIndex % spanPalette.length]! + spanColorMap.set(key, c) + spanColorIndex++ + } + return c + } + function makeBoxMesh( + b: { + minX: number + maxX: number + minY: number + maxY: number + z0: number + z1: number + }, + color: number, + wire: boolean, + opacity = 0.45, + borders = false, + ) { + const dx = b.maxX - b.minX + const dz = b.maxY - b.minY // map board Y -> three Z + const dy = (b.z1 - b.z0) * layerThickness + const cx = -((b.minX + b.maxX) / 2) // negate X to match expected orientation + const cz = (b.minY + b.maxY) / 2 + // Negate Y so z=0 is at top, higher z goes down + const cy = -((b.z0 + b.z1) / 2) * layerThickness + + const geom = new THREE.BoxGeometry(dx, dy, dz) + if (wire) { const edges = new THREE.EdgesGeometry(geom) - const borderColor = darkenColor(color, 0.6) const line = new THREE.LineSegments( edges, - new THREE.LineBasicMaterial({ color: borderColor }), + new THREE.LineBasicMaterial({ color }), ) line.position.set(cx, cy, cz) - - const group = new THREE.Group() - group.add(mesh) - group.add(line) - return group + return line } + const clampedOpacity = clamp01(opacity) + const mat = new THREE.MeshLambertMaterial({ + color, + opacity: clampedOpacity, + transparent: clampedOpacity < 1, + alphaHash: clampedOpacity < 1, + alphaToCoverage: true, + }) - // Root wireframe from SRJ bounds - if (srj && showRoot) { - const rootBox = { - minX: srj.bounds.minX, - maxX: srj.bounds.maxX, - minY: srj.bounds.minY, - maxY: srj.bounds.maxY, - z0: 0, - z1: layerCount, - } - rootGroup.add(makeBoxMesh(rootBox, colorRoot, true, 1)) + const mesh = new THREE.Mesh(geom, mat) + mesh.position.set(cx, cy, cz) + + if (!borders) return mesh + + const edges = new THREE.EdgesGeometry(geom) + const borderColor = darkenColor(color, 0.6) + const line = new THREE.LineSegments( + edges, + new THREE.LineBasicMaterial({ color: borderColor }), + ) + line.position.set(cx, cy, cz) + + const group = new THREE.Group() + group.add(mesh) + group.add(line) + return group + } + + // Root wireframe from SRJ bounds + if (srj && showRoot) { + const rootBox = { + minX: srj.bounds.minX, + maxX: srj.bounds.maxX, + minY: srj.bounds.minY, + maxY: srj.bounds.maxY, + z0: 0, + z1: layerCount, } + rootGroup.add(makeBoxMesh(rootBox, colorRoot, true, 1)) + } - // Obstacles — rectangular only — one slab per declared layer - if (srj && showObstacles) { - for (const ob of srj.obstacles ?? []) { - if (ob.type !== "rect") continue - const minX = ob.center.x - ob.width / 2 - const maxX = ob.center.x + ob.width / 2 - const minY = ob.center.y - ob.height / 2 - const maxY = ob.center.y + ob.height / 2 - - // Prefer explicit zLayers; otherwise map layer names to indices - const zs = - ob.zLayers && ob.zLayers.length - ? [...new Set(ob.zLayers)] - : (ob.layers ?? []) - .map((name) => zIndexByLayerName.get(name)) - .filter((z): z is number => typeof z === "number") - - for (const z of zs) { - if (z < 0 || z >= layerCount) continue - obstaclesGroup.add( - makeBoxMesh( - { minX, maxX, minY, maxY, z0: z, z1: z + 1 }, - colorOb, - false, - 0.35, - false, - ), - ) - } + // Obstacles — rectangular only — one slab per declared layer + if (srj && showObstacles) { + for (const ob of srj.obstacles ?? []) { + if (ob.type !== "rect") continue + const minX = ob.center.x - ob.width / 2 + const maxX = ob.center.x + ob.width / 2 + const minY = ob.center.y - ob.height / 2 + const maxY = ob.center.y + ob.height / 2 + + // Prefer explicit zLayers; otherwise map layer names to indices + const zs = + ob.zLayers && ob.zLayers.length + ? [...new Set(ob.zLayers)] + : (ob.layers ?? []) + .map((name) => zIndexByLayerName.get(name)) + .filter((z): z is number => typeof z === "number") + + const obstacleColor = ob.isCopperPour ? copperGold : colorOb + for (const z of zs) { + if (z < 0 || z >= layerCount) continue + obstaclesGroup.add( + makeBoxMesh( + { minX, maxX, minY, maxY, z0: z, z1: z + 1 }, + obstacleColor, + false, + 0.35, + false, + ), + ) } } + } - // Output prisms from nodes (wireframe toggle like the experiment) - if (showOutput) { - for (const p of prisms) { - let box = p - if (shrinkBoxes && boxShrinkAmount > 0) { - const s = boxShrinkAmount + // Output prisms from nodes (wireframe toggle like the experiment) + if (showOutput) { + for (const p of prisms) { + let box = p + if (shrinkBoxes && boxShrinkAmount > 0) { + const s = boxShrinkAmount - const widthX = p.maxX - p.minX - const widthY = p.maxY - p.minY + const widthX = p.maxX - p.minX + const widthY = p.maxY - p.minY - // Never shrink more on a side than allowed by the configured shrink amount - // while ensuring we don't shrink past a minimum dimension of "s" - const maxShrinkEachSideX = Math.max(0, (widthX - s) / 2) - const maxShrinkEachSideY = Math.max(0, (widthY - s) / 2) + // Never shrink more on a side than allowed by the configured shrink amount + // while ensuring we don't shrink past a minimum dimension of "s" + const maxShrinkEachSideX = Math.max(0, (widthX - s) / 2) + const maxShrinkEachSideY = Math.max(0, (widthY - s) / 2) - const shrinkX = Math.min(s, maxShrinkEachSideX) - const shrinkY = Math.min(s, maxShrinkEachSideY) + const shrinkX = Math.min(s, maxShrinkEachSideX) + const shrinkY = Math.min(s, maxShrinkEachSideY) - const minX = p.minX + shrinkX - const maxX = p.maxX - shrinkX - const minY = p.minY + shrinkY - const maxY = p.maxY - shrinkY + const minX = p.minX + shrinkX + const maxX = p.maxX - shrinkX + const minY = p.minY + shrinkY + const maxY = p.maxY - shrinkY - // Guard against any degenerate box - if (minX >= maxX || minY >= maxY) { - continue - } - - box = { ...p, minX, maxX, minY, maxY } + // Guard against any degenerate box + if (minX >= maxX || minY >= maxY) { + continue } - const color = getSpanColor(p.z0, p.z1) - outputGroup.add( - makeBoxMesh( - box, - color, - wireframeOutput, - meshOpacity, - showBorders && !wireframeOutput, - ), - ) + box = { ...p, minX, maxX, minY, maxY } } - } - // Fit camera - const fitBox = srj - ? { - minX: srj.bounds.minX, - maxX: srj.bounds.maxX, - minY: srj.bounds.minY, - maxY: srj.bounds.maxY, - z0: 0, - z1: layerCount, - } - : (() => { - if (prisms.length === 0) { - return { - minX: -10, - maxX: 10, - minY: -10, - maxY: 10, - z0: 0, - z1: layerCount, - } - } - let minX = Infinity, - minY = Infinity, - maxX = -Infinity, - maxY = -Infinity - for (const p of prisms) { - minX = Math.min(minX, p.minX) - maxX = Math.max(maxX, p.maxX) - minY = Math.min(minY, p.minY) - maxY = Math.max(maxY, p.maxY) - } - return { minX, maxX, minY, maxY, z0: 0, z1: layerCount } - })() - - const dx = fitBox.maxX - fitBox.minX - const dz = fitBox.maxY - fitBox.minY - const dy = (fitBox.z1 - fitBox.z0) * layerThickness - const size = Math.max(dx, dz, dy) - const dist = size * 2.0 - // Camera looks from above-right-front, with negative Y being "up" (z=0 at top) - camera.position.set( - -(fitBox.maxX + dist * 0.6), // negate X to account for flipped axis - -dy / 2 + dist, // negative Y is up, so position above the center - fitBox.maxY + dist * 0.6, - ) - camera.near = Math.max(0.1, size / 100) - camera.far = dist * 10 + size * 10 - camera.updateProjectionMatrix() - controls.target.set( - -((fitBox.minX + fitBox.maxX) / 2), // negate X to account for flipped axis - -dy / 2, // center of the inverted Y range - (fitBox.minY + fitBox.maxY) / 2, - ) - controls.update() - - const onResize = () => { - const W = el.clientWidth || w - const H = el.clientHeight || h - camera.aspect = W / H - camera.updateProjectionMatrix() - renderer.setSize(W, H) - } - window.addEventListener("resize", onResize) - - let raf = 0 - const animate = () => { - raf = requestAnimationFrame(animate) - controls.update() - renderer.render(scene, camera) + const color = getSpanColor(p.z0, p.z1) + outputGroup.add( + makeBoxMesh( + box, + color, + wireframeOutput, + meshOpacity, + effectiveShowBorders && !wireframeOutput, + ), + ) } - animate() + } - destroyRef.current = () => { - cancelAnimationFrame(raf) - window.removeEventListener("resize", onResize) - renderer.dispose() - el.innerHTML = "" - } - })() + if (!shouldFitRef.current) return + shouldFitRef.current = false - return () => { - mounted = false - destroyRef.current?.() - } + // Fit camera + const fitBox = srj + ? { + minX: srj.bounds.minX, + maxX: srj.bounds.maxX, + minY: srj.bounds.minY, + maxY: srj.bounds.maxY, + z0: 0, + z1: layerCount, + } + : (() => { + if (prisms.length === 0) { + return { + minX: -10, + maxX: 10, + minY: -10, + maxY: 10, + z0: 0, + z1: layerCount, + } + } + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity + for (const p of prisms) { + minX = Math.min(minX, p.minX) + maxX = Math.max(maxX, p.maxX) + minY = Math.min(minY, p.minY) + maxY = Math.max(maxY, p.maxY) + } + return { minX, maxX, minY, maxY, z0: 0, z1: layerCount } + })() + + const dx = fitBox.maxX - fitBox.minX + const dz = fitBox.maxY - fitBox.minY + const dy = (fitBox.z1 - fitBox.z0) * layerThickness + const size = Math.max(dx, dz, dy) + const dist = size * 2.0 + // Camera looks from above-right-front, with negative Y being "up" (z=0 at top) + camera.position.set( + -(fitBox.maxX + dist * 0.6), // negate X to account for flipped axis + -dy / 2 + dist, // negative Y is up, so position above the center + fitBox.maxY + dist * 0.6, + ) + camera.near = Math.max(0.1, size / 100) + camera.far = dist * 10 + size * 10 + camera.updateProjectionMatrix() + controls.target.set( + -((fitBox.minX + fitBox.maxX) / 2), // negate X to account for flipped axis + -dy / 2, // center of the inverted Y range + (fitBox.minY + fitBox.maxY) / 2, + ) + controls.update() }, [ srj, prisms, layerCount, layerThickness, - height, showRoot, showObstacles, showOutput, @@ -560,7 +644,7 @@ export const SolverDebugger3d: React.FC = ({ defaultWireframeOutput = false, style, }) => { - const [renderMode, setRenderMode] = useState<"2d" | "3d">("2d") + const [renderMode, setRenderMode] = useState<"2d" | "3d" | "split">("split") const [rebuildKey, setRebuildKey] = useState(0) const [showRoot, setShowRoot] = useState(defaultShowRoot) @@ -581,7 +665,7 @@ export const SolverDebugger3d: React.FC = ({ try { const output = solver.getOutput() const nodes = output.meshNodes ?? [] - setMeshNodes(nodes) + setMeshNodes(nodes.length ? [...nodes] : []) } catch { setMeshNodes([]) } @@ -601,11 +685,9 @@ export const SolverDebugger3d: React.FC = ({ // Poll for updates during stepping (GenericSolverDebugger doesn't have onStep) useEffect(() => { const interval = setInterval(() => { - // Only update if solver has output available - if (solver.solved || solver.stats?.placed > 0) { - updateMeshNodes() - } - }, 100) // Poll every 100ms during active solving + if (solver.solved) return + updateMeshNodes() + }, 200) // Poll every 200ms during active solving return () => clearInterval(interval) }, [updateMeshNodes, solver]) @@ -631,7 +713,9 @@ export const SolverDebugger3d: React.FC = ({ Render: - {renderMode === "3d" && ( + {renderMode !== "2d" && ( <>