From ee7fcce1b3144ac80a4002716224bb2970b2ac99 Mon Sep 17 00:00:00 2001 From: Victor Giers Date: Thu, 30 Apr 2026 02:56:17 +0200 Subject: [PATCH] Improve autosave reliability and implement size limits for local draft storage --- src/app/App.tsx | 8 ++ src/serialization/local-draft-storage.ts | 109 ++++++++++++++++++++--- src/viewport-three/viewport-host.ts | 35 +++++--- 3 files changed, 129 insertions(+), 23 deletions(-) diff --git a/src/app/App.tsx b/src/app/App.tsx index 25dd6936..8e940f6d 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -4555,6 +4555,14 @@ export function App({ store, initialStatusMessage }: AppProps) { autosaveControllerRef.current = new EditorAutosaveController({ saveDraft: () => store.saveDraft(), onComplete: (result) => { + if (result.status === "skipped") { + if (lastAutosaveErrorRef.current !== result.message) { + lastAutosaveErrorRef.current = result.message; + setStatusMessage(result.message); + } + return; + } + if (result.status === "error") { if (lastAutosaveErrorRef.current !== result.message) { lastAutosaveErrorRef.current = result.message; diff --git a/src/serialization/local-draft-storage.ts b/src/serialization/local-draft-storage.ts index 9a4b3f05..829a6fd6 100644 --- a/src/serialization/local-draft-storage.ts +++ b/src/serialization/local-draft-storage.ts @@ -14,9 +14,9 @@ import { import { parseSceneDocumentJson, - parseProjectDocumentJson, - serializeProjectDocument + parseProjectDocumentJson } from "./scene-document-json"; +import { assertProjectDocumentIsValid } from "../document/scene-document-validation"; export interface KeyValueStorage { getItem(key: string): string | null; @@ -31,6 +31,7 @@ export interface BrowserStorageAccessResult { export type SaveSceneDocumentDraftResult = | { status: "saved"; message: string } + | { status: "skipped"; message: string } | { status: "error"; message: string }; export type LoadSceneDocumentDraftResult = @@ -45,8 +46,15 @@ export interface LoadOrCreateSceneDocumentResult { } export const DEFAULT_SCENE_DRAFT_STORAGE_KEY = "webeditor3d.scene-document-draft"; +export const DEFAULT_SCENE_DRAFT_MAX_SERIALIZED_BYTES = 4 * 1024 * 1024; +const ESTIMATED_TERRAIN_SAMPLE_JSON_BYTES = 8; +const ESTIMATED_PROJECT_DRAFT_BASE_BYTES = 64 * 1024; const EDITOR_DRAFT_ENVELOPE_FORMAT = "webeditor3d.editor-draft.v1"; +export interface SaveSceneDocumentDraftOptions { + maxSerializedBytes?: number; +} + interface StoredEditorDraftEnvelope { format: typeof EDITOR_DRAFT_ENVELOPE_FORMAT; document: unknown; @@ -65,6 +73,48 @@ function formatStorageDiagnostic(prefix: string, error: unknown): string { return `${prefix} ${getErrorDetail(error)}`; } +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 { + return `Autosave skipped because this project draft is about ${formatByteSize(sizeBytes)}, above the ${formatByteSize(maxBytes)} browser autosave limit. Use project save/export for terrain-heavy scenes.`; +} + +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 + + terrainSampleValueCount * ESTIMATED_TERRAIN_SAMPLE_JSON_BYTES + ); +} + function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; } @@ -199,19 +249,56 @@ export function saveSceneDocumentDraft( storage: KeyValueStorage, document: ProjectDocument, viewportLayoutState: ViewportLayoutState | null = null, - key = DEFAULT_SCENE_DRAFT_STORAGE_KEY + key = DEFAULT_SCENE_DRAFT_STORAGE_KEY, + options: SaveSceneDocumentDraftOptions = {} ): SaveSceneDocumentDraftResult { try { - const rawDocument = serializeProjectDocument(document); - storage.setItem( - key, - JSON.stringify({ - format: EDITOR_DRAFT_ENVELOPE_FORMAT, - document: JSON.parse(rawDocument), - viewportLayoutState: viewportLayoutState === null ? null : cloneViewportLayoutState(viewportLayoutState) - } satisfies StoredEditorDraftEnvelope) + assertProjectDocumentIsValid(document); + + const maxSerializedBytes = + options.maxSerializedBytes ?? DEFAULT_SCENE_DRAFT_MAX_SERIALIZED_BYTES; + const estimatedDraftBytes = estimateProjectDraftSerializedBytes( + document, + viewportLayoutState ); + if ( + Number.isFinite(maxSerializedBytes) && + maxSerializedBytes > 0 && + estimatedDraftBytes > maxSerializedBytes + ) { + storage.removeItem(key); + + return { + status: "skipped", + message: getAutosaveTooLargeMessage( + estimatedDraftBytes, + maxSerializedBytes + ) + }; + } + + const rawDraft = JSON.stringify({ + format: EDITOR_DRAFT_ENVELOPE_FORMAT, + document, + viewportLayoutState: viewportLayoutState === null ? null : cloneViewportLayoutState(viewportLayoutState) + } satisfies StoredEditorDraftEnvelope); + + if ( + Number.isFinite(maxSerializedBytes) && + maxSerializedBytes > 0 && + rawDraft.length > maxSerializedBytes + ) { + storage.removeItem(key); + + return { + status: "skipped", + message: getAutosaveTooLargeMessage(rawDraft.length, maxSerializedBytes) + }; + } + + storage.setItem(key, rawDraft); + return { status: "saved", message: "Autosave updated." diff --git a/src/viewport-three/viewport-host.ts b/src/viewport-three/viewport-host.ts index 02ff7bb8..c72c8258 100644 --- a/src/viewport-three/viewport-host.ts +++ b/src/viewport-three/viewport-host.ts @@ -9531,8 +9531,9 @@ export class ViewportHost { this.currentTerrainBrushState.tool === "flatten" ? hit.point.y - terrain.position.y : null; - const previewTerrain = this.applyTerrainBrushPoint( - terrain, + const previewTerrain = cloneTerrain(terrain); + const initialStamp = this.applyTerrainBrushPoint( + previewTerrain, { x: hit.point.x, z: hit.point.z @@ -9544,6 +9545,7 @@ export class ViewportHost { this.activeTerrainBrushStroke = { pointerId: event.pointerId, previewTerrain, + changed: initialStamp.changed, referenceHeight, lastAppliedPoint: { x: hit.point.x, @@ -9552,7 +9554,10 @@ export class ViewportHost { toolState: this.currentTerrainBrushState }; this.renderer.domElement.setPointerCapture(event.pointerId); - this.rebuildDisplayedTerrainState(); + this.refreshDisplayedTerrainDirtyBounds( + previewTerrain.id, + initialStamp.dirtyBounds + ); return true; } @@ -9586,10 +9591,7 @@ export class ViewportHost { ); if ( - !areTerrainsEqual( - segmentResult.terrain, - this.activeTerrainBrushStroke.previewTerrain - ) || + segmentResult.changed || segmentResult.lastAppliedPoint.x !== this.activeTerrainBrushStroke.lastAppliedPoint.x || segmentResult.lastAppliedPoint.z !== @@ -9597,10 +9599,17 @@ export class ViewportHost { ) { this.activeTerrainBrushStroke = { ...this.activeTerrainBrushStroke, - previewTerrain: segmentResult.terrain, + changed: + this.activeTerrainBrushStroke.changed || segmentResult.changed, lastAppliedPoint: segmentResult.lastAppliedPoint }; - this.rebuildDisplayedTerrainState(); + + if (segmentResult.changed) { + this.refreshDisplayedTerrainDirtyBounds( + this.activeTerrainBrushStroke.previewTerrain.id, + segmentResult.dirtyBounds + ); + } } return true; @@ -9634,6 +9643,7 @@ export class ViewportHost { const cancelled = event.type === "pointercancel"; let finalPreviewTerrain = this.activeTerrainBrushStroke.previewTerrain; + let changed = this.activeTerrainBrushStroke.changed; if (!cancelled) { const hit = this.getTerrainBrushHitAtClientPosition( @@ -9652,13 +9662,13 @@ export class ViewportHost { this.activeTerrainBrushStroke.toolState, this.activeTerrainBrushStroke.referenceHeight ); - finalPreviewTerrain = segmentResult.terrain; + changed ||= segmentResult.changed; if ( segmentResult.lastAppliedPoint.x !== hit.point.x || segmentResult.lastAppliedPoint.z !== hit.point.z ) { - finalPreviewTerrain = this.applyTerrainBrushPoint( + const pointResult = this.applyTerrainBrushPoint( finalPreviewTerrain, { x: hit.point.x, @@ -9667,6 +9677,7 @@ export class ViewportHost { this.activeTerrainBrushStroke.toolState, this.activeTerrainBrushStroke.referenceHeight ); + changed ||= pointResult.changed; } } } @@ -9678,7 +9689,7 @@ export class ViewportHost { const commit = !cancelled && baseTerrain !== null && - !areTerrainsEqual(baseTerrain, finalPreviewTerrain); + changed; const toolState = this.activeTerrainBrushStroke.toolState; this.activeTerrainBrushStroke = null; this.terrainBrushPreviewGroup.visible = false;