diff --git a/frontend/apps/artcraft/app/src/components/experiences/ImageTo3DExperience.tsx b/frontend/apps/artcraft/app/src/components/experiences/ImageTo3DExperience.tsx
--- a/frontend/apps/artcraft/app/src/components/experiences/ImageTo3DExperience.tsx
+++ b/frontend/apps/artcraft/app/src/components/experiences/ImageTo3DExperience.tsx
@@ -1,9 +1,10 @@
+import { sendMediaToStage } from "../generation-feed/sendMediaToStage";
 import { useEffect, useMemo, useRef, useState } from "react";
 import { animated, useSpring } from "@react-spring/web";
 import { Button, GenerateButton } from "@storyteller/ui-button";
 import { TabSelector } from "@storyteller/ui-tab-selector";
 import { Tooltip } from "@storyteller/ui-tooltip";
-import { Viewer3D } from "@storyteller/ui-viewer-3d";
+import { SendToStageButton, Viewer3D } from "@storyteller/ui-viewer-3d";
 import { BoxIcon, ImagesIcon, PlusIcon, UploadIcon, XIcon } from "lucide-react";
 import { twMerge } from "tailwind-merge";
 import { useImageTo3DStore } from "../../pages/PageImageTo3DObject/ImageTo3DStore";
@@ -19,10 +20,6 @@ import {
 } from "@storyteller/tauri-api";
 import { toast } from "react-hot-toast";
 import { v4 as uuidv4 } from "uuid";
