Skip to content

Commit 6a4391b

Browse files
authored
fix(editor): swap models in save() without blank-frame flicker (#208)
Reorder editor.save() to load and add the replacement model before tearing down the outgoing model's visuals. Adds dispose({ keepInScene }), finalizeDispose() and MaterialManager.releaseModelSlot() as the two-phase deferred-dispose mechanism. Closes #207.
1 parent d4839d2 commit 6a4391b

4 files changed

Lines changed: 64 additions & 5 deletions

File tree

packages/fragments/src/FragmentsModels/src/edit/edit-helper.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,12 @@ export class EditHelper {
9292
const camera = model.camera || undefined;
9393
const newModelBuffer = await model._save();
9494

95-
// Dispose all model
96-
await model.dispose();
95+
// Free up the modelId slot in the worker + registries, but keep the
96+
// model's THREE object, tiles and materials in scene so the user does
97+
// not see a blank frame while the new model loads.
98+
await model.dispose({ keepInScene: true });
9799

98-
// Add new model
100+
// Load new model with the same id (the slot is now free).
99101
const newModel = await this._fragments.load(newModelBuffer as any, {
100102
modelId,
101103
raw: true,
@@ -109,6 +111,9 @@ export class EditHelper {
109111
parent.add(newModel.object);
110112
}
111113

114+
// New model is in scene now. Tear down the old visuals.
115+
model.finalizeDispose();
116+
112117
// Return actions (e.g. to create action history, control z, etc.)
113118
return requests;
114119
}

packages/fragments/src/FragmentsModels/src/model/data-manager.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,20 @@ export class DataManager {
1919
meshes: MeshManager,
2020
alignments: AlignmentsManager,
2121
grids: GridsManager,
22+
options?: { keepInScene?: boolean },
2223
) {
2324
meshes.list.delete(model.modelId);
2425
await this.requestModelDelete(model);
2526
model.threads.delete(model.modelId);
27+
if (options?.keepInScene) {
28+
// Free the modelId slot in the shared MaterialManager so the
29+
// replacement model can register its own definitions without
30+
// appending to ours (which would corrupt material indices). The
31+
// actual THREE materials in `list` stay alive so the outgoing
32+
// tiles keep rendering until `finalizeDispose` runs.
33+
meshes.materials.releaseModelSlot(model.modelId);
34+
return;
35+
}
2636
model.object.removeFromParent();
2737
this.deleteAllTiles(model);
2838
meshes.materials.dispose(model.modelId);

packages/fragments/src/FragmentsModels/src/model/fragments-model.ts

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -224,8 +224,15 @@ export class FragmentsModel {
224224
/**
225225
* Dispose the model. Use this when you're done with the model.
226226
* If you use the {@link FragmentsModels.dispose} method, this will be called automatically for all models.
227-
*/
228-
async dispose() {
227+
*
228+
* @param options.keepInScene - If true, frees the model's worker slot,
229+
* registry entries and shared MaterialManager slot but leaves the THREE
230+
* object, tiles, materials in `list`, alignments and grids in place.
231+
* Caller must finalize the visual cleanup later via
232+
* {@link finalizeDispose}. Used by `editor.save()` to swap in a freshly
233+
* loaded model without a blank frame.
234+
*/
235+
async dispose(options?: { keepInScene?: boolean }) {
229236
this._isLoaded = false;
230237
this.visibleItems.clear();
231238
this.onViewUpdated.reset();
@@ -234,9 +241,35 @@ export class FragmentsModel {
234241
this._meshManager,
235242
this._alignmentsManager,
236243
this._gridsManager,
244+
options,
237245
);
238246
}
239247

248+
/**
249+
* Finalize a deferred dispose. Removes the THREE object from its parent,
250+
* tears down tile meshes (geometry only — materials are shared via the
251+
* MaterialManager and may be reused by a replacement model under the
252+
* same modelId), and disposes the model's alignments and grids.
253+
*
254+
* Only call this after `dispose({ keepInScene: true })`. The tile map
255+
* is cleared with events disabled to bypass the onBeforeDelete listener
256+
* registered in the constructor (which would otherwise dispose tile
257+
* materials).
258+
*/
259+
finalizeDispose() {
260+
this.object.removeFromParent();
261+
262+
this.tiles.eventsEnabled = false;
263+
for (const [, mesh] of this.tiles) {
264+
this.object.remove(mesh);
265+
mesh.geometry.dispose();
266+
}
267+
this.tiles.clear();
268+
269+
this._alignmentsManager.dispose();
270+
this._gridsManager.dispose();
271+
}
272+
240273
/**
241274
* Get the spatial structure of the model.
242275
*/

packages/fragments/src/FragmentsModels/src/model/material-manager.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,17 @@ export class MaterialManager {
4949
this._modelMaterialMapping.delete(modelId);
5050
}
5151

52+
/**
53+
* Release a model's slot in the per-model definitions and mapping tables
54+
* without disposing the underlying THREE materials in `list`. Used by
55+
* `editor.save()` so a fresh model can register under the same modelId
56+
* while the outgoing model's tiles still render normally.
57+
*/
58+
releaseModelSlot(modelId: string) {
59+
this._definitions.delete(modelId);
60+
this._modelMaterialMapping.delete(modelId);
61+
}
62+
5263
get(data: MaterialDefinition, request: any) {
5364
const { modelId, objectClass, currentLod, templateId } = request;
5465
if (!(modelId && objectClass !== undefined && currentLod !== undefined)) {

0 commit comments

Comments
 (0)