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:
@@ -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">
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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=="],
|
||||||
|
|||||||
@@ -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]) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user