Add scene graph viewer and persist migration (#115)

Render an interactive Scene Graph in the Settings panel using @visual-json/react. Introduces SceneNode/SceneGraphNode types and buildSceneGraphValue to build a tree (handles missing nodes and cycles), and adds handlers to block drag/drop/context/delete mutations inside the viewer. Adds @visual-json/react to apps/editor package.json and updates the dev script to forbid .env.local and source the root .env instead. Also adds a persistence migration in use-scene to preserve existing nodes and rootNodeIds when the persist version changes.
This commit is contained in:
Aymeric Rabot
2026-02-24 00:40:18 -05:00
committed by GitHub
parent 5781e98270
commit db05c86120
4 changed files with 203 additions and 2 deletions
@@ -1,14 +1,161 @@
import { emitter, useScene } from "@pascal-app/core"; import { emitter, useScene } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer"; import { useViewer } from "@pascal-app/viewer";
import { VisualJson, TreeView } from "@visual-json/react";
import { Camera, Download, Save, Trash2, Upload } from "lucide-react"; import { Camera, Download, Save, Trash2, Upload } from "lucide-react";
import { useRef, useState } from "react"; import {
type KeyboardEvent,
type SyntheticEvent,
useCallback,
useMemo,
useRef,
useState,
} from "react";
import { Button } from "@/components/ui/primitives/button"; import { Button } from "@/components/ui/primitives/button";
import {
Dialog,
DialogContent,
DialogTitle,
DialogTrigger,
} from "@/components/ui/primitives/dialog";
import { Switch } from "@/components/ui/primitives/switch"; import { Switch } from "@/components/ui/primitives/switch";
import useEditor from "@/store/use-editor"; import useEditor from "@/store/use-editor";
import { AudioSettingsDialog } from "./audio-settings-dialog"; import { AudioSettingsDialog } from "./audio-settings-dialog";
import { useProjectStore } from "@/features/community/lib/projects/store"; import { useProjectStore } from "@/features/community/lib/projects/store";
import { updateProjectVisibility } from "@/features/community/lib/projects/actions"; import { updateProjectVisibility } from "@/features/community/lib/projects/actions";
type SceneNode = Record<string, unknown> & {
id?: unknown;
type?: unknown;
name?: unknown;
parentId?: unknown;
children?: unknown;
};
type SceneGraphNode = {
id: string;
type: string;
name: string | null;
parentId: string | null;
children: SceneGraphNode[];
missing?: true;
cycle?: true;
};
type SceneGraphValue = {
roots: SceneGraphNode[];
detachedNodes?: SceneGraphNode[];
};
const isSceneNode = (value: unknown): value is SceneNode => {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
typeof (value as { id: unknown }).id === "string"
);
};
const getChildIdsFromNode = (node: SceneNode): string[] => {
if (!Array.isArray(node.children)) {
return [];
}
const childIds = new Set<string>();
for (const child of node.children) {
if (typeof child === "string") {
childIds.add(child);
continue;
}
if (isSceneNode(child)) {
childIds.add(child.id as string);
}
}
return Array.from(childIds);
};
const buildSceneGraphValue = (
nodes: Record<string, SceneNode>,
rootNodeIds: string[],
): SceneGraphValue => {
const childIdsByParent = new Map<string, Set<string>>();
for (const [id, node] of Object.entries(nodes)) {
const childIds = getChildIdsFromNode(node);
if (childIds.length > 0) {
childIdsByParent.set(id, new Set(childIds));
}
}
for (const [id, node] of Object.entries(nodes)) {
if (typeof node.parentId !== "string") {
continue;
}
const siblings = childIdsByParent.get(node.parentId) ?? new Set<string>();
siblings.add(id);
childIdsByParent.set(node.parentId, siblings);
}
const visited = new Set<string>();
const buildNode = (id: string, path: Set<string>): SceneGraphNode => {
const node = nodes[id];
if (!node) {
return {
id,
type: "missing",
name: null,
parentId: null,
missing: true,
children: [],
};
}
const nodeType = typeof node.type === "string" ? node.type : "unknown";
const nodeName = typeof node.name === "string" ? node.name : null;
const parentId = typeof node.parentId === "string" ? node.parentId : null;
if (path.has(id)) {
return {
id,
type: nodeType,
name: nodeName,
parentId,
cycle: true,
children: [],
};
}
visited.add(id);
const nextPath = new Set(path);
nextPath.add(id);
const childIds = Array.from(childIdsByParent.get(id) ?? []);
return {
id,
type: nodeType,
name: nodeName,
parentId,
children: childIds.map((childId) => buildNode(childId, nextPath)),
};
};
const roots = rootNodeIds.map((id) => buildNode(id, new Set()));
const detachedNodeIds = Object.keys(nodes).filter((id) => !visited.has(id));
if (detachedNodeIds.length === 0) {
return { roots };
}
return {
roots,
detachedNodes: detachedNodeIds.map((id) => buildNode(id, new Set())),
};
};
export function SettingsPanel() { export function SettingsPanel() {
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const nodes = useScene((state) => state.nodes); const nodes = useScene((state) => state.nodes);
@@ -20,6 +167,23 @@ export function SettingsPanel() {
const setPhase = useEditor((state) => state.setPhase); const setPhase = useEditor((state) => state.setPhase);
const activeProject = useProjectStore((state) => state.activeProject); const activeProject = useProjectStore((state) => state.activeProject);
const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false); const [isGeneratingThumbnail, setIsGeneratingThumbnail] = useState(false);
const sceneGraphValue = useMemo(
() => buildSceneGraphValue(nodes as Record<string, SceneNode>, rootNodeIds),
[nodes, rootNodeIds],
);
const blockSceneGraphMutations = useCallback((event: SyntheticEvent) => {
event.preventDefault();
event.stopPropagation();
}, []);
const blockSceneGraphDeletion = useCallback(
(event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Delete" || event.key === "Backspace") {
event.preventDefault();
event.stopPropagation();
}
},
[],
);
const projectId = activeProject?.id; const projectId = activeProject?.id;
const isLocalProject = false; // Store only contains cloud projects const isLocalProject = false; // Store only contains cloud projects
@@ -231,6 +395,34 @@ export function SettingsPanel() {
<AudioSettingsDialog /> <AudioSettingsDialog />
</div> </div>
{/* Scene Graph */}
<div className="space-y-1">
<label className="font-medium text-muted-foreground text-xs uppercase">
Scene Graph
</label>
<Dialog>
<DialogTrigger asChild>
<Button className="h-auto justify-start p-0 text-sm" variant="link">
Explore scene graph
</Button>
</DialogTrigger>
<DialogContent className="h-[80vh] max-w-[95vw] gap-0 overflow-hidden border-0 bg-[#1e1e1e] p-0 shadow-none sm:max-w-5xl">
<DialogTitle className="sr-only">Scene Graph</DialogTitle>
<div
className="flex h-full w-full min-h-0 min-w-0 *:h-full *:w-full *:overflow-y-auto"
onContextMenuCapture={blockSceneGraphMutations}
onDragStartCapture={blockSceneGraphMutations}
onDropCapture={blockSceneGraphMutations}
onKeyDownCapture={blockSceneGraphDeletion}
>
<VisualJson value={sceneGraphValue}>
<TreeView showCounts />
</VisualJson>
</div>
</DialogContent>
</Dialog>
</div>
{/* Danger Zone */} {/* Danger Zone */}
<div className="space-y-2"> <div className="space-y-2">
<label className="font-medium text-destructive text-xs uppercase"> <label className="font-medium text-destructive text-xs uppercase">
+2 -1
View File
@@ -4,7 +4,7 @@
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "test ! -f .env.local || (echo 'Use root .env only; .env.local is forbidden for this app.' && exit 1); set -a && . ../../.env 2>/dev/null; set +a; next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "biome lint", "lint": "biome lint",
@@ -37,6 +37,7 @@
"@vercel/analytics": "^1.6.1", "@vercel/analytics": "^1.6.1",
"@vercel/speed-insights": "^1.3.1", "@vercel/speed-insights": "^1.3.1",
"@vercel/toolbar": "^0.2.2", "@vercel/toolbar": "^0.2.2",
"@visual-json/react": "latest",
"better-auth": "^1.4.18", "better-auth": "^1.4.18",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
+5
View File
@@ -50,6 +50,7 @@
"@vercel/analytics": "^1.6.1", "@vercel/analytics": "^1.6.1",
"@vercel/speed-insights": "^1.3.1", "@vercel/speed-insights": "^1.3.1",
"@vercel/toolbar": "^0.2.2", "@vercel/toolbar": "^0.2.2",
"@visual-json/react": "latest",
"better-auth": "^1.4.18", "better-auth": "^1.4.18",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -685,6 +686,10 @@
"@vercel/toolbar": ["@vercel/toolbar@0.2.2", "", { "dependencies": { "@tinyhttp/app": "1.3.0", "@vercel/microfrontends": "2.0.1", "chokidar": "^3.5.3", "execa": "5.1.1", "fast-glob": "^3.3.2", "find-up": "5.0.0", "get-port": "5.1.1", "jsonc-parser": "^3.3.1", "strip-ansi": "6.0.1" }, "peerDependencies": { "next": ">=11.0.0", "nuxt": ">=3.0.0", "react": ">=17", "vite": ">=5" }, "optionalPeers": ["next", "nuxt", "react", "vite"] }, "sha512-ygI0VD1mBejSOp3pNeb3jqeO3bSzLn6CrjEnVtyjEZePx5ygWEyoQUAZWFH85njtfKt15Nbmp0cNjye+Xm6RbA=="], "@vercel/toolbar": ["@vercel/toolbar@0.2.2", "", { "dependencies": { "@tinyhttp/app": "1.3.0", "@vercel/microfrontends": "2.0.1", "chokidar": "^3.5.3", "execa": "5.1.1", "fast-glob": "^3.3.2", "find-up": "5.0.0", "get-port": "5.1.1", "jsonc-parser": "^3.3.1", "strip-ansi": "6.0.1" }, "peerDependencies": { "next": ">=11.0.0", "nuxt": ">=3.0.0", "react": ">=17", "vite": ">=5" }, "optionalPeers": ["next", "nuxt", "react", "vite"] }, "sha512-ygI0VD1mBejSOp3pNeb3jqeO3bSzLn6CrjEnVtyjEZePx5ygWEyoQUAZWFH85njtfKt15Nbmp0cNjye+Xm6RbA=="],
"@visual-json/core": ["@visual-json/core@0.1.1", "", {}, "sha512-VLARpQnjJU1SnnYk1NiXScGfp2FyAwyZS2kA8hIJV5OzkaH2oS1dTHsaB8vOiyi14/L7jsbpA4g/EwzRWbxQmg=="],
"@visual-json/react": ["@visual-json/react@0.1.1", "", { "dependencies": { "@visual-json/core": "0.1.1" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0" } }, "sha512-KCpFYmBhH5F+xctfvxxVX7mVZK/iwvDwh/ITNkGPzEKXTIJQ16fau+hrG7m7QoTAOxvsdhHtHoCUiEjNHXboUQ=="],
"@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="], "@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="],
"@zappar/msdf-generator": ["@zappar/msdf-generator@1.2.4", "", { "dependencies": { "comlink": "^4.4.2" } }, "sha512-6S/MCk0Ky0ipewZJw4xFEzH/2aYfWmPXEkTdBtNyDDfkbicrNwgJgtxZ4SnTDyNe9XHMqDA4sL9srRsgDLRMqA=="], "@zappar/msdf-generator": ["@zappar/msdf-generator@1.2.4", "", { "dependencies": { "comlink": "^4.4.2" } }, "sha512-6S/MCk0Ky0ipewZJw4xFEzH/2aYfWmPXEkTdBtNyDDfkbicrNwgJgtxZ4SnTDyNe9XHMqDA4sL9srRsgDLRMqA=="],
+3
View File
@@ -155,6 +155,9 @@ const useScene: UseSceneStore = create<SceneState>()(
{ {
name: 'editor-storage', name: 'editor-storage',
version: 1, version: 1,
// Keep existing local scenes when the persist version changes.
migrate: (persistedState) =>
persistedState as Pick<SceneState, 'nodes' | 'rootNodeIds'>,
partialize: (state) => ({ partialize: (state) => ({
nodes: Object.fromEntries( nodes: Object.fromEntries(
Object.entries(state.nodes).filter(([_, node]) => { Object.entries(state.nodes).filter(([_, node]) => {