export / load json

This commit is contained in:
wass08
2026-01-30 07:48:52 +09:00
parent edb2b181c2
commit 498da300db
6 changed files with 180 additions and 4 deletions
@@ -0,0 +1,54 @@
'use client'
import { useViewer } from '@pascal-app/viewer'
import { useThree } from '@react-three/fiber'
import { useEffect } from 'react'
import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter.js'
export function ExportManager() {
const scene = useThree((state) => state.scene)
const setExportScene = useViewer((state) => state.setExportScene)
useEffect(() => {
const exportFn = async () => {
// Find the scene renderer group by name
const sceneGroup = scene.getObjectByName('scene-renderer')
if (!sceneGroup) {
console.error('scene-renderer group not found')
return
}
const exporter = new GLTFExporter()
const date = new Date().toISOString().split('T')[0]
return new Promise<void>((resolve, reject) => {
exporter.parse(
sceneGroup,
(gltf) => {
const blob = new Blob([gltf as ArrayBuffer], { type: 'model/gltf-binary' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = `model_${date}.glb`
link.click()
URL.revokeObjectURL(url)
resolve()
},
(error) => {
console.error('Export error:', error)
reject(error)
},
{ binary: true }
)
})
}
setExportScene(exportFn)
return () => {
setExportScene(null)
}
}, [scene, setExportScene])
return null
}
+2
View File
@@ -18,6 +18,7 @@ import { RoofPanel } from '../ui/panels/roof-panel'
import { SidebarProvider } from '../ui/primitives/sidebar'
import { AppSidebar } from '../ui/sidebar/app-sidebar'
import { CustomCameraControls } from './custom-camera-controls'
import { ExportManager } from './export-manager'
import { SelectionManager } from './selection-manager'
useScene.getState().loadScene()
@@ -38,6 +39,7 @@ export default function Editor() {
</SidebarProvider>
<Viewer>
<SelectionManager />
<ExportManager />
{/* Editor only system to toggle zone visibility */}
<ZoneSystem />
{/* <Stats /> */}
@@ -1,14 +1,62 @@
import { useScene } from "@pascal-app/core";
import { useViewer } from "@pascal-app/viewer";
import { Trash2 } from "lucide-react";
import { Download, Save, Trash2, Upload } from "lucide-react";
import { useRef } from "react";
import { Button } from "@/components/ui/primitives/button";
import useEditor from "@/store/use-editor";
export function SettingsPanel() {
const fileInputRef = useRef<HTMLInputElement>(null);
const nodes = useScene((state) => state.nodes);
const rootNodeIds = useScene((state) => state.rootNodeIds);
const setScene = useScene((state) => state.setScene);
const clearScene = useScene((state) => state.clearScene);
const resetSelection = useViewer((state) => state.resetSelection);
const exportScene = useViewer((state) => state.exportScene);
const setPhase = useEditor((state) => state.setPhase);
const handleExport = async () => {
if (exportScene) {
await exportScene();
}
};
const handleSaveBuild = () => {
const sceneData = { nodes, rootNodeIds };
const json = JSON.stringify(sceneData, null, 2);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
const date = new Date().toISOString().split("T")[0];
link.download = `layout_${date}.json`;
link.click();
URL.revokeObjectURL(url);
};
const handleFileLoad = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
try {
const data = JSON.parse(event.target?.result as string);
if (data.nodes && data.rootNodeIds) {
setScene(data.nodes, data.rootNodeIds);
resetSelection();
setPhase("site");
}
} catch (err) {
console.error("Failed to load build:", err);
}
};
reader.readAsText(file);
// Reset input so the same file can be loaded again
e.target.value = "";
};
const handleResetToDefault = () => {
clearScene();
resetSelection();
@@ -17,6 +65,54 @@ export function SettingsPanel() {
return (
<div className="flex flex-col gap-6 p-3">
{/* Export Section */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase">
Export
</label>
<Button
className="w-full justify-start gap-2"
onClick={handleExport}
variant="outline"
>
<Download className="size-4" />
Export 3D Model
</Button>
</div>
{/* Save/Load Section */}
<div className="space-y-2">
<label className="font-medium text-muted-foreground text-xs uppercase">
Save & Load
</label>
<Button
className="w-full justify-start gap-2"
onClick={handleSaveBuild}
variant="outline"
>
<Save className="size-4" />
Save Build
</Button>
<Button
className="w-full justify-start gap-2"
onClick={() => fileInputRef.current?.click()}
variant="outline"
>
<Upload className="size-4" />
Load Build
</Button>
<input
accept="application/json"
className="hidden"
onChange={handleFileLoad}
ref={fileInputRef}
type="file"
/>
</div>
{/* Danger Zone */}
<div className="space-y-2">
<label className="font-medium text-destructive text-xs uppercase">
+13
View File
@@ -22,6 +22,7 @@ export type SceneState = {
// Actions
loadScene: () => void
clearScene: () => void
setScene: (nodes: Record<AnyNodeId, AnyNode>, rootNodeIds: AnyNodeId[]) => void
markDirty: (id: AnyNodeId) => void
clearDirty: (id: AnyNodeId) => void
@@ -60,6 +61,18 @@ const useScene = create<SceneState>()(
get().loadScene() // Default scene
},
setScene: (nodes, rootNodeIds) => {
set({
nodes,
rootNodeIds,
dirtyNodes: new Set<AnyNodeId>(),
})
// Mark all nodes as dirty to trigger re-validation
Object.values(nodes).forEach((node) => {
get().markDirty(node.id)
})
},
loadScene: () => {
if (get().rootNodeIds.length > 0) {
// Assign all nodes as dirty to force re-validation
@@ -6,7 +6,11 @@ import { NodeRenderer } from "./node-renderer";
export const SceneRenderer = () => {
const rootNodes = useScene((state) => state.rootNodeIds);
return rootNodes.map((nodeId) => (
return (
<group name="scene-renderer">
{rootNodes.map((nodeId) => (
<NodeRenderer key={nodeId} nodeId={nodeId} />
));
))}
</group>
);
};
+7
View File
@@ -45,6 +45,10 @@ type ViewerState = {
resetSelection: () => void;
outliner: Outliner; // No setter as we will manipulate directly the arrays
// Export functionality
exportScene: (() => Promise<void>) | null;
setExportScene: (fn: (() => Promise<void>) | null) => void;
};
const useViewer = create<ViewerState>()((set, get) => ({
@@ -94,6 +98,9 @@ const useViewer = create<ViewerState>()((set, get) => ({
}),
outliner: { selectedObjects: [], hoveredObjects: [] },
exportScene: null,
setExportScene: (fn) => set({ exportScene: fn }),
}));
export default useViewer;