diff --git a/CLAUDE.md b/CLAUDE.md index b8805ad5..63eca49d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,6 +82,7 @@ LLM models: | `nanoBanana` | AI image generation | image, text | image | | `llmGenerate` | AI text generation | text, image | text | | `splitGrid` | Split image into grid cells | image | reference | +| `glbViewer` | Load/display 3D GLB models | none | image | | `output` | Display final result | image | none | ## Node Connection System @@ -125,6 +126,7 @@ Returns `{ images: string[], text: string | null }`. - `H` - Stack selected nodes horizontally - `V` - Stack selected nodes vertically - `G` - Arrange selected nodes in grid +- `?` - Show keyboard shortcuts ## Adding New Node Types diff --git a/package.json b/package.json index 45edc9a1..9e21827e 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,6 @@ "@react-three/drei": "^10.7.7", "@react-three/fiber": "^9.5.0", "@tailwindcss/postcss": "^4.1.17", - "@types/three": "^0.182.0", "@xyflow/react": "^12.9.3", "ai": "^6.0.49", "autoprefixer": "^10.4.22", @@ -41,6 +40,7 @@ "@testing-library/react": "^16.3.1", "@types/jszip": "^3.4.0", "@types/node": "^24.10.1", + "@types/three": "^0.182.0", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^4.7.0", diff --git a/src/components/nodes/GLBViewerNode.tsx b/src/components/nodes/GLBViewerNode.tsx index a9a6051e..2a8cb608 100644 --- a/src/components/nodes/GLBViewerNode.tsx +++ b/src/components/nodes/GLBViewerNode.tsx @@ -80,12 +80,14 @@ function Model({ url, onError }: { url: string; onError?: () => void }) { if (sceneRef.current) { sceneRef.current.traverse((obj) => { if (obj instanceof THREE.Mesh) { - obj.geometry?.dispose(); - if (Array.isArray(obj.material)) { - obj.material.forEach((m) => m.dispose()); - } else { - obj.material?.dispose(); - } + try { obj.geometry?.dispose(); } catch (e) { console.warn("GLB geometry dispose failed:", e); } + try { + if (Array.isArray(obj.material)) { + obj.material.forEach((m) => m.dispose()); + } else { + obj.material?.dispose(); + } + } catch (e) { console.warn("GLB material dispose failed:", e); } } }); sceneRef.current = null; @@ -140,7 +142,7 @@ function CaptureHelper({ captureRef, envGroupRef, }: { - captureRef: React.MutableRefObject<(() => string) | null>; + captureRef: React.MutableRefObject<(() => string | null) | null>; envGroupRef: React.RefObject; }) { const { gl, scene, camera } = useThree(); @@ -148,21 +150,30 @@ function CaptureHelper({ // Update the capture function every frame so it always has current gl/scene/camera useFrame(() => { captureRef.current = () => { - // Directly hide environment objects in the Three.js scene graph (synchronous) - if (envGroupRef.current) { - envGroupRef.current.visible = false; - } + try { + // Directly hide environment objects in the Three.js scene graph (synchronous) + if (envGroupRef.current) { + envGroupRef.current.visible = false; + } - // Render without the grid - gl.render(scene, camera); - const dataUrl = gl.domElement.toDataURL("image/png"); + // Render without the grid + gl.render(scene, camera); + const dataUrl = gl.domElement.toDataURL("image/png"); - // Restore environment objects - if (envGroupRef.current) { - envGroupRef.current.visible = true; - } + // Restore environment objects + if (envGroupRef.current) { + envGroupRef.current.visible = true; + } - return dataUrl; + return dataUrl; + } catch (err) { + console.warn("GLB capture failed:", err); + // Restore environment objects on failure + if (envGroupRef.current) { + envGroupRef.current.visible = true; + } + return null; + } }; }); @@ -190,7 +201,7 @@ function AutoRotate({ enabled }: { enabled: boolean }) { } /** - * Loading spinner shown while GLB is parsing. + * Loading indicator (wireframe sphere) shown while GLB is parsing. */ function LoadingIndicator() { return ( @@ -207,7 +218,7 @@ export function GLBViewerNode({ id, data, selected }: NodeProps state.updateNodeData); const { setNodes, getNodes } = useReactFlow(); const fileInputRef = useRef(null); - const captureRef = useRef<(() => string) | null>(null); + const captureRef = useRef<(() => string | null) | null>(null); const viewportRef = useRef(null); const [autoRotate, setAutoRotate] = useState(false); const [isInteracting, setIsInteracting] = useState(false); @@ -256,15 +267,16 @@ export function GLBViewerNode({ id, data, selected }: NodeProps(nodeData.glbUrl); useEffect(() => { + glbUrlRef.current = nodeData.glbUrl; return () => { - if (nodeData.glbUrl) { - URL.revokeObjectURL(nodeData.glbUrl); + if (glbUrlRef.current) { + URL.revokeObjectURL(glbUrlRef.current); } }; - // eslint-disable-next-line react-hooks/exhaustive-deps -- only revoke on unmount - }, []); + }, [nodeData.glbUrl]); // Shared file processing logic for both click-to-upload and drag-and-drop const processFile = useCallback( @@ -339,7 +351,11 @@ export function GLBViewerNode({ id, data, selected }: NodeProps { if (captureRef.current) { const base64 = captureRef.current(); - updateNodeData(id, { capturedImage: base64 }); + if (base64) { + updateNodeData(id, { capturedImage: base64 }); + } else { + useToast.getState().show("Failed to capture 3D view", "error"); + } } }, [id, updateNodeData]); diff --git a/src/components/nodes/index.ts b/src/components/nodes/index.ts index e1309cd8..c161655d 100644 --- a/src/components/nodes/index.ts +++ b/src/components/nodes/index.ts @@ -12,5 +12,4 @@ export { OutputGalleryNode } from "./OutputGalleryNode"; export { ImageCompareNode } from "./ImageCompareNode"; export { VideoStitchNode } from "./VideoStitchNode"; export { EaseCurveNode } from "./EaseCurveNode"; -export { GLBViewerNode } from "./GLBViewerNode"; export { GroupNode } from "./GroupNode"; diff --git a/src/store/utils/__tests__/connectedInputs.test.ts b/src/store/utils/__tests__/connectedInputs.test.ts index f3886810..25d120bb 100644 --- a/src/store/utils/__tests__/connectedInputs.test.ts +++ b/src/store/utils/__tests__/connectedInputs.test.ts @@ -167,6 +167,26 @@ describe("getConnectedInputsPure", () => { }); }); + it("should extract capturedImage from glbViewer source", () => { + const nodes = [ + makeNode("glb", "glbViewer", { capturedImage: "data:image/png;base64,snap" }), + makeNode("gen", "nanoBanana"), + ]; + const edges = [makeEdge("glb", "gen", "image")]; + const result = getConnectedInputsPure("gen", nodes, edges); + expect(result.images).toEqual(["data:image/png;base64,snap"]); + }); + + it("should return empty images when glbViewer has no capture", () => { + const nodes = [ + makeNode("glb", "glbViewer", { capturedImage: null }), + makeNode("gen", "nanoBanana"), + ]; + const edges = [makeEdge("glb", "gen", "image")]; + const result = getConnectedInputsPure("gen", nodes, edges); + expect(result.images).toEqual([]); + }); + it("should extract audio from audioInput source", () => { const nodes = [ makeNode("aud", "audioInput", { audioFile: "data:audio/wav;base64,abc" }), diff --git a/src/store/utils/__tests__/nodeDefaults.test.ts b/src/store/utils/__tests__/nodeDefaults.test.ts index ef1934f6..18f9ed3c 100644 --- a/src/store/utils/__tests__/nodeDefaults.test.ts +++ b/src/store/utils/__tests__/nodeDefaults.test.ts @@ -41,6 +41,7 @@ describe("nodeDefaults utilities", () => { "generateVideo", "llmGenerate", "splitGrid", + "glbViewer", "output", ]; @@ -176,6 +177,14 @@ describe("nodeDefaults utilities", () => { expect(data).toHaveProperty("error", null); }); + it("creates correct structure for glbViewer", () => { + const data = createDefaultNodeData("glbViewer"); + + expect(data).toHaveProperty("glbUrl", null); + expect(data).toHaveProperty("filename", null); + expect(data).toHaveProperty("capturedImage", null); + }); + it("creates correct structure for output", () => { const data = createDefaultNodeData("output");