feat: switch to cloud-only .titanscene saving and loading
This commit is contained in:
+200
-17
@@ -189,7 +189,8 @@ interface SceneObject {
|
|||||||
const SceneSetup = () => {
|
const SceneSetup = () => {
|
||||||
const { gl } = useThree();
|
const { gl } = useThree();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
gl.shadowMap.type = THREE.PCFSoftShadowMap;
|
gl.shadowMap.enabled = true;
|
||||||
|
gl.shadowMap.type = THREE.PCFShadowMap;
|
||||||
gl.localClippingEnabled = true;
|
gl.localClippingEnabled = true;
|
||||||
}, [gl]);
|
}, [gl]);
|
||||||
return null;
|
return null;
|
||||||
@@ -314,7 +315,29 @@ export default function Editor() {
|
|||||||
try {
|
try {
|
||||||
const data = await api.getProject(projectId);
|
const data = await api.getProject(projectId);
|
||||||
if (data.project && data.project.scene_data) {
|
if (data.project && data.project.scene_data) {
|
||||||
const sd = data.project.scene_data;
|
let sd = data.project.scene_data;
|
||||||
|
|
||||||
|
if (sd.titansceneUrl) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(sd.titansceneUrl);
|
||||||
|
const buffer = await res.arrayBuffer();
|
||||||
|
const unzipped = fflate.unzipSync(new Uint8Array(buffer));
|
||||||
|
if (unzipped['scene.json']) {
|
||||||
|
const textDecoder = new TextDecoder();
|
||||||
|
const jsonString = textDecoder.decode(unzipped['scene.json']);
|
||||||
|
const parsedSd = JSON.parse(jsonString);
|
||||||
|
|
||||||
|
if (parsedSd.modelData && parsedSd.modelData.data === 'model.glb' && unzipped['model.glb']) {
|
||||||
|
const modelBlob = new Blob([unzipped['model.glb']]);
|
||||||
|
parsedSd.modelData.data = URL.createObjectURL(modelBlob);
|
||||||
|
}
|
||||||
|
sd = parsedSd;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load .titanscene from cloud", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (sd.sceneObjects) setSceneObjects(sd.sceneObjects);
|
if (sd.sceneObjects) setSceneObjects(sd.sceneObjects);
|
||||||
if (sd.lightProps) setLightProps(sd.lightProps);
|
if (sd.lightProps) setLightProps(sd.lightProps);
|
||||||
if (sd.groundProps) setGroundProps(sd.groundProps);
|
if (sd.groundProps) setGroundProps(sd.groundProps);
|
||||||
@@ -348,8 +371,9 @@ export default function Editor() {
|
|||||||
reImportModel(sd.modelData);
|
reImportModel(sd.modelData);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch project", err);
|
console.error("Failed to fetch project", error);
|
||||||
|
navigate('/dashboard', { replace: true });
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -481,10 +505,10 @@ export default function Editor() {
|
|||||||
const [vizAiBaseImage, setVizAiBaseImage] = useState<string | null>(null);
|
const [vizAiBaseImage, setVizAiBaseImage] = useState<string | null>(null);
|
||||||
const [renderRequest, setRenderRequest] = useState<RenderSettings | null>(null);
|
const [renderRequest, setRenderRequest] = useState<RenderSettings | null>(null);
|
||||||
const [exportRequest, setExportRequest] = useState(false);
|
const [exportRequest, setExportRequest] = useState(false);
|
||||||
const [exportFormat, setExportFormat] = useState<'glb' | 'usdz'>('glb');
|
const [exportFormat, setExportFormat] = useState<'glb' | 'usdz' | 'titanscene'>('glb');
|
||||||
// Export / Save dialog
|
// Export / Save dialog
|
||||||
const [exportDialog, setExportDialog] = useState<{
|
const [exportDialog, setExportDialog] = useState<{
|
||||||
mode: 'scene' | 'glb' | 'usdz';
|
mode: 'scene' | 'glb' | 'usdz' | 'titanscene';
|
||||||
defaultName: string;
|
defaultName: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [exportDialogName, setExportDialogName] = useState('');
|
const [exportDialogName, setExportDialogName] = useState('');
|
||||||
@@ -657,33 +681,123 @@ export default function Editor() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const saveScene = async (name?: string) => {
|
const saveScene = async (name?: string) => {
|
||||||
if (!user) return;
|
|
||||||
|
|
||||||
const sceneData = {
|
const sceneData = {
|
||||||
sceneObjects, lightProps, groundProps, cameraProps,
|
sceneObjects, lightProps, groundProps, cameraProps,
|
||||||
envPreset, envRotation, backgroundColor, backgroundType,
|
envPreset, envRotation, backgroundColor, backgroundType,
|
||||||
customHdri, backplateImage, additionalLights, modelBounds, modelData, cameraPresets
|
customHdri, backplateImage, additionalLights, modelBounds, cameraPresets,
|
||||||
|
modelData: modelData ? { ...modelData } : null
|
||||||
};
|
};
|
||||||
|
|
||||||
const projectName = name || exportDialogName || 'Untitled Scene';
|
const projectName = name || exportDialogName || 'Untitled Scene';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsRendering(true); // Re-use spinner state for saving
|
setIsRendering(true);
|
||||||
|
const zipObj: Record<string, Uint8Array> = {};
|
||||||
|
|
||||||
|
if (sceneData.modelData && sceneData.modelData.data && typeof sceneData.modelData.data === 'string') {
|
||||||
|
if (sceneData.modelData.data.startsWith('data:')) {
|
||||||
|
const dataUrl = sceneData.modelData.data;
|
||||||
|
const base64 = dataUrl.split(',')[1];
|
||||||
|
const binaryStr = atob(base64);
|
||||||
|
const len = binaryStr.length;
|
||||||
|
const bytes = new Uint8Array(len);
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
bytes[i] = binaryStr.charCodeAt(i);
|
||||||
|
}
|
||||||
|
zipObj['model.glb'] = bytes;
|
||||||
|
sceneData.modelData.data = 'model.glb';
|
||||||
|
} else if (sceneData.modelData.data.startsWith('blob:') || sceneData.modelData.data.startsWith('http')) {
|
||||||
|
const res = await fetch(sceneData.modelData.data);
|
||||||
|
const buf = await res.arrayBuffer();
|
||||||
|
zipObj['model.glb'] = new Uint8Array(buf);
|
||||||
|
sceneData.modelData.data = 'model.glb';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonString = JSON.stringify(sceneData);
|
||||||
|
const textEncoder = new TextEncoder();
|
||||||
|
zipObj['scene.json'] = textEncoder.encode(jsonString);
|
||||||
|
|
||||||
|
const zipped = fflate.zipSync(zipObj);
|
||||||
|
const blob = new Blob([zipped], { type: 'application/octet-stream' });
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
setIsRendering(false);
|
||||||
|
setExportDialog(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload to Cloud
|
||||||
|
const file = new File([blob], `${projectName.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.titanscene`, { type: 'application/octet-stream' });
|
||||||
|
const fileUrl = await api.uploadAsset(file);
|
||||||
|
|
||||||
const result = await api.saveProject({
|
const result = await api.saveProject({
|
||||||
id: projectId && projectId !== 'new' ? projectId : undefined,
|
id: projectId && projectId !== 'new' ? projectId : undefined,
|
||||||
name: projectName,
|
name: projectName,
|
||||||
scene_data: sceneData
|
scene_data: { titansceneUrl: fileUrl }
|
||||||
});
|
});
|
||||||
if (projectId === 'new' && result.id) {
|
if (projectId === 'new' && result.id) {
|
||||||
navigate(`/editor/${result.id}`, { replace: true });
|
navigate(`/editor/${result.id}`, { replace: true });
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (error) {
|
||||||
console.error("Failed to save project to cloud", err);
|
console.error("Failed to save project to cloud", error);
|
||||||
alert("Failed to save project: " + (err.message || JSON.stringify(err)));
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsRendering(false);
|
setIsRendering(false);
|
||||||
setOpenMenu(null);
|
}
|
||||||
setExportDialog(null);
|
setExportDialog(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const exportTitanScene = async (name: string) => {
|
||||||
|
const sceneData = {
|
||||||
|
sceneObjects, lightProps, groundProps, cameraProps,
|
||||||
|
envPreset, envRotation, backgroundColor, backgroundType,
|
||||||
|
customHdri, backplateImage, additionalLights, modelBounds, cameraPresets,
|
||||||
|
modelData: modelData ? { ...modelData } : null
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectName = name || exportDialogName || 'Untitled Scene';
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsRendering(true);
|
||||||
|
const zipObj: Record<string, Uint8Array> = {};
|
||||||
|
|
||||||
|
if (sceneData.modelData && sceneData.modelData.data && typeof sceneData.modelData.data === 'string') {
|
||||||
|
if (sceneData.modelData.data.startsWith('data:')) {
|
||||||
|
const dataUrl = sceneData.modelData.data;
|
||||||
|
const base64 = dataUrl.split(',')[1];
|
||||||
|
const binaryStr = atob(base64);
|
||||||
|
const len = binaryStr.length;
|
||||||
|
const bytes = new Uint8Array(len);
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
bytes[i] = binaryStr.charCodeAt(i);
|
||||||
|
}
|
||||||
|
zipObj['model.glb'] = bytes;
|
||||||
|
sceneData.modelData.data = 'model.glb';
|
||||||
|
} else if (sceneData.modelData.data.startsWith('blob:') || sceneData.modelData.data.startsWith('http')) {
|
||||||
|
const res = await fetch(sceneData.modelData.data);
|
||||||
|
const buf = await res.arrayBuffer();
|
||||||
|
zipObj['model.glb'] = new Uint8Array(buf);
|
||||||
|
sceneData.modelData.data = 'model.glb';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonString = JSON.stringify(sceneData);
|
||||||
|
const textEncoder = new TextEncoder();
|
||||||
|
zipObj['scene.json'] = textEncoder.encode(jsonString);
|
||||||
|
|
||||||
|
const zipped = fflate.zipSync(zipObj);
|
||||||
|
const blob = new Blob([zipped], { type: 'application/octet-stream' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${projectName.replace(/[^a-z0-9]/gi, '_').toLowerCase()}.titanscene`;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to export .titanscene", error);
|
||||||
|
} finally {
|
||||||
|
setIsRendering(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -706,6 +820,8 @@ export default function Editor() {
|
|||||||
const name = exportDialogName.trim() || exportDialog.defaultName;
|
const name = exportDialogName.trim() || exportDialog.defaultName;
|
||||||
if (exportDialog.mode === 'scene') {
|
if (exportDialog.mode === 'scene') {
|
||||||
saveScene(name);
|
saveScene(name);
|
||||||
|
} else if (exportDialog.mode === 'titanscene' as any) {
|
||||||
|
exportTitanScene(name);
|
||||||
} else {
|
} else {
|
||||||
setExportFormat(exportDialog.mode);
|
setExportFormat(exportDialog.mode);
|
||||||
// Store name so ExportManager can use it
|
// Store name so ExportManager can use it
|
||||||
@@ -717,9 +833,66 @@ export default function Editor() {
|
|||||||
|
|
||||||
const [exportFileName, setExportFileName] = useState<string>('model');
|
const [exportFileName, setExportFileName] = useState<string>('model');
|
||||||
|
|
||||||
const loadScene = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const loadScene = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const file = event.target.files?.[0];
|
const file = event.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
|
if (file.name.endsWith('.titanscene')) {
|
||||||
|
try {
|
||||||
|
const buffer = await file.arrayBuffer();
|
||||||
|
const unzipped = fflate.unzipSync(new Uint8Array(buffer));
|
||||||
|
if (unzipped['scene.json']) {
|
||||||
|
const textDecoder = new TextDecoder();
|
||||||
|
const jsonString = textDecoder.decode(unzipped['scene.json']);
|
||||||
|
const data = JSON.parse(jsonString);
|
||||||
|
|
||||||
|
if (data.modelData && data.modelData.data === 'model.glb' && unzipped['model.glb']) {
|
||||||
|
const modelBlob = new Blob([unzipped['model.glb']]);
|
||||||
|
data.modelData.data = URL.createObjectURL(modelBlob);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.sceneObjects) setSceneObjects(data.sceneObjects);
|
||||||
|
if (data.lightProps) setLightProps(data.lightProps);
|
||||||
|
if (data.groundProps) setGroundProps(data.groundProps);
|
||||||
|
if (data.cameraProps) {
|
||||||
|
const cProps = data.cameraProps;
|
||||||
|
setCameraProps(cProps);
|
||||||
|
const updateControls = (attempts = 0) => {
|
||||||
|
if (orbitControlsRef.current) {
|
||||||
|
const controls = orbitControlsRef.current;
|
||||||
|
if (cProps.target) controls.target.set(cProps.target[0], cProps.target[1], cProps.target[2]);
|
||||||
|
if (cProps.position) {
|
||||||
|
controls.object.position.set(cProps.position[0], cProps.position[1], cProps.position[2]);
|
||||||
|
}
|
||||||
|
controls.update();
|
||||||
|
} else if (attempts < 10) {
|
||||||
|
setTimeout(() => updateControls(attempts + 1), 100);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
updateControls();
|
||||||
|
}
|
||||||
|
if (data.envPreset !== undefined) setEnvPreset(data.envPreset);
|
||||||
|
if (data.envRotation !== undefined) setEnvRotation(data.envRotation);
|
||||||
|
if (data.backgroundColor) setBackgroundColor(data.backgroundColor);
|
||||||
|
if (data.backgroundType) setBackgroundType(data.backgroundType);
|
||||||
|
if (data.customHdri) setCustomHdri(data.customHdri);
|
||||||
|
if (data.backplateImage) setBackplateImage(data.backplateImage);
|
||||||
|
if (data.additionalLights) setAdditionalLights(data.additionalLights);
|
||||||
|
if (data.modelBounds) setModelBounds(data.modelBounds);
|
||||||
|
if (data.cameraPresets) setCameraPresets(data.cameraPresets);
|
||||||
|
|
||||||
|
if (data.modelData && data.modelData.data) {
|
||||||
|
reImportModel(data.modelData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to parse .titanscene", err);
|
||||||
|
}
|
||||||
|
setOpenMenu(null);
|
||||||
|
event.target.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (e) => {
|
reader.onload = (e) => {
|
||||||
try {
|
try {
|
||||||
@@ -1706,7 +1879,7 @@ export default function Editor() {
|
|||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
ref={sceneInputRef}
|
ref={sceneInputRef}
|
||||||
accept=".json"
|
accept=".json,.titanscene"
|
||||||
onChange={loadScene}
|
onChange={loadScene}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
@@ -1793,6 +1966,16 @@ export default function Editor() {
|
|||||||
<span>Export Scene (USDZ)...</span>
|
<span>Export Scene (USDZ)...</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
<div className="h-px bg-white/10 my-1" />
|
||||||
|
<button
|
||||||
|
onClick={() => openExportDialog('titanscene' as any)}
|
||||||
|
className="w-full text-left px-4 py-2 hover:bg-primary hover:text-white flex items-center justify-between transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Save size={14} />
|
||||||
|
<span>Export Local (.titanscene)</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user