-import { useTabStore } from "../../pages/Stores/TabState";
-import { addObject, getActiveEditor } from "@storyteller/ui-pagescene";
-import type { MediaItem } from "@storyteller/ui-pagescene";
-import { AssetType } from "~/enums";
 import { SPLAT_MODELS } from "@storyteller/model-list";
 import {
   ClassyModelSelector,
@@ -959,33 +956,9 @@ export const ImageTo3DExperience = ({
               />
               {activeResult?.modelUrl && activeResult?.mediaToken && (
                 <div className="absolute right-4 top-4 z-10 flex gap-2">
-                  <Button
-                    variant="primary"
-                    className="min-w-[120px]"
-                    onClick={() => {
-                      useTabStore.getState().setActiveTab("3D");
-                      setTimeout(() => {
-                        const mediaItem = {
-                          version: 1,
-                          type: AssetType.OBJECT,
-                          media_id: activeResult.mediaToken!,
-                          name: activeResult.note || "3D Object",
-                          position: { x: 0, y: 0, z: 0 },
-                        } as MediaItem & {
-                          position: { x: number; y: number; z: number };
-                        };
-                        const editor = getActiveEditor();
-                        if (editor) {
-                          void addObject(editor, mediaItem);
-                          toast.success("Added to 3D scene");
-                        } else {
-                          toast.error("3D editor isn't ready yet");
-                        }
-                      }, 500);
-                    }}
-                  >
-                    Open in 3D Editor
-                  </Button>
+                  <SendToStageButton
+                    onSend={() => sendMediaToStage(activeResult.mediaToken ?? undefined, activeResult.note || "3D Object")}
+                  />
                   <Button
                     variant="action"
                     icon={BoxIcon}
diff --git a/frontend/apps/artcraft/app/src/components/generation-feed/sendMediaToStage.spec.ts b/frontend/apps/artcraft/app/src/components/generation-feed/sendMediaToStage.spec.ts
new file mode 100644
--- /dev/null
+++ b/frontend/apps/artcraft/app/src/components/generation-feed/sendMediaToStage.spec.ts
@@ -0,0 +1,57 @@
+import { afterEach, beforeEach, expect, it, vi } from "vitest";
+import { sendMediaToStage } from "./sendMediaToStage";
+import { useTabStore } from "../../pages/Stores/TabState";
+
+const stage = vi.hoisted(() => ({ editor: vi.fn(), add: vi.fn() }));
+vi.mock("@storyteller/ui-pagescene", () => ({ getActiveEditor: stage.editor, addObject: stage.add, AssetType: { OBJECT: "object" } }));
+
+beforeEach(() => {
+  vi.useFakeTimers();
+  stage.editor.mockReset();
+  stage.add.mockReset().mockResolvedValue("object-id");
+  useTabStore.setState(useTabStore.getInitialState(), true);
+});
+afterEach(() => vi.useRealTimers());
+
+it("waits for the saved scene to load before adding the selected media", async () => {
+  let loaded = false;
+  const editor = { sceneManager: {}, isEngineDataLoaded: () => loaded };
+  stage.editor.mockReturnValue(editor);
+  const result = sendMediaToStage("mf_selected");
+  await vi.advanceTimersByTimeAsync(500);
+  expect(stage.add).not.toHaveBeenCalled();
+  loaded = true;
+  await vi.advanceTimersByTimeAsync(50);
+  await result;
+  expect(useTabStore.getState().activeTabId).toBe("3D");
+  expect(stage.add).toHaveBeenCalledExactlyOnceWith(editor, expect.objectContaining({ media_id: "mf_selected" }));
+});
+
+it("times out without inserting an asset when the editor never becomes ready", async () => {
+  stage.editor.mockReturnValue(null);
+  const result = expect(sendMediaToStage("mf_selected")).rejects.toThrow("still loading");
+  await vi.advanceTimersByTimeAsync(5000);
+  await result;
+  expect(stage.add).not.toHaveBeenCalled();
+});
+
+it("does not report success when the scene rejects the asset", async () => {
+  stage.editor.mockReturnValue({ sceneManager: {}, isEngineDataLoaded: () => true });
+  stage.add.mockResolvedValue(undefined);
+  await expect(sendMediaToStage("mf_selected")).rejects.toThrow("could not be added");
+});
+
+it("rejects a missing media ID without navigating", async () => {
+  await expect(sendMediaToStage()).rejects.toThrow("no media ID");
+  expect(useTabStore.getState().activeTabId).toBe("APPS");
+});
+
+it("cancels the pending transfer if the user leaves the stage", async () => {
+  stage.editor.mockReturnValue(null);
+  const result = expect(sendMediaToStage("mf_selected")).rejects.toThrow("switched tabs");
+  await vi.advanceTimersByTimeAsync(50);
+  await useTabStore.getState().setActiveTab("IMAGE");
+  await vi.advanceTimersByTimeAsync(50);
+  await result;
+  expect(stage.add).not.toHaveBeenCalled();
+});
diff --git a/frontend/apps/artcraft/app/src/components/generation-feed/sendMediaToStage.ts b/frontend/apps/artcraft/app/src/components/generation-feed/sendMediaToStage.ts
new file mode 100644
--- /dev/null
+++ b/frontend/apps/artcraft/app/src/components/generation-feed/sendMediaToStage.ts
@@ -0,0 +1,24 @@
+import { addObject, AssetType, getActiveEditor } from "@storyteller/ui-pagescene";
+import { useTabStore } from "../../pages/Stores/TabState";
+
+/** Add a library asset only after the stage and its saved scene are ready. */
+export async function sendMediaToStage(mediaId?: string, name = "3D asset"): Promise<void> {
+  if (!mediaId) throw new Error("This asset has no media ID. Please reopen it from your library.");
+  if (!await useTabStore.getState().setActiveTab("3D")) {
+    throw new Error("Could not open the 3D stage.");
+  }
+  for (let attempt = 0; attempt < 100; attempt += 1) {
+    if (useTabStore.getState().activeTabId !== "3D") {
+      throw new Error("Stage transfer cancelled because you switched tabs.");
+    }
+    const editor = getActiveEditor();
+    if (editor?.sceneManager && editor.isEngineDataLoaded()) {
+      const objectId = await addObject(editor, { version: 1, type: AssetType.OBJECT, media_id: mediaId, name });
+      if (!objectId) throw new Error("The asset could not be added to the stage. Please try again.");
+      if (getActiveEditor() !== editor) throw new Error("The stage changed while the asset was loading. Please try again.");
+      return;
+    }
+    await new Promise<void>((resolve) => setTimeout(resolve, 50));
+  }
+  throw new Error("The 3D stage is still loading. Please try again in a moment.");
+}
diff --git a/frontend/apps/artcraft/app/src/components/signaled/TopBar/TopBar.tsx b/frontend/apps/artcraft/app/src/components/signaled/TopBar/TopBar.tsx
--- a/frontend/apps/artcraft/app/src/components/signaled/TopBar/TopBar.tsx
+++ b/frontend/apps/artcraft/app/src/components/signaled/TopBar/TopBar.tsx
@@ -1,3 +1,4 @@
+import { sendMediaToStage } from "~/components/generation-feed/sendMediaToStage";
 import { CalculatorIcon, CheckIcon, ChevronRightIcon, CircleAlertIcon, CoinsIcon, GemIcon, HouseIcon, ImagesIcon, MinusIcon, PictureInPicture2Icon, SettingsIcon, SquareIcon, XIcon } from "lucide-react";
 import { DynamicIcon } from "@storyteller/icons";
 import { signal } from "@preact/signals-react";
@@ -704,6 +705,7 @@ export const TopBar = ({ pageName }: Props) => {
       <GalleryModal
         mode="view"
         onDownloadClicked={downloadFile}
+        onAddToSceneClicked={(_url, mediaId) => sendMediaToStage(mediaId)}
         onEditClicked={handleEditFromGallery}
         onTurnIntoVideoClicked={handleTurnIntoVideoFromGallery}
         onRemoveBackgroundClicked={handleRemoveBackgroundFromGallery}
diff --git a/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.tsx b/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.tsx
--- a/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.tsx
+++ b/frontend/libs/components/lightbox-modal/src/lib/lightbox-modal.tsx
@@ -6,7 +6,7 @@ import { ArrowDownToLineIcon, BoxIcon, CheckIcon, ChevronLeftIcon, ChevronRightI
 import { DynamicIcon } from "@storyteller/icons";
 import { MediaFileDelete } from "@storyteller/tauri-api";
 import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
-import { Viewer3D } from "@storyteller/ui-viewer-3d";
+import { SendToStageButton, Viewer3D } from "@storyteller/ui-viewer-3d";
 import { WaveformAudioPlayer } from "@storyteller/ui-audio-player";
 import {
   useEffect,
@@ -1184,20 +1184,16 @@ export function LightboxModal({
                   </Button>
                 )}
 
-                {onAddToSceneClicked && actionUrl && (
-                  <Button
-                    className="w-full py-1.5 text-[13px]"
-                    variant="secondary"
-                    onClick={async (e) => {
-                      e.stopPropagation();
+                {onAddToSceneClicked && actionUrl && selectedMediaToken &&
+                  ["dimensional", "mesh", "splat"].includes(mediaClass ?? "") && (
+                  <SendToStageButton
+                    onSend={() => onAddToSceneClicked(actionUrl, selectedMediaToken)}
+                    onSent={() => {
                       gtagEvent("add_to_scene_clicked");
-                      await onAddToSceneClicked(actionUrl, selectedMediaToken);
                       onClose();
                       onCloseGallery();
                     }}
-                  >
-                    Add to Scene
-                  </Button>
+                  />
                 )}
 
                 {selectedMediaToken && (
diff --git a/frontend/libs/components/viewer-3d/src/index.ts b/frontend/libs/components/viewer-3d/src/index.ts
--- a/frontend/libs/components/viewer-3d/src/index.ts
+++ b/frontend/libs/components/viewer-3d/src/index.ts
@@ -2,3 +2,5 @@
 export * from './lib/viewer-3d';
 export * from './lib/NodeHierarchyHelper';
 export * from './lib/disposeObject3D';
+
+export { SendToStageButton } from "./lib/SendToStageButton";
diff --git a/frontend/libs/components/viewer-3d/src/lib/SendToStageButton.spec.tsx b/frontend/libs/components/viewer-3d/src/lib/SendToStageButton.spec.tsx
new file mode 100644
--- /dev/null
+++ b/frontend/libs/components/viewer-3d/src/lib/SendToStageButton.spec.tsx
@@ -0,0 +1,38 @@
+import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { ButtonHTMLAttributes } from "react";
+import { SendToStageButton } from "./SendToStageButton";
+
+const notify = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() }));
+vi.mock("react-hot-toast", () => ({ default: notify }));
+vi.mock("@storyteller/ui-button", () => ({ Button: ({ children, disabled, onClick }: ButtonHTMLAttributes<HTMLButtonElement>) => <button disabled={disabled} onClick={onClick}>{children}</button> }));
+beforeEach(() => vi.clearAllMocks());
+afterEach(cleanup);
+
+describe("SendToStageButton", () => {
+  it("allows one transfer and closes only after confirmed success", async () => {
+    let finish!: () => void;
+    const onSend = vi.fn(() => new Promise<void>((resolve) => { finish = resolve; }));
+    const onSent = vi.fn();
+    render(<SendToStageButton onSend={onSend} onSent={onSent} />);
+    fireEvent.click(screen.getByRole("button"));
+    fireEvent.click(screen.getByRole("button"));
+    expect(onSend).toHaveBeenCalledTimes(1);
+    expect(onSent).not.toHaveBeenCalled();
+    expect((screen.getByRole("button") as HTMLButtonElement).disabled).toBe(true);
+    finish();
+    await waitFor(() => expect(onSent).toHaveBeenCalledTimes(1));
+    expect(notify.success).toHaveBeenCalledTimes(1);
+  });
+
+  it("keeps the viewer open and permits retry after a failed transfer", async () => {
+    const onSend = vi.fn().mockRejectedValueOnce(new Error("Model download failed")).mockResolvedValueOnce(undefined);
+    const onSent = vi.fn();
+    render(<SendToStageButton onSend={onSend} onSent={onSent} />);
+    fireEvent.click(screen.getByRole("button"));
+    await waitFor(() => expect(notify.error).toHaveBeenCalledWith("Model download failed"));
+    expect(onSent).not.toHaveBeenCalled();
+    fireEvent.click(screen.getByRole("button"));
+    await waitFor(() => expect(onSent).toHaveBeenCalledTimes(1));
+  });
+});
diff --git a/frontend/libs/components/viewer-3d/src/lib/SendToStageButton.tsx b/frontend/libs/components/viewer-3d/src/lib/SendToStageButton.tsx
new file mode 100644
--- /dev/null
+++ b/frontend/libs/components/viewer-3d/src/lib/SendToStageButton.tsx
@@ -0,0 +1,34 @@
+import { useRef, useState } from "react";
+import { Button } from "@storyteller/ui-button";
+import toast from "react-hot-toast";
+
+export function SendToStageButton({ onSend, onSent }: { onSend: () => Promise<void>; onSent?: () => void }) {
+  const submitting = useRef(false);
+  const [pending, setPending] = useState(false);
+  return (
+    <Button
+      className="w-full py-1.5 text-[13px]"
+      variant="secondary"
+      disabled={pending}
+      loading={pending}
+      onClick={async (event) => {
+        event.stopPropagation();
+        if (submitting.current) return;
+        submitting.current = true;
+        setPending(true);
+        try {
+          await onSend();
+          toast.success("Added to the 3D stage");
+          onSent?.();
+        } catch (error) {
+          toast.error(error instanceof Error ? error.message : "Could not add this asset to the stage. Please try again.");
+        } finally {
+          submitting.current = false;
+          setPending(false);
+        }
+      }}
+    >
+      {pending ? "Sending to stage…" : "Send to stage"}
+    </Button>
+  );
+}
diff --git a/netlify.toml b/netlify.toml
new file mode 100644
--- /dev/null
+++ b/netlify.toml
@@ -0,0 +1,7 @@
+# The `storyteller-docs` Netlify site still builds this repository, but its
+# build command (./script/netlify_build_docs.sh) and the docs themselves now
+# live in storytold/artcraft-services, so every deploy preview here fails.
+# Skip those builds until the Netlify site is repointed or disconnected, then
+# delete this file.
+[build]
+  ignore = "exit 0"
