data persist

This commit is contained in:
wass08
2026-01-23 09:56:06 +09:00
parent 2cee38e295
commit cc9d9916b7
4 changed files with 176 additions and 171 deletions
@@ -27,6 +27,7 @@ import {
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import useEditor from "@/store/use-editor"; import useEditor from "@/store/use-editor";
import { useViewer } from "@pascal-app/viewer"; import { useViewer } from "@pascal-app/viewer";
import { SettingsPanel } from "./panels/settings-panel";
import { SitePanel } from "./panels/site-panel"; import { SitePanel } from "./panels/site-panel";
import { ZonePanel } from "./panels/zone-panel"; import { ZonePanel } from "./panels/zone-panel";
@@ -83,13 +84,8 @@ export function AppSidebar() {
return <ZonePanel />; return <ZonePanel />;
// case "collections": // case "collections":
// return <CollectionsPanel />; // return <CollectionsPanel />;
// case "settings": case "settings":
// return ( return <SettingsPanel />;
// <SettingsPanel
// onOpenHelp={() => setIsHelpOpen(true)}
// onOpenJsonInspector={() => setIsJsonInspectorOpen(true)}
// />
// );
default: default:
return null; return null;
} }
@@ -0,0 +1,34 @@
import { useScene } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer";
import { Trash2 } from "lucide-react";
import { Button } from "@/components/ui/primitives/button";
export function SettingsPanel() {
const clearScene = useScene((state) => state.clearScene);
const resetSelection = useViewer((state) => state.resetSelection);
const handleResetToDefault = () => {
clearScene();
resetSelection();
};
return (
<div className="flex flex-col gap-6 p-3">
{/* Danger Zone */}
<div className="space-y-2">
<label className="font-medium text-destructive text-xs uppercase">
Danger Zone
</label>
<Button
className="w-full justify-start gap-2"
onClick={handleResetToDefault}
variant="destructive"
>
<Trash2 className="size-4" />
Clear & Start New
</Button>
</div>
</div>
);
}
+138 -164
View File
@@ -1,213 +1,187 @@
"use client"; 'use client'
import { temporal } from "zundo"; import { temporal } from 'zundo'
import { create } from "zustand"; import { create } from 'zustand'
import { BuildingNode, ItemNode, type Zone } from "../schema"; import { persist } from 'zustand/middleware'
import { LevelNode } from "../schema/nodes/level"; import { BuildingNode, type Zone } from '../schema'
import { WallNode } from "../schema/nodes/wall"; import { LevelNode } from '../schema/nodes/level'
import type { AnyNode, AnyNodeId } from "../schema/types"; import type { AnyNode, AnyNodeId } from '../schema/types'
import * as nodeActions from "./actions/node-actions"; import * as nodeActions from './actions/node-actions'
import * as zoneActions from "./actions/zone-actions"; import * as zoneActions from './actions/zone-actions'
export type SceneState = { export type SceneState = {
// 1. The Data: A flat dictionary of all nodes // 1. The Data: A flat dictionary of all nodes
nodes: Record<AnyNodeId, AnyNode>; nodes: Record<AnyNodeId, AnyNode>
zones: Record<Zone["id"], Zone>; zones: Record<Zone['id'], Zone>
// 2. The Root: Which nodes are at the top level? // 2. The Root: Which nodes are at the top level?
rootNodeIds: AnyNodeId[]; rootNodeIds: AnyNodeId[]
zoneIds: Zone["id"][]; zoneIds: Zone['id'][]
// 3. The "Dirty" Set: For the Wall/Physics systems // 3. The "Dirty" Set: For the Wall/Physics systems
dirtyNodes: Set<AnyNodeId>; dirtyNodes: Set<AnyNodeId>
// Actions // Actions
loadScene: () => void; loadScene: () => void
markDirty: (id: AnyNodeId) => void; clearScene: () => void
clearDirty: (id: AnyNodeId) => void;
createNode: (node: AnyNode, parentId?: AnyNodeId) => void; markDirty: (id: AnyNodeId) => void
createNodes: (ops: { node: AnyNode; parentId?: AnyNodeId }[]) => void; clearDirty: (id: AnyNodeId) => void
updateNode: (id: AnyNodeId, data: Partial<AnyNode>) => void; createNode: (node: AnyNode, parentId?: AnyNodeId) => void
updateNodes: (updates: { id: AnyNodeId; data: Partial<AnyNode> }[]) => void; createNodes: (ops: { node: AnyNode; parentId?: AnyNodeId }[]) => void
deleteNode: (id: AnyNodeId) => void; updateNode: (id: AnyNodeId, data: Partial<AnyNode>) => void
deleteNodes: (ids: AnyNodeId[]) => void; updateNodes: (updates: { id: AnyNodeId; data: Partial<AnyNode> }[]) => void
deleteNode: (id: AnyNodeId) => void
deleteNodes: (ids: AnyNodeId[]) => void
// Zone actions // Zone actions
createZone: (zone: Zone) => void; createZone: (zone: Zone) => void
createZones: (zones: Zone[]) => void; createZones: (zones: Zone[]) => void
updateZone: (id: Zone["id"], data: Partial<Zone>) => void; updateZone: (id: Zone['id'], data: Partial<Zone>) => void
updateZones: (updates: { id: Zone["id"]; data: Partial<Zone> }[]) => void; updateZones: (updates: { id: Zone['id']; data: Partial<Zone> }[]) => void
deleteZone: (id: Zone["id"]) => void; deleteZone: (id: Zone['id']) => void
deleteZones: (ids: Zone["id"][]) => void; deleteZones: (ids: Zone['id'][]) => void
}; }
// type PartializedStoreState = Pick<SceneState, 'rootNodeIds' | 'nodes'>; // type PartializedStoreState = Pick<SceneState, 'rootNodeIds' | 'nodes'>;
const useScene = create<SceneState>()( const useScene = create<SceneState>()(
temporal( persist(
(set, get) => ({ temporal(
// 1. Flat dictionary of all nodes (set, get) => ({
nodes: {}, // 1. Flat dictionary of all nodes
zones: {}, nodes: {},
zones: {},
// 2. Root node IDs // 2. Root node IDs
rootNodeIds: [], rootNodeIds: [],
zoneIds: [], zoneIds: [],
// 3. Dirty set // 3. Dirty set
dirtyNodes: new Set<AnyNodeId>(), dirtyNodes: new Set<AnyNodeId>(),
loadScene: () => { clearScene: () => {
const building = BuildingNode.parse({ set({
children: [], nodes: {},
}); rootNodeIds: [],
zones: {},
zoneIds: [],
dirtyNodes: new Set<AnyNodeId>(),
})
get().loadScene() // Default scene
},
const level0 = LevelNode.parse({ loadScene: () => {
level: 0, if (get().rootNodeIds.length > 0) {
children: [], // Assign all nodes as dirty to force re-validation
}); Object.values(get().nodes).forEach((node) => {
const level1 = LevelNode.parse({ get().markDirty(node.id)
level: 1, })
children: [], return // Scene already loaded
}); }
const level2 = LevelNode.parse({
level: 2,
children: [],
});
const wall0 = WallNode.parse({ const building = BuildingNode.parse({
start: [0, 0], children: [],
end: [5, 0], })
children: [],
parentId: level0.id,
});
const wall1 = WallNode.parse({ const level0 = LevelNode.parse({
start: [0, 0], level: 0,
end: [0, 5], children: [],
children: [], })
parentId: level0.id,
});
const wall2 = WallNode.parse({ building.children.push(level0.id)
start: [5, 5],
end: [0, 5],
children: [],
parentId: level0.id,
});
const wall3 = WallNode.parse({ // Define all nodes flat
start: [5, 5], const nodes: Record<AnyNodeId, AnyNode> = {
end: [5, 0], [building.id]: building,
children: [], [level0.id]: level0,
parentId: level1.id, }
});
const window1 = ItemNode.parse({ // Root nodes are the levels
type: "item", const rootNodeIds = [building.id]
name: "Window",
position: [2.5, 0.5, 0],
parentId: wall3.id,
asset: {
id: "window-round",
name: "Round Window",
thumbnail: "/items/window-small/thumbnail.png",
category: "windows",
attachTo: "wall",
src: "/items/window-small/model.glb",
},
});
wall3.children.push(window1.id); set({ nodes, rootNodeIds })
},
level0.children.push(wall0.id, wall1.id, wall2.id); markDirty: (id) => {
level1.children.push(wall3.id); get().dirtyNodes.add(id)
},
building.children.push(level0.id, level1.id, level2.id); clearDirty: (id) => {
get().dirtyNodes.delete(id)
},
// Define all nodes flat createNodes: (ops) => nodeActions.createNodesAction(set, get, ops),
const nodes: Record<AnyNodeId, AnyNode> = { createNode: (node, parentId) =>
[building.id]: building, nodeActions.createNodesAction(set, get, [{ node, parentId }]),
[level0.id]: level0,
[level1.id]: level1,
[level2.id]: level2,
[wall0.id]: wall0,
[wall1.id]: wall1,
[wall2.id]: wall2,
[wall3.id]: wall3,
[window1.id]: window1,
};
// Root nodes are the levels updateNodes: (updates) => nodeActions.updateNodesAction(set, get, updates),
const rootNodeIds = [building.id]; updateNode: (id, data) => nodeActions.updateNodesAction(set, get, [{ id, data }]),
get().dirtyNodes.add(wall0.id); // --- DELETE ---
get().dirtyNodes.add(wall1.id);
get().dirtyNodes.add(wall2.id); deleteNodes: (ids) => nodeActions.deleteNodesAction(set, get, ids),
get().dirtyNodes.add(wall3.id);
set({ nodes, rootNodeIds }); deleteNode: (id) => nodeActions.deleteNodesAction(set, get, [id]),
// --- ZONES ---
createZones: (zones) => zoneActions.createZonesAction(set, get, zones),
createZone: (zone) => zoneActions.createZonesAction(set, get, [zone]),
updateZones: (updates) => zoneActions.updateZonesAction(set, get, updates),
updateZone: (id, data) => zoneActions.updateZonesAction(set, get, [{ id, data }]),
deleteZones: (ids) => zoneActions.deleteZonesAction(set, get, ids),
deleteZone: (id) => zoneActions.deleteZonesAction(set, get, [id]),
}),
{
partialize: (state) => {
const { nodes, rootNodeIds } = state // Only track nodes and rootNodeIds in history
return { nodes, rootNodeIds }
},
limit: 50, // Limit to last 50 actions
}, },
),
markDirty: (id) => {
get().dirtyNodes.add(id);
},
clearDirty: (id) => {
get().dirtyNodes.delete(id);
},
createNodes: (ops) => nodeActions.createNodesAction(set, get, ops),
createNode: (node, parentId) =>
nodeActions.createNodesAction(set, get, [{ node, parentId }]),
updateNodes: (updates) =>
nodeActions.updateNodesAction(set, get, updates),
updateNode: (id, data) =>
nodeActions.updateNodesAction(set, get, [{ id, data }]),
// --- DELETE ---
deleteNodes: (ids) => nodeActions.deleteNodesAction(set, get, ids),
deleteNode: (id) => nodeActions.deleteNodesAction(set, get, [id]),
// --- ZONES ---
createZones: (zones) => zoneActions.createZonesAction(set, get, zones),
createZone: (zone) => zoneActions.createZonesAction(set, get, [zone]),
updateZones: (updates) => zoneActions.updateZonesAction(set, get, updates),
updateZone: (id, data) =>
zoneActions.updateZonesAction(set, get, [{ id, data }]),
deleteZones: (ids) => zoneActions.deleteZonesAction(set, get, ids),
deleteZone: (id) => zoneActions.deleteZonesAction(set, get, [id]),
}),
{ {
partialize: (state) => { name: 'editor-storage',
const { nodes, rootNodeIds } = state; // Only track nodes and rootNodeIds in history partialize: (state) => ({
return { nodes, rootNodeIds }; nodes: state.nodes,
rootNodeIds: state.rootNodeIds,
zones: state.zones,
zoneIds: state.zoneIds,
}),
onRehydrateStorage: (state) => {
console.log('hydration starts')
// optional
return (state, error) => {
if (error) {
console.log('an error happened during hydration', error)
} else {
console.log('hydration finished')
}
}
}, },
limit: 50, // Limit to last 50 actions
}, },
), ),
); )
export default useScene; export default useScene
// Subscribe to the temporal store (Undo/Redo events) // Subscribe to the temporal store (Undo/Redo events)
useScene.temporal.subscribe((state, prevState) => { useScene.temporal.subscribe((state, prevState) => {
// Check if we just jumped in time (Undo/Redo) // Check if we just jumped in time (Undo/Redo)
// If the 'nodes' object changed but it wasn't a normal 'set' // If the 'nodes' object changed but it wasn't a normal 'set'
const currentNodes = useScene.getState().nodes; const currentNodes = useScene.getState().nodes
// Trigger a full scene re-validation // Trigger a full scene re-validation
Object.values(currentNodes).forEach((node) => { Object.values(currentNodes).forEach((node) => {
if (node.type === "wall") { if (node.type === 'wall') {
useScene.getState().markDirty(node.id); useScene.getState().markDirty(node.id)
} }
}); })
}); })
@@ -39,6 +39,7 @@ const ModelRenderer = ({ node }: { node: ItemNode }) => {
if ((child as Mesh).isMesh) { if ((child as Mesh).isMesh) {
child.castShadow = true child.castShadow = true
child.receiveShadow = true child.receiveShadow = true
// TODO: do it with a shared material
child.material = new MeshStandardNodeMaterial({ child.material = new MeshStandardNodeMaterial({
color: 0xffffff, color: 0xffffff,
roughness: 0.8, roughness: 0.8,