Skip to content

Commit 82a54ed

Browse files
stepankuzmingithub-actions[bot]
authored andcommitted
Add cancellation support to the ModelSource (internal-9574)
GitOrigin-RevId: 587aa36ff33d823806b6938fcce5ed05616a99bc
1 parent 9fe1518 commit 82a54ed

6 files changed

Lines changed: 139 additions & 72 deletions

File tree

3d-style/render/model_manager.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,6 @@ class ModelManager extends Evented {
4646
loadModel(id: string, url: string): Promise<Model | null | undefined> {
4747
return loadGLTF(this.requestManager.transformRequest(url, ResourceType.Model).url)
4848
.then(gltf => {
49-
if (!gltf) return;
50-
5149
const nodes = convertModel(gltf);
5250
const model = new Model(id, url, undefined, undefined, nodes);
5351
model.computeBoundsAndApplyParent();

3d-style/source/model_source.ts

Lines changed: 81 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,8 @@ class ModelSource extends Evented<SourceEvents> implements ISource {
6969
uri: string;
7070
models: Array<Model>;
7171
_options: ModelSourceSpecification;
72+
_abortController: AbortController | null;
7273

73-
onRemove: undefined;
7474
abortTile: undefined;
7575
unloadTile: undefined;
7676
hasTile: undefined;
@@ -89,67 +89,82 @@ class ModelSource extends Evented<SourceEvents> implements ISource {
8989
this.models = [];
9090
this._options = options;
9191
this._modelsInfo = new Map();
92+
this._abortController = null;
9293
}
9394

94-
private loadGLTFFromURI(uri: string): Promise<void | GLTF> {
95-
return loadGLTF(this.map._requestManager.transformRequest(uri, ResourceType.Model).url);
95+
private cancelModelRequests() {
96+
if (this._abortController) {
97+
this._abortController.abort();
98+
this._abortController = null;
99+
}
96100
}
97101

98-
load(): void {
99-
for (const modelId in this._options.models) {
100-
const modelSpec = this._options.models[modelId];
101-
102-
const modelInfo = this._modelsInfo.get(modelId);
103-
if (modelInfo) {
104-
modelInfo.modelSpec = modelSpec;
105-
// Update model if loaded
106-
const model = modelInfo.model;
107-
if (model) {
108-
model.position = modelSpec.position != null ? new LngLat(modelSpec.position[0], modelSpec.position[1]) : new LngLat(0, 0);
109-
model.orientation = modelSpec.orientation != null ? modelSpec.orientation : [0, 0, 0];
110-
ModelSource.applyModelSpecification(model, modelSpec);
111-
model.computeBoundsAndApplyParent();
112-
this.models.push(model);
113-
}
114-
} else {
115-
// Model neither currently loading nor already loaded
116-
this._modelsInfo.set(modelId, {modelSpec, model: null});
117-
// eslint-disable-next-line @typescript-eslint/no-floating-promises
118-
this.loadModel(modelId, modelSpec);
119-
}
120-
}
121-
// Fire data event if all models are already loaded (i.e model source is empty or there are no more requests pending)
122-
if (this.loaded()) {
123-
this.fire(new Event('data', {dataType: 'source', sourceDataType: 'metadata'}));
124-
}
102+
private loadGLTFFromURI(uri: string, signal?: AbortSignal): Promise<GLTF> {
103+
return loadGLTF(this.map._requestManager.transformRequest(uri, ResourceType.Model).url, signal);
125104
}
126105

127-
private async loadModel(modelId: string, modelSpec: ModelSourceModelSpecification): Promise<void> {
106+
private async loadModel(modelId: string, modelSpec: ModelSourceModelSpecification, signal: AbortSignal): Promise<void> {
128107
try {
129-
const gltf = await this.loadGLTFFromURI(modelSpec.uri);
130-
if (!gltf) return;
108+
const gltf = await this.loadGLTFFromURI(modelSpec.uri, signal);
109+
if (signal.aborted) return;
131110

132-
// Check if model is still active
133111
const modelInfo = this._modelsInfo.get(modelId);
134-
if (!modelInfo) return;
112+
if (!modelInfo) return; // source modified during async gap
135113

136114
const nodes = convertModel(gltf);
137-
const currentModelSpec = modelInfo.modelSpec;
138-
const model = new Model(modelId, currentModelSpec.uri, currentModelSpec.position, currentModelSpec.orientation, nodes);
139-
ModelSource.applyModelSpecification(model, currentModelSpec);
115+
const currentSpec = modelInfo.modelSpec;
116+
const model = new Model(modelId, currentSpec.uri, currentSpec.position, currentSpec.orientation, nodes);
117+
ModelSource.applyModelSpecification(model, currentSpec);
140118
model.computeBoundsAndApplyParent();
141119

142120
this.models.push(model);
143121
modelInfo.model = model;
122+
} catch (err: unknown) {
123+
if (err instanceof Error && err.name === 'AbortError') return;
124+
const message = err instanceof Error ? err.message : 'Unknown error';
125+
this.fire(new ErrorEvent(new Error(`Could not load model ${modelId} from ${modelSpec.uri}: ${message}`)));
126+
}
127+
}
144128

145-
// If all models are loaded, fire data event
129+
async load(): Promise<void> {
130+
if (!this._abortController) {
131+
this._abortController = new AbortController();
132+
}
133+
const signal = this._abortController.signal;
134+
135+
const loadPromises: Promise<void>[] = [];
136+
137+
for (const modelId in this._options.models) {
138+
const modelSpec = this._options.models[modelId];
139+
140+
const existingInfo = this._modelsInfo.get(modelId);
141+
if (existingInfo && existingInfo.model) {
142+
existingInfo.modelSpec = modelSpec;
143+
const model = existingInfo.model;
144+
model.position = modelSpec.position != null ? new LngLat(modelSpec.position[0], modelSpec.position[1]) : new LngLat(0, 0);
145+
model.orientation = modelSpec.orientation != null ? modelSpec.orientation : [0, 0, 0];
146+
ModelSource.applyModelSpecification(model, modelSpec);
147+
model.computeBoundsAndApplyParent();
148+
this.models.push(model);
149+
} else if (!existingInfo) {
150+
this._modelsInfo.set(modelId, {modelSpec, model: null});
151+
loadPromises.push(this.loadModel(modelId, modelSpec, signal));
152+
} else {
153+
existingInfo.modelSpec = modelSpec;
154+
}
155+
}
156+
157+
if (loadPromises.length === 0) {
146158
if (this.loaded()) {
147159
this.fire(new Event('data', {dataType: 'source', sourceDataType: 'metadata'}));
148160
}
149-
} catch (err) {
150-
151-
this.fire(new ErrorEvent(new Error(`Could not load model ${modelId} from ${modelSpec.uri}: ${(err as Error).message}`)));
161+
return;
152162
}
163+
164+
await Promise.allSettled(loadPromises);
165+
166+
if (signal.aborted) return; // new load will fire data event
167+
this.fire(new Event('data', {dataType: 'source', sourceDataType: 'metadata'}));
153168
}
154169

155170
private static applyModelSpecification(model: Model, modelSpec: ModelSourceModelSpecification) {
@@ -246,6 +261,7 @@ class ModelSource extends Evented<SourceEvents> implements ISource {
246261

247262
onAdd(map: MapboxMap) {
248263
this.map = map;
264+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
249265
this.load();
250266
}
251267

@@ -278,13 +294,19 @@ class ModelSource extends Evented<SourceEvents> implements ISource {
278294
}
279295

280296
reload() {
297+
this.cancelModelRequests();
281298
const fqid = makeFQID(this.id, this.scope);
282299
this.map.style.clearSource(fqid);
283300
this.models = [];
284301
this._modelsInfo.clear();
302+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
285303
this.load();
286304
}
287305

306+
onRemove(_map: MapboxMap) {
307+
this.cancelModelRequests();
308+
}
309+
288310
/**
289311
* Sets the list of models along with their properties.
290312
*
@@ -300,23 +322,32 @@ class ModelSource extends Evented<SourceEvents> implements ISource {
300322
* });
301323
*/
302324
setModels(modelSpecs: ModelSourceModelsSpecification) {
303-
// Mimic behavior of native `ModelSource::updateModelData` implementation
304325
this.models = [];
305326

306-
// Only preserve model info entries for ids present in new model specification
307327
const updatedModelsInfo = new Map<string, ModelSourceModelInfo>();
308328
for (const modelId in modelSpecs) {
309329
const modelSpec = modelSpecs[modelId];
310-
if (this._modelsInfo.has(modelId)) {
311-
const entry = this._modelsInfo.get(modelId);
312-
// Only preserve if uri did not change
313-
if (entry && entry.modelSpec.uri === modelSpec.uri) {
314-
updatedModelsInfo.set(modelId, entry);
315-
}
330+
const entry = this._modelsInfo.get(modelId);
331+
if (entry && entry.modelSpec.uri === modelSpec.uri) {
332+
updatedModelsInfo.set(modelId, entry);
316333
}
317334
}
335+
336+
// Only cancel requests when models are actually removed or URIs change.
337+
// Property-only updates (position, orientation) are high-frequency (animation)
338+
// and should not restart in-flight model loads.
339+
const modelsChanged = this._modelsInfo.size !== updatedModelsInfo.size;
340+
if (modelsChanged) {
341+
this.cancelModelRequests();
342+
// Remove pending entries - their cancelled requests won't complete
343+
for (const [id, info] of updatedModelsInfo) {
344+
if (!info.model) updatedModelsInfo.delete(id);
345+
}
346+
}
347+
318348
this._modelsInfo = updatedModelsInfo;
319349
this._options.models = modelSpecs;
350+
// eslint-disable-next-line @typescript-eslint/no-floating-promises
320351
this.load();
321352
}
322353
}

3d-style/source/tiled_3d_model_worker_source.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,6 @@ class Tiled3dWorkerTile {
7171

7272
load3DTile(data)
7373
.then(gltf => {
74-
if (!gltf) return callback(new Error('Could not parse tile'));
7574
const hasMapboxMeshFeatures: boolean = (gltf.json.extensionsUsed && gltf.json.extensionsUsed.includes('MAPBOX_mesh_features')) ||
7675
(gltf.json.asset.extras && gltf.json.asset.extras['MAPBOX_mesh_features']);
7776

3d-style/util/loaders.ts

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import assert from 'assert';
1111
import {DracoDecoderModule} from './draco_decoder_gltf';
1212
import {MeshoptDecoder} from './meshopt_decoder';
1313
import {PerformanceUtils} from '../../src/util/performance';
14+
import {makeAsyncRequest} from '../../src/util/ajax';
1415

1516
import type {vec3, mat4, quat} from 'gl-matrix';
1617
import type {BuildingGen} from './building_gen';
@@ -356,8 +357,8 @@ function resolveUrl(url: string, baseUrl?: string) {
356357
return (new URL(url, baseUrl)).href;
357358
}
358359

359-
async function loadBuffer(buffer: {uri: string; byteLength: number}, gltf: GLTF, index: number, baseUrl?: string): Promise<void> {
360-
const response = await fetch(resolveUrl(buffer.uri, baseUrl));
360+
async function loadBuffer(buffer: {uri: string; byteLength: number}, gltf: GLTF, index: number, baseUrl?: string, signal?: AbortSignal): Promise<void> {
361+
const response = await fetch(resolveUrl(buffer.uri, baseUrl), {signal});
361362
const arrayBuffer = await response.arrayBuffer();
362363
assert(arrayBuffer.byteLength >= buffer.byteLength);
363364
gltf.buffers[index] = arrayBuffer;
@@ -369,10 +370,10 @@ function getGLTFBytes(gltf: GLTF, bufferViewIndex: number): Uint8Array<ArrayBuff
369370
return new Uint8Array<ArrayBuffer>(buffer, bufferView.byteOffset || 0, bufferView.byteLength);
370371
}
371372

372-
async function loadImage(img: {uri?: string; bufferView?: number; mimeType: string}, gltf: GLTF, index: number, baseUrl?: string): Promise<void> {
373+
async function loadImage(img: {uri?: string; bufferView?: number; mimeType: string}, gltf: GLTF, index: number, baseUrl?: string, signal?: AbortSignal): Promise<void> {
373374
if (img.uri) {
374375
const uri = resolveUrl(img.uri, baseUrl);
375-
const response = await fetch(uri);
376+
const response = await fetch(uri, {signal});
376377
const blob = await response.blob();
377378
const imageBitmap = await createImageBitmap(blob);
378379
gltf.images[index] = imageBitmap;
@@ -384,7 +385,7 @@ async function loadImage(img: {uri?: string; bufferView?: number; mimeType: stri
384385
}
385386
}
386387

387-
export async function decodeGLTF(arrayBuffer: ArrayBuffer, byteOffset: number = 0, baseUrl?: string): Promise<GLTF | void> {
388+
export async function decodeGLTF(arrayBuffer: ArrayBuffer, byteOffset: number = 0, baseUrl?: string, signal?: AbortSignal): Promise<GLTF> {
388389
const startTime = PerformanceUtils.now();
389390

390391
const gltf: GLTF = {json: null, images: [], buffers: []};
@@ -420,14 +421,16 @@ export async function decodeGLTF(arrayBuffer: ArrayBuffer, byteOffset: number =
420421
for (let i = 0; i < buffers.length; i++) {
421422
const buffer = buffers[i];
422423
if (buffer.uri) {
423-
bufferLoads.push(loadBuffer(buffer, gltf, i, baseUrl));
424+
bufferLoads.push(loadBuffer(buffer, gltf, i, baseUrl, signal));
424425
} else if (!gltf.buffers[i]) {
425426
gltf.buffers[i] = null;
426427
}
427428
}
428429
await Promise.all(bufferLoads);
429430
}
430431

432+
if (signal && signal.aborted) throw new DOMException('Aborted', 'AbortError');
433+
431434
const assetLoads: Promise<unknown>[] = [];
432435
const dracoUsed = extensionsUsed && extensionsUsed.includes(DRACO_EXT);
433436
const meshoptUsed = extensionsUsed && extensionsUsed.includes(MESHOPT_EXT);
@@ -440,14 +443,16 @@ export async function decodeGLTF(arrayBuffer: ArrayBuffer, byteOffset: number =
440443
}
441444
if (images) {
442445
for (let i = 0; i < images.length; i++) {
443-
assetLoads.push(loadImage(images[i], gltf, i, baseUrl));
446+
assetLoads.push(loadImage(images[i], gltf, i, baseUrl, signal));
444447
}
445448
}
446449

447450
if (assetLoads.length) {
448451
await Promise.all(assetLoads);
449452
}
450453

454+
if (signal && signal.aborted) throw new DOMException('Aborted', 'AbortError');
455+
451456
if (dracoUsed && meshes) {
452457
for (const {primitives} of meshes) {
453458
for (const primitive of primitives) {
@@ -467,13 +472,12 @@ export async function decodeGLTF(arrayBuffer: ArrayBuffer, byteOffset: number =
467472
return gltf;
468473
}
469474

470-
export async function loadGLTF(url: string): Promise<GLTF | void> {
471-
const response = await fetch(url);
472-
const buffer = await response.arrayBuffer();
473-
return decodeGLTF(buffer, 0, url);
475+
export async function loadGLTF(url: string, signal?: AbortSignal): Promise<GLTF> {
476+
const buffer = await makeAsyncRequest<ArrayBuffer>({url, type: 'arrayBuffer'}, signal);
477+
return decodeGLTF(buffer, 0, url, signal);
474478
}
475479

476-
export function load3DTile(data: ArrayBuffer): Promise<GLTF | void> {
480+
export function load3DTile(data: ArrayBuffer): Promise<GLTF> {
477481
const magic = new Uint32Array(data, 0, 1)[0];
478482
let gltfOffset = 0;
479483
if (magic !== MAGIC_GLTF) {

src/util/ajax.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,29 @@ export const getArrayBuffer = function (
263263
return makeRequest(Object.assign(requestParameters, {type: 'arrayBuffer'}), callback);
264264
};
265265

266+
export async function makeAsyncRequest<T>(
267+
requestParameters: RequestParameters,
268+
signal?: AbortSignal
269+
): Promise<T> {
270+
if (signal && signal.aborted) {
271+
throw new DOMException('Aborted', 'AbortError');
272+
}
273+
274+
return new Promise((resolve, reject) => {
275+
const cancelable = makeRequest(requestParameters, (err, data) => {
276+
if (err) reject(err);
277+
else resolve(data as T);
278+
});
279+
280+
if (signal) {
281+
signal.addEventListener('abort', () => {
282+
cancelable.cancel();
283+
reject(new DOMException('Aborted', 'AbortError'));
284+
}, {once: true});
285+
}
286+
});
287+
}
288+
266289
export const postData = function (requestParameters: RequestParameters, callback: ResponseCallback<string>): Cancelable {
267290
return makeRequest(Object.assign(requestParameters, {method: 'POST'}), callback);
268291
};

0 commit comments

Comments
 (0)