export / load json
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import { RoofPanel } from '../ui/panels/roof-panel'
|
|||||||
import { SidebarProvider } from '../ui/primitives/sidebar'
|
import { SidebarProvider } from '../ui/primitives/sidebar'
|
||||||
import { AppSidebar } from '../ui/sidebar/app-sidebar'
|
import { AppSidebar } from '../ui/sidebar/app-sidebar'
|
||||||
import { CustomCameraControls } from './custom-camera-controls'
|
import { CustomCameraControls } from './custom-camera-controls'
|
||||||
|
import { ExportManager } from './export-manager'
|
||||||
import { SelectionManager } from './selection-manager'
|
import { SelectionManager } from './selection-manager'
|
||||||
|
|
||||||
useScene.getState().loadScene()
|
useScene.getState().loadScene()
|
||||||
@@ -38,6 +39,7 @@ export default function Editor() {
|
|||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
<Viewer>
|
<Viewer>
|
||||||
<SelectionManager />
|
<SelectionManager />
|
||||||
|
<ExportManager />
|
||||||
{/* Editor only system to toggle zone visibility */}
|
{/* Editor only system to toggle zone visibility */}
|
||||||
<ZoneSystem />
|
<ZoneSystem />
|
||||||
{/* <Stats /> */}
|
{/* <Stats /> */}
|
||||||
|
|||||||
@@ -1,14 +1,62 @@
|
|||||||
import { useScene } from "@pascal-app/core";
|
import { useScene } from "@pascal-app/core";
|
||||||
import { useViewer } from "@pascal-app/viewer";
|
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 { Button } from "@/components/ui/primitives/button";
|
||||||
import useEditor from "@/store/use-editor";
|
import useEditor from "@/store/use-editor";
|
||||||
|
|
||||||
export function SettingsPanel() {
|
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 clearScene = useScene((state) => state.clearScene);
|
||||||
const resetSelection = useViewer((state) => state.resetSelection);
|
const resetSelection = useViewer((state) => state.resetSelection);
|
||||||
|
const exportScene = useViewer((state) => state.exportScene);
|
||||||
const setPhase = useEditor((state) => state.setPhase);
|
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 = () => {
|
const handleResetToDefault = () => {
|
||||||
clearScene();
|
clearScene();
|
||||||
resetSelection();
|
resetSelection();
|
||||||
@@ -17,6 +65,54 @@ export function SettingsPanel() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 p-3">
|
<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 */}
|
{/* 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">
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ export type SceneState = {
|
|||||||
// Actions
|
// Actions
|
||||||
loadScene: () => void
|
loadScene: () => void
|
||||||
clearScene: () => void
|
clearScene: () => void
|
||||||
|
setScene: (nodes: Record<AnyNodeId, AnyNode>, rootNodeIds: AnyNodeId[]) => void
|
||||||
|
|
||||||
markDirty: (id: AnyNodeId) => void
|
markDirty: (id: AnyNodeId) => void
|
||||||
clearDirty: (id: AnyNodeId) => void
|
clearDirty: (id: AnyNodeId) => void
|
||||||
@@ -60,6 +61,18 @@ const useScene = create<SceneState>()(
|
|||||||
get().loadScene() // Default scene
|
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: () => {
|
loadScene: () => {
|
||||||
if (get().rootNodeIds.length > 0) {
|
if (get().rootNodeIds.length > 0) {
|
||||||
// Assign all nodes as dirty to force re-validation
|
// Assign all nodes as dirty to force re-validation
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ import { NodeRenderer } from "./node-renderer";
|
|||||||
export const SceneRenderer = () => {
|
export const SceneRenderer = () => {
|
||||||
const rootNodes = useScene((state) => state.rootNodeIds);
|
const rootNodes = useScene((state) => state.rootNodeIds);
|
||||||
|
|
||||||
return rootNodes.map((nodeId) => (
|
return (
|
||||||
<NodeRenderer key={nodeId} nodeId={nodeId} />
|
<group name="scene-renderer">
|
||||||
));
|
{rootNodes.map((nodeId) => (
|
||||||
|
<NodeRenderer key={nodeId} nodeId={nodeId} />
|
||||||
|
))}
|
||||||
|
</group>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ type ViewerState = {
|
|||||||
resetSelection: () => void;
|
resetSelection: () => void;
|
||||||
|
|
||||||
outliner: Outliner; // No setter as we will manipulate directly the arrays
|
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) => ({
|
const useViewer = create<ViewerState>()((set, get) => ({
|
||||||
@@ -94,6 +98,9 @@ const useViewer = create<ViewerState>()((set, get) => ({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
outliner: { selectedObjects: [], hoveredObjects: [] },
|
outliner: { selectedObjects: [], hoveredObjects: [] },
|
||||||
|
|
||||||
|
exportScene: null,
|
||||||
|
setExportScene: (fn) => set({ exportScene: fn }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export default useViewer;
|
export default useViewer;
|
||||||
|
|||||||
Reference in New Issue
Block a user