2026-04-11 03:48:17 +02:00
import {
2026-04-11 13:28:10 +02:00
createProjectDocumentFromSceneDocument ,
2026-04-11 03:48:17 +02:00
createEmptyProjectDocument ,
type ProjectDocument
} from "../document/scene-document" ;
2026-04-03 01:36:37 +02:00
import {
VIEWPORT_PANEL_IDS ,
cloneViewportLayoutState ,
createDefaultViewportLayoutState ,
type ViewportLayoutMode ,
type ViewportLayoutState ,
type ViewportPanelId
} from "../viewport-three/viewport-layout" ;
2026-03-31 01:29:35 +02:00
2026-04-11 03:48:17 +02:00
import {
2026-04-11 13:28:10 +02:00
parseSceneDocumentJson ,
2026-04-30 02:56:17 +02:00
parseProjectDocumentJson
2026-04-11 03:48:17 +02:00
} from "./scene-document-json" ;
2026-04-30 02:56:17 +02:00
import { assertProjectDocumentIsValid } from "../document/scene-document-validation" ;
2026-03-31 01:29:35 +02:00
export interface KeyValueStorage {
getItem ( key : string ) : string | null ;
setItem ( key : string , value : string ) : void ;
removeItem ( key : string ) : void ;
}
2026-03-31 01:50:29 +02:00
export interface BrowserStorageAccessResult {
storage : KeyValueStorage | null ;
diagnostic : string | null ;
}
export type SaveSceneDocumentDraftResult =
| { status : "saved" ; message : string }
2026-04-30 02:56:17 +02:00
| { status : "skipped" ; message : string }
2026-03-31 01:50:29 +02:00
| { status : "error" ; message : string } ;
export type LoadSceneDocumentDraftResult =
2026-04-11 03:48:17 +02:00
| { status : "loaded" ; document : ProjectDocument ; viewportLayoutState : ViewportLayoutState | null ; message : string }
2026-03-31 01:50:29 +02:00
| { status : "missing" ; message : string }
| { status : "error" ; message : string } ;
export interface LoadOrCreateSceneDocumentResult {
2026-04-11 03:48:17 +02:00
document : ProjectDocument ;
2026-04-03 01:36:37 +02:00
viewportLayoutState : ViewportLayoutState | null ;
2026-03-31 01:50:29 +02:00
diagnostic : string | null ;
}
2026-03-31 01:29:35 +02:00
export const DEFAULT_SCENE_DRAFT_STORAGE_KEY = "webeditor3d.scene-document-draft" ;
2026-04-30 02:56:17 +02:00
export const DEFAULT_SCENE_DRAFT_MAX_SERIALIZED_BYTES = 4 * 1024 * 1024 ;
2026-05-01 18:23:56 +02:00
const MIN_TERRAIN_SAMPLE_JSON_BYTES = 2 ;
2026-04-30 02:56:17 +02:00
const ESTIMATED_PROJECT_DRAFT_BASE_BYTES = 64 * 1024 ;
2026-04-03 01:36:37 +02:00
const EDITOR_DRAFT_ENVELOPE_FORMAT = "webeditor3d.editor-draft.v1" ;
2026-04-30 02:56:17 +02:00
export interface SaveSceneDocumentDraftOptions {
maxSerializedBytes? : number ;
}
2026-04-03 01:36:37 +02:00
interface StoredEditorDraftEnvelope {
format : typeof EDITOR_DRAFT_ENVELOPE_FORMAT ;
document : unknown ;
viewportLayoutState? : unknown ;
}
2026-03-31 01:29:35 +02:00
2026-03-31 01:50:29 +02:00
function getErrorDetail ( error : unknown ) : string {
if ( error instanceof Error && error . message . trim ( ) . length > 0 ) {
return error . message . trim ( ) ;
}
return "Unknown error." ;
}
function formatStorageDiagnostic ( prefix : string , error : unknown ) : string {
return ` ${ prefix } ${ getErrorDetail ( error ) } ` ;
}
2026-04-30 02:56:17 +02:00
function formatByteSize ( bytes : number ) : string {
if ( bytes < 1024 ) {
return ` ${ bytes } B ` ;
}
if ( bytes < 1024 * 1024 ) {
return ` ${ ( bytes / 1024 ) . toFixed ( 1 ) } KB ` ;
}
return ` ${ ( bytes / ( 1024 * 1024 ) ) . toFixed ( 1 ) } MB ` ;
}
function getAutosaveTooLargeMessage ( sizeBytes : number , maxBytes : number ) : string {
2026-05-01 18:23:56 +02:00
return ` Autosave skipped because this project draft is about ${ formatByteSize ( sizeBytes ) } , above the ${ formatByteSize ( maxBytes ) } browser autosave limit. The last successful autosave was kept; use project save/export for terrain-heavy scenes. ` ;
2026-04-30 02:56:17 +02:00
}
function getTerrainDraftSampleValueCount ( document : ProjectDocument ) : number {
let sampleValueCount = 0 ;
for ( const scene of Object . values ( document . scenes ) ) {
for ( const terrain of Object . values ( scene . terrains ) ) {
sampleValueCount += terrain . heights . length + terrain . paintWeights . length ;
}
}
return sampleValueCount ;
}
export function estimateProjectDraftSerializedBytes (
document : ProjectDocument ,
viewportLayoutState : ViewportLayoutState | null = null
) : number {
const terrainSampleValueCount = getTerrainDraftSampleValueCount ( document ) ;
const viewportBytes = viewportLayoutState === null ? 0 : 16 * 1024 ;
return (
ESTIMATED_PROJECT_DRAFT_BASE_BYTES +
viewportBytes +
2026-05-01 18:23:56 +02:00
terrainSampleValueCount * MIN_TERRAIN_SAMPLE_JSON_BYTES
2026-04-30 02:56:17 +02:00
) ;
}
2026-04-03 01:36:37 +02:00
function isRecord ( value : unknown ) : value is Record < string , unknown > {
return typeof value === "object" && value !== null ;
}
function isFiniteNumber ( value : unknown ) : value is number {
return typeof value === "number" && Number . isFinite ( value ) ;
}
function parseViewportLayoutMode ( value : unknown ) : ViewportLayoutMode | null {
return value === "single" || value === "quad" ? value : null ;
}
function parseViewportPanelId ( value : unknown ) : ViewportPanelId | null {
return typeof value === "string" && ( VIEWPORT_PANEL_IDS as readonly string [ ] ) . includes ( value ) ? ( value as ViewportPanelId ) : null ;
}
2026-05-01 18:49:08 +02:00
export function parseViewportLayoutState ( value : unknown ) : ViewportLayoutState | null {
2026-04-03 01:36:37 +02:00
if ( ! isRecord ( value ) ) {
return null ;
}
const layoutMode = parseViewportLayoutMode ( value . layoutMode ) ;
const activePanelId = parseViewportPanelId ( value . activePanelId ) ;
const viewportQuadSplit = isRecord ( value . viewportQuadSplit ) ? value.viewportQuadSplit : null ;
const panels = isRecord ( value . panels ) ? value.panels : null ;
if ( layoutMode === null || activePanelId === null || viewportQuadSplit === null || panels === null ) {
return null ;
}
if ( ! isFiniteNumber ( viewportQuadSplit . x ) || ! isFiniteNumber ( viewportQuadSplit . y ) ) {
return null ;
}
const defaultLayoutState = createDefaultViewportLayoutState ( ) ;
const nextLayoutState = cloneViewportLayoutState ( defaultLayoutState ) ;
nextLayoutState . layoutMode = layoutMode ;
nextLayoutState . activePanelId = activePanelId ;
nextLayoutState . viewportQuadSplit = {
x : viewportQuadSplit.x ,
y : viewportQuadSplit.y
} ;
for ( const panelId of VIEWPORT_PANEL_IDS ) {
const storedPanel = panels [ panelId ] ;
if ( ! isRecord ( storedPanel ) ) {
return null ;
}
const storedViewMode = storedPanel . viewMode ;
const storedDisplayMode = storedPanel . displayMode ;
const storedCameraState = isRecord ( storedPanel . cameraState ) ? storedPanel.cameraState : null ;
const storedPerspectiveOrbit = storedCameraState !== null && isRecord ( storedCameraState . perspectiveOrbit ) ? storedCameraState.perspectiveOrbit : null ;
const storedTarget = storedCameraState !== null && isRecord ( storedCameraState . target ) ? storedCameraState.target : null ;
if (
( storedViewMode !== "perspective" && storedViewMode !== "top" && storedViewMode !== "front" && storedViewMode !== "side" ) ||
2026-04-04 19:05:12 +02:00
( storedDisplayMode !== "normal" && storedDisplayMode !== "authoring" && storedDisplayMode !== "wireframe" ) ||
2026-04-03 01:36:37 +02:00
storedCameraState === null ||
storedPerspectiveOrbit === null ||
storedTarget === null
) {
return null ;
}
if (
! isFiniteNumber ( storedTarget . x ) ||
! isFiniteNumber ( storedTarget . y ) ||
! isFiniteNumber ( storedTarget . z ) ||
! isFiniteNumber ( storedPerspectiveOrbit . radius ) ||
! isFiniteNumber ( storedPerspectiveOrbit . theta ) ||
! isFiniteNumber ( storedPerspectiveOrbit . phi ) ||
! isFiniteNumber ( storedCameraState . orthographicZoom )
) {
return null ;
}
nextLayoutState . panels [ panelId ] = {
viewMode : storedViewMode ,
displayMode : storedDisplayMode ,
cameraState : {
target : {
x : storedTarget.x ,
y : storedTarget.y ,
z : storedTarget.z
} ,
perspectiveOrbit : {
radius : storedPerspectiveOrbit.radius ,
theta : storedPerspectiveOrbit.theta ,
phi : storedPerspectiveOrbit.phi
} ,
orthographicZoom : storedCameraState.orthographicZoom
}
} ;
}
return nextLayoutState ;
}
function isStoredEditorDraftEnvelope ( value : unknown ) : value is StoredEditorDraftEnvelope {
return isRecord ( value ) && value . format === EDITOR_DRAFT_ENVELOPE_FORMAT && "document" in value ;
}
2026-03-31 01:50:29 +02:00
export function getBrowserStorageAccess ( ) : BrowserStorageAccessResult {
2026-03-31 01:29:35 +02:00
if ( typeof window === "undefined" ) {
2026-03-31 01:50:29 +02:00
return {
storage : null ,
diagnostic : null
} ;
2026-03-31 01:29:35 +02:00
}
2026-03-31 01:50:29 +02:00
try {
return {
storage : window.localStorage ,
diagnostic : null
} ;
} catch ( error ) {
return {
storage : null ,
diagnostic : formatStorageDiagnostic ( "Browser local storage is unavailable." , error )
} ;
}
}
export function getBrowserStorage ( ) : KeyValueStorage | null {
return getBrowserStorageAccess ( ) . storage ;
2026-03-31 01:29:35 +02:00
}
export function saveSceneDocumentDraft (
storage : KeyValueStorage ,
2026-04-11 03:48:17 +02:00
document : ProjectDocument ,
2026-04-03 01:36:37 +02:00
viewportLayoutState : ViewportLayoutState | null = null ,
2026-04-30 02:56:17 +02:00
key = DEFAULT_SCENE_DRAFT_STORAGE_KEY ,
options : SaveSceneDocumentDraftOptions = { }
2026-03-31 01:50:29 +02:00
) : SaveSceneDocumentDraftResult {
try {
2026-04-30 02:56:17 +02:00
const maxSerializedBytes =
options . maxSerializedBytes ? ? DEFAULT_SCENE_DRAFT_MAX_SERIALIZED_BYTES ;
const estimatedDraftBytes = estimateProjectDraftSerializedBytes (
document ,
viewportLayoutState
) ;
if (
Number . isFinite ( maxSerializedBytes ) &&
maxSerializedBytes > 0 &&
estimatedDraftBytes > maxSerializedBytes
) {
return {
status : "skipped" ,
message : getAutosaveTooLargeMessage (
estimatedDraftBytes ,
maxSerializedBytes
)
} ;
}
2026-05-01 17:43:49 +02:00
assertProjectDocumentIsValid ( document ) ;
2026-04-30 02:56:17 +02:00
const rawDraft = JSON . stringify ( {
2026-04-03 01:36:37 +02:00
format : EDITOR_DRAFT_ENVELOPE_FORMAT ,
2026-04-30 02:56:17 +02:00
document ,
2026-04-03 01:36:37 +02:00
viewportLayoutState : viewportLayoutState === null ? null : cloneViewportLayoutState ( viewportLayoutState )
2026-04-30 02:56:17 +02:00
} satisfies StoredEditorDraftEnvelope ) ;
if (
Number . isFinite ( maxSerializedBytes ) &&
maxSerializedBytes > 0 &&
rawDraft . length > maxSerializedBytes
) {
return {
status : "skipped" ,
message : getAutosaveTooLargeMessage ( rawDraft . length , maxSerializedBytes )
} ;
}
storage . setItem ( key , rawDraft ) ;
2026-03-31 01:50:29 +02:00
return {
status : "saved" ,
2026-04-10 12:23:11 +02:00
message : "Autosave updated."
2026-03-31 01:50:29 +02:00
} ;
} catch ( error ) {
return {
status : "error" ,
2026-04-10 12:23:11 +02:00
message : formatStorageDiagnostic ( "Autosave could not be saved." , error )
2026-03-31 01:50:29 +02:00
} ;
}
2026-03-31 01:29:35 +02:00
}
2026-03-31 01:50:29 +02:00
export function loadSceneDocumentDraft (
storage : KeyValueStorage ,
key = DEFAULT_SCENE_DRAFT_STORAGE_KEY
) : LoadSceneDocumentDraftResult {
try {
const rawDocument = storage . getItem ( key ) ;
2026-03-31 01:29:35 +02:00
2026-03-31 01:50:29 +02:00
if ( rawDocument === null ) {
return {
status : "missing" ,
2026-04-10 12:23:11 +02:00
message : "No autosave was found."
2026-03-31 01:50:29 +02:00
} ;
}
2026-03-31 01:29:35 +02:00
2026-04-03 01:36:37 +02:00
const parsedDraft = JSON . parse ( rawDocument ) as unknown ;
if ( isStoredEditorDraftEnvelope ( parsedDraft ) ) {
return {
status : "loaded" ,
2026-04-11 03:48:17 +02:00
document : parseProjectDocumentJson ( JSON . stringify ( parsedDraft . document ) ) ,
2026-04-03 01:36:37 +02:00
viewportLayoutState : parseViewportLayoutState ( parsedDraft . viewportLayoutState ? ? null ) ,
2026-04-10 12:23:11 +02:00
message : "Recovered latest autosave."
2026-04-03 01:36:37 +02:00
} ;
}
2026-03-31 01:50:29 +02:00
return {
status : "loaded" ,
2026-04-11 13:28:10 +02:00
document :
isRecord ( parsedDraft ) &&
( "scenes" in parsedDraft || "activeSceneId" in parsedDraft )
? parseProjectDocumentJson ( rawDocument )
: createProjectDocumentFromSceneDocument (
parseSceneDocumentJson ( rawDocument )
) ,
2026-04-03 01:36:37 +02:00
viewportLayoutState : null ,
2026-04-10 12:23:11 +02:00
message : "Recovered latest autosave."
2026-03-31 01:50:29 +02:00
} ;
} catch ( error ) {
return {
status : "error" ,
2026-04-10 12:23:11 +02:00
message : formatStorageDiagnostic ( "Stored autosave could not be loaded." , error )
2026-03-31 01:50:29 +02:00
} ;
}
2026-03-31 01:29:35 +02:00
}
2026-03-31 01:50:29 +02:00
export function loadOrCreateSceneDocument (
storage : KeyValueStorage | null ,
key = DEFAULT_SCENE_DRAFT_STORAGE_KEY
) : LoadOrCreateSceneDocumentResult {
2026-03-31 01:29:35 +02:00
if ( storage === null ) {
2026-03-31 01:50:29 +02:00
return {
2026-04-11 03:48:17 +02:00
document : createEmptyProjectDocument ( ) ,
2026-04-03 01:38:49 +02:00
viewportLayoutState : null ,
2026-03-31 01:50:29 +02:00
diagnostic : null
} ;
2026-03-31 01:29:35 +02:00
}
2026-03-31 01:50:29 +02:00
const draftResult = loadSceneDocumentDraft ( storage , key ) ;
switch ( draftResult . status ) {
case "loaded" :
return {
document : draftResult . document ,
2026-04-03 01:36:37 +02:00
viewportLayoutState : draftResult.viewportLayoutState ,
2026-04-10 12:23:58 +02:00
diagnostic : draftResult.message
2026-03-31 01:50:29 +02:00
} ;
case "missing" :
return {
2026-04-11 03:48:17 +02:00
document : createEmptyProjectDocument ( ) ,
2026-04-03 01:36:37 +02:00
viewportLayoutState : null ,
2026-03-31 01:50:29 +02:00
diagnostic : null
} ;
case "error" :
return {
2026-04-11 03:48:17 +02:00
document : createEmptyProjectDocument ( ) ,
2026-04-03 01:36:37 +02:00
viewportLayoutState : null ,
2026-03-31 01:50:29 +02:00
diagnostic : ` ${ draftResult . message } Starting with a fresh empty document. `
} ;
}
2026-03-31 01:29:35 +02:00
}