Source: loaders/PrefabBundleLoader.js

import { ResolvablePromise, ResolvableCachedObject } from '@uino/base-thing';
import { Utils } from '../common/Utils';
import { MathUtils } from '../math/MathUtils';
import { SceneResourceType } from '../const';
import { Entity } from '../objects/Entity';
import { Object3D } from '../objects/Object3D';
import { SceneLoaderOld } from '../loaders/SceneLoaderOld'

const __ = {
	private: Symbol('private'),
}

const cIdentityMat4 = MathUtils.createMat4();
const cBundleRootObjectKeyPrefix = '__prefab_root__';

// #region Private Functions

// Mark as prefab object.
function _markPrefabObject(object) {
	if (object.isSubObject === undefined) {
		Object.defineProperty(object, 'isSubObject', {
			get: function () {
				return true;
			}
		});

		// Set the unique id
		object.external = object.external || {};
		object.external['uuid'] = object.external['uuid'] || MathUtils.generateUUID();
	}
}

// Mark as prefab root object.
function _markPrefabRootObject(object, bundle) {
	const key = `${cBundleRootObjectKeyPrefix}_${bundle.uuid}`;

	if (object[key] === undefined) {
		Object.defineProperty(object, key, {
			get: function () {
				return true;
			}
		});
	}
}

// #endregion

/**
 * Create 3D object.
 * @callback OnPrefabBundleCreateObject
 * @param {BaseObjectInitialOptions} options The options.
 * @param {Function} load The load complete callback function.
 * @returns {THING.Object3D}
 */

/**
 * Create prefab object.
 * @callback OnPrefabBundleCreatePrefabObject
 * @param {BaseObjectInitialOptions} options The options.
 * @param {Function} load The load complete callback function.
 * @returns {THING.Entity}
 */

/**
 * The prefab bundle.
 * @typedef {Object} PrefabBundle
 * @property {OnPrefabBundleCreateObject} createObject Create 3D object.
 * @property {OnPrefabBundleCreatePrefabObject} createPrefabObject Create prefab object.
 */

/**
 * @class PrefabBundleLoader
 * The prefab bundle.
 * @memberof THING
 * @private
 */
class PrefabBundleLoader {

	/**
	 * The prefab bundle resource loader, prefab can create multiple times.
	 */
	constructor() {
		this[__.private] = {};
		let _private = this[__.private];

		_private.componentExternalDataMap = new WeakMap();

		_private.caches = new ResolvableCachedObject();

		_private.prefabFileCache = {};
		_private.jsFileCache = {};
	}

	// #region Private

	_markObjects(owner) {
		// Mark all children with prefab flag
		owner.children.traverse(child => {
			_markPrefabObject(child);
		});
	}

	_copyBody(to, from) {
		// Copy body resource but keep some info
		let userData = to.bodyNode.getUserData();
		let matrixWorld = to.matrixWorld;

		// Update body resource
		to.bodyNode.copy(from.bodyNode);

		// Copy style
		to.body.style = from.body.style;

		// Resume info
		to.bodyNode.setUserData(userData);
		to.matrixWorld = matrixWorld;
	}

	_copyOwner(to, from) {
		this._copyBody(to, from);

		// Make owner
		Object.defineProperty(to, 'isOwner', {
			get: function () {
				return true;
			}
		});

		// Copy some attributes
		to.tags = from.tags;

		// Clone components for owner
		to.copyCompnents(from);
	}

	_buildCacheKey(bundle, options) {
		return bundle.url;
	}

	_tryLoadFromCache(bundle, load, options = {}) {
		let useSceneCache = Utils.parseValue(options['useSceneCache'], true);
		if (!useSceneCache) {
			return false;
		}

		let _private = this[__.private];

		let key = this._buildCacheKey(bundle, options);
		let cache = _private.caches.get(key);
		if (!cache) {
			return false;
		}

		// Wait for resource loaded
		cache.pendingPromise.then(() => {
			// Get options
			let owner = options['owner'];

			// Copy owner info
			this._copyOwner(owner, cache.owner);

			// Clone children
			let promises = cache.owner.children.toArray().map(object => {
				return object.cloneAsync(true, null, options);
			});

			// We must wait all object clone finished
			Promise.all(promises).then((objs) => {
				objs.forEach(obj => {
					owner.add(obj, { attachMode: false });
				});
			}).then(() => {
				this._markObjects(owner);

				// Notify load from cache completed
				if (load) {
					load();
				}
			});
		});

		return true;
	}

