diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx
index 3f651c27..c80ebf66 100644
--- a/src/components/WorkflowCanvas.tsx
+++ b/src/components/WorkflowCanvas.tsx
@@ -1094,6 +1094,7 @@ export function WorkflowCanvas() {
promptConstructor: { width: 340, height: 280 },
nanoBanana: { width: 300, height: 300 },
generateVideo: { width: 300, height: 300 },
+ generate3d: { width: 300, height: 300 },
llmGenerate: { width: 320, height: 360 },
splitGrid: { width: 300, height: 320 },
output: { width: 320, height: 320 },
diff --git a/src/components/__tests__/ConnectionDropMenu.test.tsx b/src/components/__tests__/ConnectionDropMenu.test.tsx
index d42267c3..9d47cc39 100644
--- a/src/components/__tests__/ConnectionDropMenu.test.tsx
+++ b/src/components/__tests__/ConnectionDropMenu.test.tsx
@@ -79,6 +79,16 @@ describe("ConnectionDropMenu", () => {
expect(screen.queryByText("Annotate")).not.toBeInTheDocument();
expect(screen.queryByText("LLM Generate")).not.toBeInTheDocument();
});
+
+ it("should show 3D-accepting nodes when dragging from 3D output", () => {
+ render();
+
+ expect(screen.getByText("3D Viewer")).toBeInTheDocument();
+ // Should NOT show image/text/video nodes
+ expect(screen.queryByText("Annotate")).not.toBeInTheDocument();
+ expect(screen.queryByText("Generate Image")).not.toBeInTheDocument();
+ expect(screen.queryByText("Generate Video")).not.toBeInTheDocument();
+ });
});
describe("Node Type Filtering - Target Connection (from input handle)", () => {
@@ -110,6 +120,15 @@ describe("ConnectionDropMenu", () => {
expect(screen.queryByText("Image Input")).not.toBeInTheDocument();
expect(screen.queryByText("Prompt")).not.toBeInTheDocument();
});
+
+ it("should show 3D-producing nodes when dragging from 3D input", () => {
+ render();
+
+ expect(screen.getByText("Generate 3D")).toBeInTheDocument();
+ // Should NOT show other nodes
+ expect(screen.queryByText("Image Input")).not.toBeInTheDocument();
+ expect(screen.queryByText("Generate Image")).not.toBeInTheDocument();
+ });
});
describe("Menu Item Click", () => {
diff --git a/src/components/__tests__/Generate3DNode.test.tsx b/src/components/__tests__/Generate3DNode.test.tsx
new file mode 100644
index 00000000..6d587973
--- /dev/null
+++ b/src/components/__tests__/Generate3DNode.test.tsx
@@ -0,0 +1,234 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { Generate3DNode } from "@/components/nodes/Generate3DNode";
+import { ReactFlowProvider } from "@xyflow/react";
+import { Generate3DNodeData } from "@/types";
+
+// Mock deduplicatedFetch
+vi.mock("@/utils/deduplicatedFetch", () => ({
+ deduplicatedFetch: (...args: Parameters) => fetch(...args),
+ clearFetchCache: vi.fn(),
+}));
+
+// Mock the workflow store
+const mockUpdateNodeData = vi.fn();
+const mockRegenerateNode = vi.fn();
+const mockUseWorkflowStore = vi.fn();
+
+vi.mock("@/store/workflowStore", () => ({
+ useWorkflowStore: (selector?: (state: unknown) => unknown) => {
+ if (selector) {
+ return mockUseWorkflowStore(selector);
+ }
+ return mockUseWorkflowStore((s: unknown) => s);
+ },
+ useProviderApiKeys: () => ({
+ replicateApiKey: null,
+ falApiKey: null,
+ kieApiKey: null,
+ wavespeedApiKey: null,
+ replicateEnabled: false,
+ kieEnabled: false,
+ }),
+}));
+
+// Mock useReactFlow
+const mockSetNodes = vi.fn();
+
+vi.mock("@xyflow/react", async () => {
+ const actual = await vi.importActual("@xyflow/react");
+ return {
+ ...actual,
+ useReactFlow: () => ({
+ getNodes: vi.fn(() => []),
+ setNodes: mockSetNodes,
+ screenToFlowPosition: vi.fn((pos) => pos),
+ }),
+ };
+});
+
+// Mock Toast
+vi.mock("@/components/Toast", () => ({
+ useToast: {
+ getState: () => ({
+ show: vi.fn(),
+ }),
+ },
+}));
+
+// Mock createPortal
+vi.mock("react-dom", async () => {
+ const actual = await vi.importActual("react-dom");
+ return {
+ ...actual,
+ createPortal: (node: React.ReactNode) => node,
+ };
+});
+
+// Mock fetch
+const mockFetch = vi.fn();
+global.fetch = mockFetch;
+
+function TestWrapper({ children }: { children: React.ReactNode }) {
+ return {children};
+}
+
+describe("Generate3DNode", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockFetch.mockResolvedValue({
+ ok: true,
+ json: () => Promise.resolve({ models: [], success: true }),
+ });
+
+ mockUseWorkflowStore.mockImplementation((selector) => {
+ const state = {
+ updateNodeData: mockUpdateNodeData,
+ regenerateNode: mockRegenerateNode,
+ isRunning: false,
+ currentNodeIds: [],
+ groups: {},
+ nodes: [],
+ recentModels: [],
+ trackModelUsage: vi.fn(),
+ incrementModalCount: vi.fn(),
+ decrementModalCount: vi.fn(),
+ getNodesWithComments: vi.fn(() => []),
+ markCommentViewed: vi.fn(),
+ setNavigationTarget: vi.fn(),
+ generationsPath: null,
+ };
+ return selector(state);
+ });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ const createNodeData = (overrides: Partial = {}): Generate3DNodeData => ({
+ inputImages: [],
+ inputPrompt: null,
+ output3dUrl: null,
+ status: "idle",
+ error: null,
+ ...overrides,
+ });
+
+ const createNodeProps = (data: Partial = {}) => ({
+ id: "test-3d-node",
+ type: "generate3d" as const,
+ data: createNodeData(data),
+ selected: false,
+ });
+
+ describe("Basic Rendering", () => {
+ it("should render with default title when no model selected", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("Select 3D model...")).toBeInTheDocument();
+ });
+
+ it("should render model name when selected", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("TripoSR")).toBeInTheDocument();
+ });
+
+ it("should render Browse button", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("Browse")).toBeInTheDocument();
+ });
+
+ it("should render 'Run to generate' when idle", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("Run to generate")).toBeInTheDocument();
+ });
+ });
+
+ describe("Handles", () => {
+ it("should render image and text input handles", () => {
+ const { container } = render(
+
+
+
+ );
+
+ const imageHandle = container.querySelector('[data-handletype="image"]');
+ const textHandle = container.querySelector('[data-handletype="text"]');
+ expect(imageHandle).toBeInTheDocument();
+ expect(textHandle).toBeInTheDocument();
+ });
+
+ it("should render 3D output handle", () => {
+ const { container } = render(
+
+
+
+ );
+
+ const outputHandle = container.querySelector('[data-handletype="3d"]');
+ expect(outputHandle).toBeInTheDocument();
+ });
+
+ it("should render output label as '3D'", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("3D")).toBeInTheDocument();
+ });
+ });
+
+ describe("Output States", () => {
+ it("should show 3D model generated indicator when output exists", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("3D Model Generated")).toBeInTheDocument();
+ expect(screen.getByText("Connect to 3D Viewer")).toBeInTheDocument();
+ });
+
+ it("should show error message when status is error", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText("Model not found")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/components/__tests__/ModelSearchDialog.test.tsx b/src/components/__tests__/ModelSearchDialog.test.tsx
index f30a7f02..7fbaf9b6 100644
--- a/src/components/__tests__/ModelSearchDialog.test.tsx
+++ b/src/components/__tests__/ModelSearchDialog.test.tsx
@@ -104,6 +104,14 @@ const sampleModels: ProviderModel[] = [
capabilities: ["text-to-video", "image-to-video"],
coverImage: "https://example.com/kling.jpg",
},
+ {
+ id: "fal-ai/triposr",
+ name: "TripoSR",
+ description: "3D model generation from images",
+ provider: "fal",
+ capabilities: ["image-to-3d"],
+ coverImage: "https://example.com/triposr.jpg",
+ },
];
describe("ModelSearchDialog", () => {
@@ -412,7 +420,7 @@ describe("ModelSearchDialog", () => {
);
await waitFor(() => {
- expect(screen.getByText(/3 models? found/)).toBeInTheDocument();
+ expect(screen.getByText(/4 models? found/)).toBeInTheDocument();
});
});
});
@@ -475,6 +483,7 @@ describe("ModelSearchDialog", () => {
provider: "replicate",
modelId: "stability-ai/sdxl",
displayName: "SDXL",
+ capabilities: ["text-to-image"],
},
})
);
@@ -506,6 +515,38 @@ describe("ModelSearchDialog", () => {
provider: "fal",
modelId: "kling-video/v1.6/pro",
displayName: "Kling Video Pro",
+ capabilities: ["text-to-video", "image-to-video"],
+ },
+ })
+ );
+ });
+
+ it("should create generate3d node for 3D models", async () => {
+ const onClose = vi.fn();
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText("TripoSR")).toBeInTheDocument();
+ });
+
+ // Click on the TripoSR 3D model card
+ const modelCard = screen.getByText("TripoSR").closest("button");
+ fireEvent.click(modelCard!);
+
+ expect(mockAddNode).toHaveBeenCalledWith(
+ "generate3d",
+ expect.any(Object),
+ expect.objectContaining({
+ selectedModel: {
+ provider: "fal",
+ modelId: "fal-ai/triposr",
+ displayName: "TripoSR",
+ capabilities: ["image-to-3d"],
},
})
);
diff --git a/src/lib/chat/tools.ts b/src/lib/chat/tools.ts
index beea39b2..17f2e962 100644
--- a/src/lib/chat/tools.ts
+++ b/src/lib/chat/tools.ts
@@ -15,6 +15,7 @@ const VALID_NODE_TYPES: NodeType[] = [
"prompt",
"nanoBanana",
"generateVideo",
+ "generate3d",
"llmGenerate",
"splitGrid",
"output",
@@ -205,7 +206,7 @@ export function createChatTools(nodeIds: string[]) {
.string()
.optional()
.describe(
- "Node type for addNode. Valid: imageInput, annotation, prompt, nanoBanana, generateVideo, llmGenerate, splitGrid, output"
+ "Node type for addNode. Valid: imageInput, annotation, prompt, nanoBanana, generateVideo, generate3d, llmGenerate, splitGrid, output"
),
nodeId: z
.string()
diff --git a/src/lib/quickstart/validation.ts b/src/lib/quickstart/validation.ts
index ecbada66..c0cd0aa6 100644
--- a/src/lib/quickstart/validation.ts
+++ b/src/lib/quickstart/validation.ts
@@ -17,6 +17,7 @@ const VALID_NODE_TYPES: NodeType[] = [
"prompt",
"nanoBanana",
"generateVideo",
+ "generate3d",
"llmGenerate",
"splitGrid",
"output",
@@ -33,6 +34,7 @@ const DEFAULT_DIMENSIONS: Record =
promptConstructor: { width: 340, height: 280 },
nanoBanana: { width: 300, height: 300 },
generateVideo: { width: 300, height: 300 },
+ generate3d: { width: 300, height: 300 },
llmGenerate: { width: 320, height: 360 },
splitGrid: { width: 300, height: 320 },
output: { width: 320, height: 320 },
@@ -250,6 +252,15 @@ function createDefaultNodeData(type: NodeType): WorkflowNodeData {
videoHistory: [],
selectedVideoHistoryIndex: 0,
};
+ case "generate3d":
+ return {
+ inputImages: [],
+ inputPrompt: null,
+ output3dUrl: null,
+ selectedModel: undefined,
+ status: "idle",
+ error: null,
+ };
case "llmGenerate":
return {
inputPrompt: null,
diff --git a/src/store/execution/executeNode.ts b/src/store/execution/executeNode.ts
index 4d7d2530..640cb3ed 100644
--- a/src/store/execution/executeNode.ts
+++ b/src/store/execution/executeNode.ts
@@ -18,6 +18,7 @@ import {
} from "./simpleNodeExecutors";
import { executeNanoBanana } from "./nanoBananaExecutor";
import { executeGenerateVideo } from "./generateVideoExecutor";
+import { executeGenerate3D } from "./generate3dExecutor";
import { executeLlmGenerate } from "./llmGenerateExecutor";
import { executeSplitGrid } from "./splitGridExecutor";
import { executeVideoStitch, executeEaseCurve } from "./videoProcessingExecutors";
@@ -58,6 +59,9 @@ export async function executeNode(
case "generateVideo":
await executeGenerateVideo(ctx, regenOpts);
break;
+ case "generate3d":
+ await executeGenerate3D(ctx, regenOpts);
+ break;
case "llmGenerate":
await executeLlmGenerate(ctx, regenOpts);
break;