(null);
+ const projectId = useSceneStore((s) => s.project?.metadata.id ?? null);
+
+ useEffect(() => {
+ if (!projectId) return;
+ const container = containerRef.current;
+ if (!container) return;
+
+ const canvas = document.createElement("canvas");
+ canvas.style.display = "block";
+ canvas.style.width = "100%";
+ canvas.style.height = "100%";
+ container.appendChild(canvas);
+
+ const host = createHost ? createHost() : new BabylonRenderHost();
+ host.mount(canvas);
+ const adapter = host.adapter;
+
+ const trySync = (node: SceneNode, op: SyncOp) => {
+ try {
+ adapter.syncNode(node, op);
+ } catch (e) {
+ console.warn(
+ `BabylonViewport: syncNode ${op} failed for ${node.id} — skipped`,
+ e,
+ );
+ }
+ };
+ const applyDiff = (diff: SceneDiff) => {
+ for (const n of diff.added) trySync(n, "add");
+ for (const n of diff.updated) trySync(n, "update");
+ for (const n of diff.removed) trySync(n, "remove");
+ };
+
+ const initial = useSceneStore.getState().project;
+ if (initial) applyDiff(diffSceneNodes(EMPTY_SCENE_GRAPH, initial.scene));
+
+ host.start();
+
+ const unsubscribe = useSceneStore.subscribe((state, prev) => {
+ const next = state.project;
+ const old = prev.project;
+ if (!next || !old) return;
+ if (next === old) return;
+ if (next.metadata.id !== old.metadata.id) return; // handled by effect re-run
+ applyDiff(diffSceneNodes(old.scene, next.scene));
+ });
+
+ const resize = () => {
+ const w = container.clientWidth;
+ const h = container.clientHeight;
+ if (w === 0 || h === 0) return;
+ host.resize(w, h);
+ };
+ resize();
+ const ro = new ResizeObserver(resize);
+ ro.observe(container);
+
+ return () => {
+ unsubscribe();
+ ro.disconnect();
+ host.dispose();
+ if (canvas.parentNode === container) {
+ container.removeChild(canvas);
+ }
+ };
+ }, [projectId, createHost]);
+
+ return ;
+}
diff --git a/src/ui/viewport/EngineToggle.test.tsx b/src/ui/viewport/EngineToggle.test.tsx
new file mode 100644
index 0000000..96a7ec9
--- /dev/null
+++ b/src/ui/viewport/EngineToggle.test.tsx
@@ -0,0 +1,33 @@
+import { fireEvent, render, screen } from "@testing-library/react";
+import { beforeEach, describe, expect, it } from "vitest";
+
+import { useUIStore } from "@/services/ui/store";
+
+import { EngineToggle } from "./EngineToggle";
+
+describe("EngineToggle", () => {
+ beforeEach(() =>
+ useUIStore.setState({ viewportEngine: "three.js", playState: "edit" }),
+ );
+
+ it("switches the engine on click", () => {
+ render();
+ fireEvent.click(screen.getByText("Babylon"));
+ expect(useUIStore.getState().viewportEngine).toBe("babylon.js");
+ });
+
+ it("forces play state back to edit when switching", () => {
+ useUIStore.setState({ playState: "play" });
+ render();
+ fireEvent.click(screen.getByText("Babylon"));
+ expect(useUIStore.getState().playState).toBe("edit");
+ });
+
+ it("clicking the active engine is a no-op", () => {
+ useUIStore.setState({ playState: "play" });
+ render();
+ fireEvent.click(screen.getByText("Three"));
+ expect(useUIStore.getState().viewportEngine).toBe("three.js");
+ expect(useUIStore.getState().playState).toBe("play");
+ });
+});
diff --git a/src/ui/viewport/EngineToggle.tsx b/src/ui/viewport/EngineToggle.tsx
new file mode 100644
index 0000000..ad681eb
--- /dev/null
+++ b/src/ui/viewport/EngineToggle.tsx
@@ -0,0 +1,54 @@
+import { useTranslation } from "react-i18next";
+
+import { cn } from "@/lib/utils";
+import type { ViewportEngine } from "@/runtime/render-host";
+import { useUIStore } from "@/services/ui/store";
+
+const ENGINES: { engine: ViewportEngine; labelKey: string }[] = [
+ { engine: "three.js", labelKey: "viewport.engine.three" },
+ { engine: "babylon.js", labelKey: "viewport.engine.babylon" },
+];
+
+/**
+ * Three/Babylon viewport engine switch (v1.0 B1). Switching forces playState
+ * back to "edit" first: the Three viewport tears down play mode on unmount,
+ * but the store flag would otherwise stay "play" with nothing ticking —
+ * and remounting ThreeViewport while playState === "play" would show a
+ * paused-looking play mode with no behaviors installed.
+ */
+export function EngineToggle() {
+ const { t } = useTranslation("editor");
+ const engine = useUIStore((s) => s.viewportEngine);
+ const setViewportEngine = useUIStore((s) => s.setViewportEngine);
+ const setPlayState = useUIStore((s) => s.setPlayState);
+
+ return (
+
+ {ENGINES.map((entry) => {
+ const active = entry.engine === engine;
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/src/ui/viewport/PlayButton.test.tsx b/src/ui/viewport/PlayButton.test.tsx
index bf36546..854124d 100644
--- a/src/ui/viewport/PlayButton.test.tsx
+++ b/src/ui/viewport/PlayButton.test.tsx
@@ -6,7 +6,9 @@ import { useUIStore } from "@/services/ui/store";
import { PlayButton } from "./PlayButton";
describe("PlayButton", () => {
- beforeEach(() => useUIStore.setState({ playState: "edit" }));
+ beforeEach(() =>
+ useUIStore.setState({ playState: "edit", viewportEngine: "three.js" }),
+ );
it("shows 'Play' label when in edit mode", () => {
render();
@@ -31,4 +33,13 @@ describe("PlayButton", () => {
fireEvent.click(screen.getByText(/pause/i));
expect(useUIStore.getState().playState).toBe("edit");
});
+
+ it("is disabled in the Babylon viewport (B1 — play lands in B4)", () => {
+ useUIStore.setState({ viewportEngine: "babylon.js" });
+ render();
+ const button = screen.getByRole("button");
+ expect(button).toBeDisabled();
+ fireEvent.click(button);
+ expect(useUIStore.getState().playState).toBe("edit");
+ });
});
diff --git a/src/ui/viewport/PlayButton.tsx b/src/ui/viewport/PlayButton.tsx
index 3ce640d..4536809 100644
--- a/src/ui/viewport/PlayButton.tsx
+++ b/src/ui/viewport/PlayButton.tsx
@@ -1,18 +1,23 @@
import { useTranslation } from "react-i18next";
+import { isEngineEditingCapable } from "@/runtime/render-host";
import { useUIStore } from "@/services/ui/store";
export function PlayButton() {
const { t } = useTranslation("editor");
const playState = useUIStore((s) => s.playState);
const setPlayState = useUIStore((s) => s.setPlayState);
+ const viewportEngine = useUIStore((s) => s.viewportEngine);
const isPlay = playState === "play";
+ const editingCapable = isEngineEditingCapable(viewportEngine);
return (