	_hasCache(bundle, options = {}) {
		let _private = this[__.private];

		let key = this._buildCacheKey(bundle, options);
		return _private.caches.has(key);
	}

	_createCache(bundle, options = {}) {
		let _private = this[__.private];

		let key = this._buildCacheKey(bundle, options);
		return _private.caches.set(key, { owner: null });
	}

	_extractChildren(owner, cache) {
		// Create owner as prefab object(cache always use editor mode)
		cache.owner = new Entity({ parent: null, isEditor: true });

		// Copy owner info
		this._copyOwner(cache.owner, owner);

		// Build clone options
		let options = {
			attachMode: false,
			isEditor: true
		};

		// Make transform to origin for cache owner
		let matrixWorld = owner.matrixWorld;
		owner.matrixWorld = cIdentityMat4;

		// Clone all children
		let promises = owner.children.toArray().map(child => {
			return child.cloneAsync(true, cache.owner, options);
		});

		// Resume transform
		owner.matrixWorld = matrixWorld;

		// We must wait all object clone finished
		return Promise.all(promises);
	}

	_updateCache(owner, bundle, options = {}) {
		return new Promise(async (resolve, reject) => {
			let _private = this[__.private];

			let key = this._buildCacheKey(bundle, options);
			let cache = _private.caches.get(key);

			// Extract and clone children
			this._extractChildren(owner, cache).then(async () => {
				// Make sure all children load finished
				await owner.query('*').waitForComplete();

				// Load prefab object completed
				resolve();
				cache.pendingPromise.resolve();
			});
		})
	}

	_loadObject(bundle, data, load, options = {}) {
		let _private = this[__.private];

		// Get options
		let owner = options['owner'];
		let isEditor = options['isEditor'];
		let useSceneCache = false;

		// Create or get the root object as parent object
		_markPrefabRootObject(owner, bundle);

		// Create cache
		if (useSceneCache) {
			// We are going to create cache first
			if (!this._hasCache(bundle, options)) {
				this._createCache(bundle, options);
			}
		}

		// Load prefab resource into object
		let sceneLoader = new SceneLoaderOld();
		sceneLoader.parse(SceneResourceType.Object, data,
			// Load
			async () => {
				if (useSceneCache) {
					await this._updateCache(owner, bundle, options);
				}

				this._markObjects(owner);

				// Notify all resources load completed
				if (load) {
					load();
				}
			},
			// Progress
			() => {
			},
			// Error
			(ev) => {
				Utils.error(ev);
			},
			// Options
			{
				isEditor,
				basePath: bundle.url,
				owner,
				// Save external data of each components
				onGetComponentExternalData: function (object, name, external) {
					let info = _private.componentExternalDataMap.get(object);
					if (info) {
						info.components[name] = external;
					}
					else {
						let components = {};
						components[name] = external;

						_private.componentExternalDataMap.set(object, {
							components
						});
					}
				},
				// Get class type with bundle id
				onGetClassType: function (res) {
					let type = `${res.type}_${bundle.uuid}`;

					return Utils.getRegisteredClasses()[type];
				},
				// Get component class type with bundle id
				onGetComponentClassType: function (res) {
					let result = null;

					const componentMap = bundle.componentMap;
					if (componentMap) {
						result = componentMap[res.type];
					}

					if (!result) {
						result = Utils.getRegisteredClasses()[`${res.type}_${bundle.uuid}`];
					}

					return result;
				}
			}
		);
	}

	_createObject(bundle, sceneData, options = {}, load) {
		let owner = options['owner'];
		if (!owner) {
			// Copy options without callbacks
			let ownerOptions = Object.assign({}, options);
			ownerOptions['complete'] = null;

			// Create owner object with options(without callbacks)
			owner = new Object3D(ownerOptions);
		}

		let loadOptions = {
			owner,
			isEditor: options['isEditor'],
			useSceneCache: false
		};

		let onLoad = () => {
			if (options['complete']) {
				options['complete']({ object: owner });
			}

			if (load) {
				load();
			}
		}

		// Try to load from cache first
		if (!this._tryLoadFromCache(bundle, onLoad, loadOptions)) {
			this._loadObject(bundle, sceneData, onLoad, loadOptions);
		}

		return owner;
	}

