import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js';
import * as THREE from 'three';
import { Howl } from 'howler';
import { colorSpace } from '@ud-viz/utils_browser';
/**
* @typedef {object} RenderDataConfig - Contains path, anchor, scale and rotation.
* @property {string} anchor - Values: center | max | min | center_min
* @property {{x:number,y:number,z:number}} scale - Object's local scale
* @property {{x:number,y:number,z:number}} rotation - Object's local rotation
* @property {string} path - Path to the 3D data file
*/
/**
* @typedef {object} SoundsConfig - Contains path
* @property {string} path - Path to the audio file
*/
/**
* @typedef {object} AssetManagerConfig - Contains configs of assets.
* @property {Object<string,SoundsConfig>} sounds {@link SoundsConfig}
* @property {Object<string,RenderDataConfig>} renderData {@link RenderDataConfig}
*/
/**
* Default material used by native objects
*/
const DEFAULT_MATERIAL = new THREE.MeshLambertMaterial({
color: 0x00ff00,
});
/**
* @classdesc Load async assets (gltf, JSON, ...) from a config file and create render data, sounds, and native objects.
*/
export class AssetManager {
/**
* Initialize the native render data.
*/
constructor() {
/** @type {AssetManagerConfig} */
this.conf = null;
/** @type {Object<string,SoundsConfig>} */
this.sounds = {};
/** @type {Object<string,RenderData>}*/
this.renderData = {};
this.initNativeRenderData();
}
/**
* Return new renderData corresponding to the id passed
*
* @param {string} idRenderData - Id of the renderData
* @returns {RenderData} - A clone of the renderData object
*/
createRenderData(idRenderData) {
if (!this.renderData[idRenderData])
console.error('no render data with id ', idRenderData);
return this.renderData[idRenderData].clone();
}
/**
* Create a a new Howl object with the given idSound and options.
*
* @param {string} idSound - Id of sounds in config
* @param {object} [options={}] - Arguments to create Howl object.
* @param {boolean} options.loop - Set to true to automatically loop the sound forever.
* @returns {Howl} - Used to control the sound
*/
createSound(idSound, options = {}) {
const pathSound = this.sounds[idSound].path;
if (!pathSound) console.error('no sound with id ', idSound);
return new Howl({
src: pathSound,
preload: true,
html5: true,
loop: options.loop || false,
onload: () => {
console.log(pathSound, ' has loaded');
},
onloaderror: (id, err) => {
// refer here https://github.com/goldfire/howler.js
console.warn('error while loading ', pathSound, err);
// eslint-disable-next-line no-undef
console.info(Howler._codecs, ' are the supported Howler codecs');
switch (err) {
case 1:
console.warn(
"The fetching process for the media resource was aborted by the user agent at the user's request."
);
break;
case 2:
console.warn(
'A network error of some description caused the user agent to stop fetching the media resource, after the resource was established to be usable.'
);
break;
case 3:
console.warn(
'An error of some description occurred while decoding the media resource, after the resource was established to be usable.'
);
break;
case 4:
console.warn(
'The media resource indicated by the src attribute or assigned media provider object was not suitable.'
);
break;
default:
console.warn('unknow error');
}
},
});
}
/**
* Build native objects (procedural objects) and stores them in `this.renderData` object.
*
*/
initNativeRenderData() {
const geometryBox = new THREE.BoxGeometry();
const cube = new THREE.Mesh(geometryBox, DEFAULT_MATERIAL);
this.renderData['cube'] = new RenderData(cube, { anchor: 'center_min' });
const geometrySphere = new THREE.SphereGeometry(1, 32, 32);
const sphere = new THREE.Mesh(geometrySphere, DEFAULT_MATERIAL);
this.renderData['sphere'] = new RenderData(sphere);
const geometryTorus = new THREE.TorusGeometry(10, 0.1, 16, 100);
const torus = new THREE.Mesh(geometryTorus, DEFAULT_MATERIAL);
this.renderData['torus'] = new RenderData(torus);
const geometryQuad = new THREE.PlaneGeometry();
const quad = new THREE.Mesh(geometryQuad, DEFAULT_MATERIAL);
this.renderData['quad'] = new RenderData(quad);
}
/**
* Load a 3D render data from a config. Then create the {@link LoadingView} process.
*
* @param {AssetManagerConfig} config configuration details
* @param {HTMLDivElement} [parentDiv=document.body] where to add the loadingView
* @returns {Promise} promise processed to load assets
*/
loadFromConfig(config = {}, parentDiv = document.body) {
this.conf = config;
/** @type {LoadingView}*/
const loadingView = new LoadingView();
parentDiv.appendChild(loadingView.domElement);
/** @type {Promise[]} */
const promises = [];
if (config.renderData) {
const idLoadingRenderData = '3D';
loadingView.addLoadingBar(idLoadingRenderData);
const loader = new GLTFLoader();
promises.push(
new Promise((resolve, reject) => {
let count = 0;
for (const idRenderData in config.renderData) {
const renderDataConfig = config.renderData[idRenderData];
loader.load(
renderDataConfig.path,
(result) => {
result.scene.name = idRenderData;
this.renderData[idRenderData] = new RenderData(
result.scene,
renderDataConfig,
result.animations
);
count++;
// Update loading bar
loadingView.updateProgress(
idLoadingRenderData,
(100 * count) / Object.keys(config.renderData).length
);
// Check if finish
if (count == Object.keys(config.renderData).length) {
console.log('render data loaded ', this.renderData);
resolve();
}
},
null,
reject
);
}
})
);
}
if (config.sounds) {
this.sounds = this.conf.sounds;
}
return new Promise((resolve) => {
Promise.all(promises).then(function () {
loadingView.domElement.remove();
resolve();
});
});
}
}
/**
* @class A view in which loading bar can be added
*/
class LoadingView {
/**
* It creates a root HTML, then adds HTML elements for the loading bar.
*/
constructor() {
/** @type {HTMLDivElement} */
this.domElement = document.createElement('div');
this.domElement.classList.add('assetsLoadingView');
/** @type {HTMLDivElement} */
this.parentLoadingBar = document.createElement('div');
this.parentLoadingBar.classList.add('parent_loading_bar_asset');
this.domElement.appendChild(this.parentLoadingBar);
/** @type {HTMLDivElement} */
const label = document.createElement('label');
label.classList.add('loadingLabel_Assets');
label.innerText = 'Loading assets';
this.parentLoadingBar.appendChild(label);
/**
* Loading bars
*
@type {Object<string,HTMLDivElement>} */
this.loadingBars = {};
}
/**
* Updates the progress bar of a loading bar with the given id.
* Sets the width of the loading bar with the given percent.
*
* @param {string} id of the loading bar
* @param {number} percent the new percent of the bar
*/
updateProgress(id, percent) {
this.loadingBars[id].style.width = percent + '%';
}
/**
* Add a loading bar to this view with a label equals to the id
*
* @param {string} id if of the loading bar to add
*/
addLoadingBar(id) {
const parent = document.createElement('div');
parent.classList.add('barBackground-Assets');
const progress = document.createElement('div');
progress.classList.add('progressBar-Assets');
parent.appendChild(progress);
const label = document.createElement('div');
label.innerText = id;
parent.appendChild(label);
this.loadingBars[id] = progress;
this.parentLoadingBar.appendChild(parent);
}
}
/**
* @class Contains a THREE.Object3D and an array of animations
*/
export class RenderData {
/**
* It takes an object3D and an optional animations object, and sets the object3D and animations
* properties of the object
*
* @param {THREE.Object3D} childObject3D - The object to add.
* @param {RenderDataConfig} [renderDataConfig = {}] - Contains path, anchor, scale and rotation.
* @param {THREE.AnimationClip[]} [animations=null] - An array of animations.
*/
constructor(childObject3D, renderDataConfig = {}, animations = null) {
/**
* Parent object of the object3D to set up
*
@type {THREE.Object3D} */
this.object3D = new THREE.Object3D();
const anchor = renderDataConfig.anchor;
const scale = renderDataConfig.scale;
const rotation = renderDataConfig.rotation;
// Anchor point
const bbox = new THREE.Box3().setFromObject(childObject3D);
switch (anchor) {
case 'center':
{
const center = bbox.min.lerp(bbox.max, 0.5);
childObject3D.position.sub(center);
}
break;
case 'max':
{
childObject3D.position.sub(bbox.max);
}
break;
case 'min':
{
childObject3D.position.sub(bbox.min);
}
break;
case 'center_min':
{
const centerMin = bbox.min.clone().lerp(bbox.max, 0.5);
centerMin.z = bbox.min.z;
childObject3D.position.sub(centerMin);
}
break;
default:
}
// Scale
if (scale) {
const newScale = childObject3D.scale;
newScale.x *= scale.x;
newScale.y *= scale.y;
newScale.z *= scale.z;
childObject3D.scale.copy(newScale);
}
// Rotation
if (rotation) {
const newRotation = childObject3D.rotation;
newRotation.x += rotation.x;
newRotation.y += rotation.y;
newRotation.z += rotation.z;
childObject3D.rotation.copy(newRotation);
}
this.object3D.add(childObject3D);
this.object3D.traverse(function (child) {
if (child.geometry) {
child.castShadow = true;
child.receiveShadow = true;
}
if (child.material) {
if (child.material.map) child.material.map.colorSpace = colorSpace;
child.material.side = THREE.FrontSide;
child.material.needsUpdate = true;
}
});
this.object3D.name = childObject3D.name + '_set_up_';
this.animations = animations;
}
/**
* It clones the object3D and then clones all of the materials in the object3D
*
* @returns {RenderData} A new RenderData object with a cloned object3D and the same animations.
*/
clone() {
const cloneObject = this.object3D.clone();
cloneObject.traverse((child) => {
if (child.material) {
child.material = child.material.clone();
child.material.needsUpdate = true;
}
});
return new RenderData(cloneObject, {}, this.animations);
}
dispose() {
if (this.object3D && this.object3D.parent)
this.object3D.parent.remove(this.object3D);
}
}