From db05c86120e5dfc28b6585c6b5886261aa81c3da Mon Sep 17 00:00:00 2001 From: Aymeric Rabot Date: Tue, 24 Feb 2026 00:40:18 -0500 Subject: [PATCH] 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. --- .../sidebar/panels/settings-panel/index.tsx | 194 +++++++++++++++++- apps/editor/package.json | 3 +- bun.lock | 5 + packages/core/src/store/use-scene.ts | 3 + 4 files changed, 203 insertions(+), 2 deletions(-) diff --git a/apps/editor/components/ui/sidebar/panels/settings-panel/index.tsx b/apps/editor/components/ui/sidebar/panels/settings-panel/index.tsx index 025d1458..70b620ec 100644 --- a/apps/editor/components/ui/sidebar/panels/settings-panel/index.tsx +++ b/apps/editor/components/ui/sidebar/panels/settings-panel/index.tsx @@ -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 & { + 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(); + + 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, + rootNodeIds: string[], +): SceneGraphValue => { + const childIdsByParent = new Map>(); + + 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(); + siblings.add(id); + childIdsByParent.set(node.parentId, siblings); + } + + const visited = new Set(); + + const buildNode = (id: string, path: Set): 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(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, rootNodeIds), + [nodes, rootNodeIds], + ); + const blockSceneGraphMutations = useCallback((event: SyntheticEvent) => { + event.preventDefault(); + event.stopPropagation(); + }, []); + const blockSceneGraphDeletion = useCallback( + (event: KeyboardEvent) => { + 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() { + {/* Scene Graph */} +
+ + + + + + + Scene Graph +
+ + + +
+
+
+
+ {/* Danger Zone */}