	_linkPrefabObject(prefab, entrance) {
		// Link export properties
		if (Utils.isFunction(entrance.getExportProperties)) {
			let properties = entrance.getExportProperties();
			if (properties) {
				Object.keys(properties).forEach(key => {
					Object.defineProperty(prefab, key, {
						enumerable: true,
						configurable: true,
						set(value) {
							properties[key] = value;
						},
						get() {
							return properties[key];
						}
					});
				});
			}
		}

		// Link export functions
		if (Utils.isFunction(entrance.getExportFunctions)) {
			let functions = entrance.getExportFunctions();
			if (functions) {
				Object.keys(functions).forEach(funcName => {
					prefab[funcName] = function () {
						return functions[funcName].apply(prefab, arguments);
					}
				});
			}
		}
	}

	_buildPrefabObject(data, bundle, sceneData) {
		let entrance = data.entrance ? Utils.getRegisteredClass(data.entrance) : null;

		return {
			entrance,
			// Prefab object would always create 'Entity' type
			createObject: (options = {}, load) => {
				return bundle.prefab.createPrefabObject(options, load);
			},
			// Create 'Entity' as root
			createPrefabObject: (options = {}, load) => {
				let resolvePromise = null;

				let owner = options['owner'];
				if (!owner) {
					resolvePromise = new ResolvablePromise();

					// Copy options without callbacks
					let ownerOptions = Object.assign({}, options);
					ownerOptions['complete'] = null;
					ownerOptions['bundle'] = bundle;
					ownerOptions['onLoadAsync'] = () => {
						return resolvePromise;
					}

					// Create owner object with options(without callbacks)
					owner = new Entity(ownerOptions);
				}

				let bundleOptions = Object.assign({}, options);
				bundleOptions['owner'] = owner;

				return this._createObject(bundle, sceneData, bundleOptions, () => {
					// Link export properties and functions
					if (entrance) {
						this._linkPrefabObject(owner, entrance);
					}

					if (resolvePromise) {
						resolvePromise.resolve();
					}

					if (load) {
						load();
					}
				});
			}
		};
	}

	// Load files.
	_loadFiles(bundle, files, options) {
		let _private = this[__.private];
		const autoCompile = Utils.parseBoolean(options['autoCompile'], false);
		return new Promise(async (resolve, reject) => {
			try {
				let modules = []
				for (let i = 0; i < files.length; i++) {
					const file = files[i];
					let fileUrl = bundle.url._appendPath(file);
					let module = _private.jsFileCache[fileUrl];
					if (!module) {
						if (autoCompile) {
							module = await bundle.loadCodeFromUrl(fileUrl, options);
						}
						else {
							module = await Utils.importScript(fileUrl);
						}
					}
					modules[i] = module;
				}
				resolve(modules);
			}
			catch (error) {
				reject(error);
			}
		});
	}

	// #endregion

	onDispose() {
		let _private = this[__.private];

		_private.componentExternalDataMap = new WeakMap();

		_private.caches.clear(object => {
			object.owner.destroy();
		});
	}

	/**
	 * When load prefab bundle.
	 * @param {THING.Bundle} bundle The bundle object.
	 * @param {Object} options The load options.
	 * @param {Function} load When load callback function.
	 * @param {Function} error When error callback function.
	 * @private
	 */
	onLoad(bundle, options) {
		let _private = this[__.private];
		// , load, error
		let prefabFileName = bundle.info.main;
		if (prefabFileName) {
			// Get the prefab file url
			let prefabFileUrl = bundle.url._appendPath(prefabFileName);

			(async () => {
				try {
					let data = _private.prefabFileCache[prefabFileUrl];
					if (!data) {
						data = _private.prefabFileCache[prefabFileUrl] = await Utils.loadJSONFile(prefabFileUrl);
					}
					// Load dependence files
					let files = data['files'];
					if (files) {
						let componentMap = {};
						const modules = await this._loadFiles(bundle, files, options);
						modules.forEach(module => {
							if (module) {
								for (const key in module) {
									const cls = module[key];
									componentMap[key] = cls;
								}
							}
						});
						bundle.componentMap = componentMap;
					}

					// Load objects from root
					let sceneData = data['root'];
					if (sceneData) {
						// Build prefab hookers
						bundle.prefab = this._buildPrefabObject(data, bundle, sceneData);
					}
					options.onComplete();
				}
				catch (error) {
					options.onError(error);
				}
			})();
		}
		else {
			options.onComplete();
		}
	}

}

export { PrefabBundleLoader }