import * as THREE from 'three';
import { LinkedHashMap } from 'js-utils-z';
import { B3DMDecoder } from "../decoder/B3DMDecoder";
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js';
import { KTX2Loader } from "three/addons/loaders/KTX2Loader";
import { resolveImplicite } from './implicit/ImplicitTileResolver.js';
import { MeshoptDecoder } from 'meshoptimizer';
let concurrentDownloads = 0;
/**
* A Tile loader that manages caching and load order.
* The cache is an LRU cache and is defined by the number of items it can hold.
* The actual number of cached items might grow beyond max if all items are in use.
*
* The load order is designed for optimal perceived loading speed (nearby tiles are refined first).
* @class
*/
class TileLoader {
/**
* Creates a tile loader with a maximum number of cached items and callbacks.
* The only required property is a renderer that will be used to visualize the tiles.
* The maxCachedItems property is the size of the cache in number of objects, mesh tile and tileset.json files.
* The mesh and point callbacks will be called for every incoming mesh or points.
*
*
*
* @param {Object} [options] - Optional configuration object.
* @param {number} [options.maxCachedItems=100] - the cache size.
* @param {function} [options.meshCallback = undefined] - A callback to call on newly decoded meshes.
* @param {function} [options.pointsCallback = undefined] - A callback to call on newly decoded points.
* @param {sring} [options.proxy = undefined] - An optional proxy that tile requests will be directed too as POST requests with the actual tile url in the body of the request.
* @param {KTX2Loader} [options.ktx2Loader = undefined] - A KTX2Loader (three/addons)
* @param {DRACOLoader} [options.dracoLoader = undefined] - A DRACOLoader (three/addons)
* @param {renderer} [options.renderer = undefined] - optional the renderer, this is required only for on the fly ktx2 support. not needed if you pass a ktx2Loader manually
*/
constructor(options) {
this.zUpToYUpMatrix = new THREE.Matrix4();
this.zUpToYUpMatrix.set(1, 0, 0, 0,
0, 0, -1, 0,
0, 1, 0, 0,
0, 0, 0, 1);
this.maxCachedItems = 100;
this.proxy = options.proxy;
if (!!options) {
this.meshCallback = options.meshCallback;
this.pointsCallback = options.pointsCallback;
if (options.maxCachedItems) this.maxCachedItems = options.maxCachedItems;
}
this.gltfLoader = new GLTFLoader();
if (!!options && !!options.dracoLoader) {
this.gltfLoader.setDRACOLoader(options.dracoLoader);
this.hasDracoLoader = true;
} else {
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath('https://storage.googleapis.com/ogc-3d-tiles/draco/');
this.gltfLoader.setDRACOLoader(dracoLoader);
this.gltfLoader.hasDracoLoader = true;
}
if (!!options && !!options.ktx2Loader) {
this.gltfLoader.setKTX2Loader(options.ktx2Loader);
this.hasKTX2Loader = true;
} else if (!!options && !!options.renderer) {
const ktx2Loader = new KTX2Loader();
ktx2Loader.setTranscoderPath('https://storage.googleapis.com/ogc-3d-tiles/basis/').detectSupport(options.renderer);
this.gltfLoader.setKTX2Loader(ktx2Loader);
this.gltfLoader.hasKTX2Loader = true;
}
this.gltfLoader.setMeshoptDecoder(MeshoptDecoder);
this.hasMeshOptDecoder = true;
this.b3dmDecoder = new B3DMDecoder(this.gltfLoader);
this.cache = new LinkedHashMap();
this.register = {};
this.ready = [];
this.downloads = [];
this.nextReady = [];
this.nextDownloads = [];
}
/**
* To be called in the render loop or at regular intervals.
* launches tile downloading and loading in an orderly fashion.
*/
update() {
const self = this;
if (concurrentDownloads < 8) {
self._download();
}
self._loadBatch();
}
_scheduleDownload(f) {
this.downloads.unshift(f);
}
_download() {
if (this.nextDownloads.length == 0) {
this._getNextDownloads();
if (this.nextDownloads.length == 0) return;
}
while (this.nextDownloads.length > 0) {
const nextDownload = this.nextDownloads.shift();
if (!!nextDownload && nextDownload.shouldDoDownload()) {
nextDownload.doDownload();
}
}
return;
}
_meshReceived(cache, register, key, distanceFunction, getSiblings, level, uuid) {
this.ready.unshift([cache, register, key, distanceFunction, getSiblings, level, uuid]);
}
_loadBatch() {
if (this.nextReady.length == 0) {
this._getNextReady();
}
while(this.nextReady.length > 0){
const data = this.nextReady.shift();
if (!data) return;
const cache = data[0];
const register = data[1];
const key = data[2];
const mesh = cache.get(key);
if (!!mesh && !!register[key]) {
Object.keys(register[key]).forEach(tile => {
const callback = register[key][tile];
if (!!callback) {
callback(mesh);
register[key][tile] = null;
}
});
}
if (this.nextReady.length == 0) {
this._getNextReady();
}
}
return;
/* while (this.ready.length > 0) {
const data = this.ready.shift();
if (!data) return 0;
const cache = data[0];
const register = data[1];
const key = data[2];
const mesh = cache.get(key);
if (!!mesh && !!register[key]) {
Object.keys(register[key]).forEach(tile => {
const callback = register[key][tile];
if (!!callback) {
callback(mesh);
register[key][tile] = null;
}
});
}
return;
} */
}
_getNextDownloads() {
let smallestDistance = Number.POSITIVE_INFINITY;
let closest = -1;
for (let i = this.downloads.length - 1; i >= 0; i--) {
/* if (!this.downloads[i].shouldDoDownload()) {
this.downloads.splice(i, 1);
continue;
} */
if (!this.downloads[i].distanceFunction) { // if no distance function, must be a json, give absolute priority!
this.nextDownloads.push(this.downloads.splice(i, 1)[0]);
}
}
if (this.nextDownloads.length > 0) return;
for (let i = this.downloads.length - 1; i >= 0; i--) {
const dist = this.downloads[i].distanceFunction();
if (dist <= smallestDistance) {
smallestDistance = dist;
closest = i;
}
}
if (closest >= 0) {
const closestItem = this.downloads.splice(closest, 1).pop();
this.nextDownloads.push(closestItem);
const siblings = closestItem.getSiblings();
for (let i = this.downloads.length - 1; i >= 0; i--) {
if (siblings.map(s => s.uuid).includes(this.downloads[i].uuid)) {
this.nextDownloads.push(this.downloads.splice(i, 1).pop());
}
}
}
}
_getNextReady() {
let smallestDistance = Number.POSITIVE_INFINITY;
let closest = -1;
for (let i = this.ready.length - 1; i >= 0; i--) {
if (!this.ready[i][3]) {// if no distance function, must be a json, give absolute priority!
this.nextReady.push(this.ready.splice(i, 1)[0]);
}
}
if (this.nextReady.length > 0) return;
for (let i = this.ready.length - 1; i >= 0; i--) {
const dist = this.ready[i][3]() * this.ready[i][5];
if (dist <= smallestDistance) {
smallestDistance = dist;
closest = i
}
}
if (closest >= 0) {
const closestItem = this.ready.splice(closest, 1).pop();
this.nextReady.push(closestItem);
/* const siblings = closestItem[4]();
for (let i = this.ready.length - 1; i >= 0; i--) {
if (siblings.map(s=>s.uuid).includes(this.ready[i][6])) {
this.nextReady.push(this.ready.splice(i, 1).pop());
}
} */
}
}
/**
* Schedules a tile content to be downloaded
*
* @param {AbortController} abortController
* @param {string|Number} tileIdentifier
* @param {string} path
* @param {Function} callback
* @param {Function} distanceFunction
* @param {Function} getSiblings
* @param {Number} level
* @param {Boolean} sceneZupToYup
* @param {Boolean} meshZupToYup
* @param {Number} geometricError
*/
get(abortController, tileIdentifier, path, callback, distanceFunction, getSiblings, level, sceneZupToYup, meshZupToYup, geometricError) {
const self = this;
const key = _simplifyPath(path);
const realAbortController = new AbortController();
abortController.signal.addEventListener("abort", () => {
if (!self.register[key] || Object.keys(self.register[key]).length == 0) {
realAbortController.abort();
}
})
if (!path.includes(".b3dm") && !path.includes(".json") && !path.includes(".gltf") && !path.includes(".glb")) {
console.error("the 3DTiles cache can only be used to load B3DM, gltf and json data");
return;
}
if (!self.register[key]) {
self.register[key] = {};
}
if (!!self.register[key][tileIdentifier]) {
console.error(" a tile should only be loaded once");
}
self.register[key][tileIdentifier] = callback;
const cachedObject = self.cache.get(key);
if (!!cachedObject) {
this._meshReceived(self.cache, self.register, key, distanceFunction, getSiblings, level, tileIdentifier);
} else if (Object.keys(self.register[key]).length == 1) {
let downloadFunction;
if (path.includes(".b3dm")) {
downloadFunction = () => {
var fetchFunction;
if (!self.proxy) {
fetchFunction = () => {
return fetch(path, { signal: realAbortController.signal });
}
} else {
fetchFunction = () => {
return fetch(self.proxy,
{
method: 'POST',
body: path,
signal: realAbortController.signal
}
);
}
}
concurrentDownloads++;
fetchFunction().then(result => {
if (!result.ok) {
console.error("could not load tile with path : " + path)
throw new Error(`couldn't load "${path}". Request failed with status ${result.status} : ${result.statusText}`);
}
return result.arrayBuffer();
}).then(resultArrayBuffer => {
return this.b3dmDecoder.parseB3DM(resultArrayBuffer, (mesh) => { self.meshCallback(mesh, geometricError) }, sceneZupToYup, meshZupToYup);
}).then(mesh => {
self.cache.put(key, mesh);
self._checkSize();
this._meshReceived(self.cache, self.register, key, distanceFunction, getSiblings, level, tileIdentifier);
}).catch((e) => {
console.error(e)
}).finally(() => {
concurrentDownloads--;
});
}
} else if (path.includes(".glb") || path.includes(".gltf")) {
downloadFunction = () => {
var fetchFunction;
if (!self.proxy) {
fetchFunction = () => {
return fetch(path, { signal: realAbortController.signal });
}
} else {
fetchFunction = () => {
return fetch(self.proxy,
{
method: 'POST',
body: path,
signal: realAbortController.signal
}
);
}
}
concurrentDownloads++;
fetchFunction().then(result => {
if (!result.ok) {
console.error("could not load tile with path : " + path)
throw new Error(`couldn't load "${path}". Request failed with status ${result.status} : ${result.statusText}`);
}
return result.arrayBuffer();
}).then(async arrayBuffer => {
await _checkLoaderInitialized(this.gltfLoader);
this.gltfLoader.parse(arrayBuffer, null, gltf => {
gltf.scene.asset = gltf.asset;
if (sceneZupToYup) {
gltf.scene.applyMatrix4(this.zUpToYUpMatrix);
}
gltf.scene.traverse((o) => {
if (o.isMesh) {
if (meshZupToYup) {
o.applyMatrix4(this.zUpToYUpMatrix);
}
if (!!self.meshCallback) {
self.meshCallback(o, geometricError);
}
}
if (o.isPoints) {
if (!!self.pointsCallback) {
self.pointsCallback(o, geometricError);
}
}
});
self.cache.put(key, gltf.scene);
self._checkSize();
self._meshReceived(self.cache, self.register, key, distanceFunction, getSiblings, level, tileIdentifier);
});
}).catch((e) => {
console.error(e)
}).finally(() => {
concurrentDownloads--;
});
}
} else if (path.includes(".json")) {
downloadFunction = () => {
var fetchFunction;
if (!self.proxy) {
fetchFunction = () => {
return fetch(path, { signal: realAbortController.signal });
}
} else {
fetchFunction = () => {
return fetch(self.proxy,
{
method: 'POST',
body: path,
signal: realAbortController.signal
}
);
}
}
concurrentDownloads++;
fetchFunction().then(result => {
if (!result.ok) {
console.error("could not load tile with path : " + path)
throw new Error(`couldn't load "${path}". Request failed with status ${result.status} : ${result.statusText}`);
}
return result.json();
}).then(json => {
return resolveImplicite(json, path)
}).then(json => {
self.cache.put(key, json);
self._checkSize();
self._meshReceived(self.cache, self.register, key);
}).catch((e) => {
console.error(e)
}).finally(() => {
concurrentDownloads--;
});
}
}
this._scheduleDownload({
"shouldDoDownload": () => {
return !abortController.signal.aborted && !!self.register[key] && Object.keys(self.register[key]).length > 0;
},
"doDownload": downloadFunction,
"distanceFunction": distanceFunction,
"getSiblings": getSiblings,
"level": level,
"uuid": tileIdentifier
})
}
}
/**
* Invalidates all the unused cached tiles.
*/
clear(){
const temp = this.maxCachedItems;
this.maxCachedItems = 0;
this._checkSize();
this.maxCachedItems = temp;
}
/**
* unregisters a tile content for a specific tile, removing it from the cache if no other tile is using the same content.
* @param {string} path the content path/url
* @param {string|Number} tileIdentifier the tile ID
*/
invalidate(path, tileIdentifier) {
const key = _simplifyPath(path);
if (!!this.register[key]) {
delete this.register[key][tileIdentifier];
//this.register[key][tileIdentifier] = undefined;
//this._checkSize();
}
}
_checkSize() {
const self = this;
let i = 0;
while (self.cache.size() > self.maxCachedItems && i < self.cache.size()) {
i++;
const entry = self.cache.head();
const reg = self.register[entry.key];
if (!!reg) {
if (Object.keys(reg).length > 0) {
self.cache.remove(entry.key);
self.cache.put(entry.key, entry.value);
} else {
self.cache.remove(entry.key);
delete self.register[entry.key];
//self.register[entry.key] = undefined;
entry.value.traverse((o) => {
if (o.material) {
// dispose materials
if (o.material.length) {
for (let i = 0; i < o.material.length; ++i) {
o.material[i].dispose();
}
}
else {
o.material.dispose()
}
}
if (o.geometry) {
// dispose geometry
o.geometry.dispose();
}
});
}
}
}
}
}
async function _checkLoaderInitialized(loader) {
const self = this;
return new Promise((resolve) => {
const interval = setInterval(() => {
if ((!loader.hasDracoLoader || loader.dracoLoader) && (!loader.hasKTX2Loader || loader.ktx2Loader)) {
clearInterval(interval);
resolve();
}
}, 10); // check every 100ms
});
};
function _simplifyPath(main_path) {
var parts = main_path.split('/'),
new_path = [],
length = 0;
for (var i = 0; i < parts.length; i++) {
var part = parts[i];
if (part === '.' || part === '' || part === '..') {
if (part === '..' && length > 0) {
length--;
}
continue;
}
new_path[length++] = part;
}
if (length === 0) {
return '/';
}
var result = '';
for (var i = 0; i < length; i++) {
result += '/' + new_path[i];
}
return result;
}
export { TileLoader };