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 { useViewer } from "@pascal-app/viewer";
|
||||
import { VisualJson, TreeView } from "@visual-json/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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/primitives/dialog";
|
||||
import { Switch } from "@/components/ui/primitives/switch";
|
||||
import useEditor from "@/store/use-editor";
|
||||
import { AudioSettingsDialog } from "./audio-settings-dialog";
|
||||
import { useProjectStore } from "@/features/community/lib/projects/store";
|
||||
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() {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const nodes = useScene((state) => state.nodes);
|
||||
@@ -20,6 +167,23 @@ export function SettingsPanel() {
|
||||
const setPhase = useEditor((state) => state.setPhase);
|
||||
const activeProject = useProjectStore((state) => state.activeProject);
|
||||
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 isLocalProject = false; // Store only contains cloud projects
|
||||
@@ -231,6 +395,34 @@ export function SettingsPanel() {
|
||||
<AudioSettingsDialog />
|
||||
</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 */}
|
||||
<div className="space-y-2">
|
||||
<label className="font-medium text-destructive text-xs uppercase">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"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",
|
||||
"start": "next start",
|
||||
"lint": "biome lint",
|
||||
@@ -37,6 +37,7 @@
|
||||
"@vercel/analytics": "^1.6.1",
|
||||
"@vercel/speed-insights": "^1.3.1",
|
||||
"@vercel/toolbar": "^0.2.2",
|
||||
"@visual-json/react": "latest",
|
||||
"better-auth": "^1.4.18",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"@vercel/analytics": "^1.6.1",
|
||||
"@vercel/speed-insights": "^1.3.1",
|
||||
"@vercel/toolbar": "^0.2.2",
|
||||
"@visual-json/react": "latest",
|
||||
"better-auth": "^1.4.18",
|
||||
"class-variance-authority": "^0.7.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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@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',
|
||||
version: 1,
|
||||
// Keep existing local scenes when the persist version changes.
|
||||
migrate: (persistedState) =>
|
||||
persistedState as Pick<SceneState, 'nodes' | 'rootNodeIds'>,
|
||||
partialize: (state) => ({
|
||||
nodes: Object.fromEntries(
|
||||
Object.entries(state.nodes).filter(([_, node]) => {
|
||||
|
||||
Reference in New Issue
Block a user