import { Component } from '../../component/component.js';
import { Reporter } from '../../reporter/reporter.js';
import { ComponentLoader } from '../loader/component_loader.js';
import { checkPropTypes, copyProps, UUIDRegex, unique, pause } from '../../lib.js';
import { WrappedImage } from '../image/wrapped_image.js';
// import { MaterialSet } from '../material/material_set.js';
let defaultLoader = null;
/**
* Component that combines other components, but does not
* directly need to load data itself
*/
class BuildableComponent extends Component {
/**
* @typedef {('integral'|'alternatives')} ArrayParseType
*/
/**
* @typedef {Object} ParseInstruction
* @property {Boolean} [autoLinkDependencies]
* @property {Object<string,ArrayParseType>} [parse] - Specification of whether setting arrays should be parsed as lists or alternative options. Default is list.
*/
/**
* @param {Reporter} reporter
* @property {UUID} [data.id]
* @property {string} [data.name]
* @property {URL} [data.url]
* @property {WrappedImage} [data.thumbnail]
* @param {ParseInstruction} [instructions]
*/
constructor(reporter, settings, instructions = {}) {
//console.log( settings.blockInstances )
super(reporter, settings);
checkPropTypes(
settings,
{},
{
thumbnail: WrappedImage
}
);
//console.log( settings.blockInstances )
this._resetDependencies();
// subscribe to events from dependencies to
// automatically trigger status updates and builds
if (instructions.autoLinkDependencies !== false) {
// this method is overwritten in Package
this._autoLink(settings, instructions);
}
if (settings.assignable === true) {
//
}
}
/**
* for component tree every array specifies an integral list (not alternatives)
* @param {Object} settings
* @returns {Object}
*/
static autogenIntegralInstruction(settings) {
return {
parse: Object.entries(settings).reduce(
(obj, [setting, value]) => {
return Array.isArray(value)
? Object.assign(obj, { [setting]: 'integral' })
: obj
},
{}
)
}
}
_autoLink(settings, instructions) {
this.report({ msg: 'Auto-linking' });
this.removeDependencyListeners();
this._resetDependencies();
// fill the _dependencies field with settings that are
// Component class instances
for (let [key, val] of Object.entries(settings)) {
if (this._debug === true) {
console.log(this.label, `Process settings "${key}" =`, val);
}
// which type of dependency are we dealing with?
// almost always main, of course
const part = BuildableComponent.partSettings[key] !== undefined ? BuildableComponent.partSettings[key] : 'main';
// add a reference to every setting that instances Component
// in the _dependencies object
if (val instanceof Component) {
if (this._debug === true) {
console.log(this.label, `Simple setting, adding to _dependencies.${part}`);
}
this._dependencies[part][key] = val;
}
else if (Array.isArray(val)) {
if (this._debug === true) {
console.log(this.label, 'Setting value is an array');
}
// if the setting's value is an array, it must be (explicitly) specified
// whether the elements are an integral list or alternative values
if (instructions.parse?.[key] === undefined) {
throw new Error(`Unspecified array setting ${this.constructor.name}.${key}`);
}
// if the dependencies are hidden in objects, bring 'em out
// and only allow Components
let components = [];
for (let subVal of val) {
// when dealing with a settings array that has a non-Component object as value
// it is possible to specify more than one Component. This is an issue when the
// array elements are to be interpreted as alterntives, because these would
// essentially be small integral lists within the dependencies. Therefore, if
// this is a list of alternatives, only ONE Component per element/object
// is allowed.
if (typeof subVal === 'object' && !Array.isArray(subVal) && !(subVal instanceof Component)) {
if (this._debug === true) {
console.log(this.label, 'Subvalue', subVal, 'looks like an object');
}
const inObjComponents = Object.values(subVal).filter(subValVal => subValVal instanceof Component);
if (this._debug === true) {
console.log(this.label, 'Found', inObjComponents, 'components in subvalue');
}
switch (inObjComponents.length) {
case 0:
break;
case 1:
if (this._debug === true) {
console.log(this.label, 'Adding', inObjComponents[0], 'to ._dependencies');
}
components.push(inObjComponents[0]);
break;
default:
// more than one Component
if (instructions.parse[key] === 'alternatives') {
throw new Error(`${this.label}.key setting holds ${inObjComponents.length} Components, but 1 is the maximum`);
}
else {
if (this._debug === true) {
console.log(this.label, 'Adding', inObjComponents.length, 'in-object-dependencies to ._dependencies');
}
components.push(...inObjComponents);
}
}
}
else if (subVal instanceof Component) {
if (this._debug === true) {
console.log(this.label, 'Subvalue looks like Component. Adding', subVal, 'to _dependencies');
}
components.push(subVal);
}
}
if (instructions.parse[key] === 'integral') {
// if the array is to be parsed as an integral list, every element is added
// as a separate dependency (which must be loaded for this component to be ready)
for (let i = 0; i < components.length; i += 1) {
this._dependencies[part][`${key}-${i}`] = components[i];
}
}
else if (instructions.parse[key] === 'alternatives') {
// if the array is to be parsed as a list of alternatives, the array is
// added "as is" and only one of the elements needs to be ready for this
// component to be ready as well
this._dependencies[part][key] = components;
}
}
else if (typeof val === 'object') {
let i = 0;
for (let [subKey, subVal] of Object.entries(val)) {
i += 1;
if (subVal instanceof Component) {
this._dependencies[part][`${key}-${i}`] = subVal;
}
}
}
}
// Object.freeze(this._dependencies.main);
// Object.freeze(this._dependencies.UI);
// if ( this.name === 'mat1') {
// console.warn( this._dependencies )
// }
// when a change event is received from a dependency
// check whether anything should be done
const component = this;
for (let part of Component.parts) {
for (let dep of Object.values(this._dependencies[part]).flat()) {
if (this._debug) {
console.log(this.label, 'subscribe status change', dep.label)
}
this.autoBuildOnStatusChange(dep);
}
}
}
async _dependencyStatusChangeHandler(statusChangeData) {
await this.autoUpdateStatus();
if (this._debug) {
console.warn(this.status);
}
}
autoBuildOnStatusChange(dep) {
dep.on('status-change', this._dependencyStatusChangeHandler.bind(this), this.id);
}
removeDependencyListeners() {
for (let part of Component.parts) {
for (let dep of Object.values(this._dependencies[part]).flat()) {
if (dep.hasListener('status-change', this.id)) {
dep.removeListener('status-change', this.id);
}
}
}
}
// /**
// * When the component has finished its initialization
// * @returns {Promise<BuildableComponent>}
// */
// initialization;
/**
* Known UI settings (right now only thumbnail)
* @static
* @type {Array<string>}
*/
//static UIPartSettings = ['thumbnail'];
//static specsPartSettings = ['dimensionsImage'];
static partSettings = {
'thumbnail': 'UI',
'dimensionsImage': 'specs',
'*': 'main'
}
// static setMutuallyDependent(part, componentA, componentB) {
// componentA.addDependency(part, componentB);
// componentB.addDependency(part, componentA);
// }
addExportDependency(key, component) {
if (!this.dependsDirectlyOn(component)) {
this._exportOnlyDependencies[key] = component;
}
}
// /**
// * Whether the component should automatically build its content
// * when the required dependencies have been loaded
// * @private
// * @type {boolean}
// * @default true
// */
// _autoBuild = true;
// static updateQueue = Promise.resolve();
// _autoUpdateStatusChain() {
// BuildableComponent.updateQueue = BuildableComponent.updateQueue.then(this._autoUpdateStatus.bind(this))
// return BuildableComponent.updateQueue;
// }
/**
* Updates the component's status according to the status of
* its dependencies
* @method
*/
async autoUpdateStatus() {
const dependencyStatus = this.dependencyStatus;
if (this._debug === true) {
console.log('_autoUpdateStatus', this.label, dependencyStatus)
}
const callStartState = this.status;
for (let part of Component.parts) {
for (let quality of Component.qualities) {
if (this._debug === true) {
// console.debug(this.label, 'check', part, quality);
}
const initialState = this._status[part][quality].state;
if (callStartState[part][quality] !== initialState) {
// this.report({
// msg: `${part} ${quality} status changed during async auto update process from ${callStartState[part][quality]} to ${initialState}. Skipping update.`,
// level: 'debug'
// });
continue;
}
const intendedState = dependencyStatus[part][quality].status;
if (initialState === 'error') {
if (this._debug === true) {
console.log(this.label, 'Skip (comp in error state)');
}
continue;
}
if (
intendedState === 'ready'
&& initialState !== 'ready'
&& this._content[part][quality] === null
) {
// this.report({ msg: `Auto-building ${part} ${quality} quality` })
if (this._debug === true) {
console.log(this.label, `Auto building: ${initialState}->${intendedState}`);
}
let error = null;
try {
await this._build(part, quality, dependencyStatus[part][quality].dependencies);
}
catch (err) {
// console.warn('Auto-build error caught', err);
error = err;
}
const postChangeState = this._status[part][quality].state;
if (postChangeState !== 'ready' || error !== null) {
const errorMsg = `${this.label} status should've been "ready" after auto-build but is "${this._status[part][quality].state}" ${error ? error.message : ''}. ${postChangeState !== 'error' ? 'Going to error state.' : ''}`;
this.report({ msg: errorMsg, level: 'error' });
if (postChangeState !== 'error') {
this._setError(part, quality, error ? error : new Error(errorMsg));
if (this._debug === true) {
console.log('Component set to error state', part, quality);
}
throw error;
}
continue;
}
}
else if (intendedState !== initialState) {
let error = null;
if (this._debug === true) {
console.log(`Auto updating ${part} ${quality}: ${initialState}->${intendedState}`);
}
if (intendedState === 'error') {
this._error[part][quality] = new Error('Error in dependency chain');
}
try {
// it is entirely possible (likely even) that this state will be changed
// during this async call. When this happens, an error might be thrown in case
// the line below attempts to do an illegal state transition. It wasn't
// illegal when initiated, but it became illegal because of another transition
// that cam in between.
// For this reason, errors about state transitions are suppressed here.
await this._status[part][quality].setState(intendedState);
}
catch (err) {
if (err.message.toLowerCase().indexOf('transition') === -1) {
error = err;
}
else {
this.report({
msg: `Status update ${initialState}->${intendedState} failed, probably because the state changed intermediately to "${this._status[part][quality].state}"`,
level: 'debug'
});
}
}
const postChangeState = this._status[part][quality].state;
// and so, we only have an issue when the state failed to change at all
// or when another error was thrown
// note: it might, in fact, happen that the state is the same - it changed it the meantime though
// and so, remove that check as well
// if (postChangeState === initialState || error !== null) {
if (error !== null) {
const errorMsg = `Status change ${initialState}->${intendedState} failed. Current status: "${this._status[part][quality].state}".`;
this.report({ msg: errorMsg, level: 'error' });
if (postChangeState !== 'error') {
this._setError(part, quality, error ? error : new Error(errorMsg));
if (this._debug === true) {
console.log(`${this.label} set to error state`, part, quality);
}
}
continue;
}
}
}
}
// console.log( 'end _autoUpdateStatus', this.label )
}
// placeholder content would allow building a scene before anything is loaded
_placeHolderContent = null;
defaultContent = this._placeHolderContent;
/** @typedef {(Component|Array<Component>)} ComponentOrComponentArray */
/**
* @typedef {Object<string,ComponentOrComponentArray>} DependencyList
*/
/**
* @protected
* @type {Object}
* @property {DependencyList} main
* @property {DependencyList} UI
* @property {DependencyList} specs
*/
_dependencies;
_resetDependencies() {
this._dependencies = {
main: {},
UI: {},
specs: {} // should not be hardcoded
};
}
/**
* @type {Object}
* @property {DependencyList} main
* @property {DependencyList} UI
* @property {DependencyList} specs
*/
get dependencies() {
return this._dependencies;
}
_exportOnlyDependencies = {};
// _makeStatusObj = () =>
// Component.parts.reduce(
// (obj, part) =>
// )
get dependencyStatus() {
const statusReport = {
main: {
// high: {},
medium: {},
// low: {}
},
UI: {
// high: {},
medium: {},
// low: {}
},
specs: { // this object should not be hardcoded
// high: {},
medium: {},
// low: {}
}
};
for (let part of Component.parts) {
// console.log( this._dependencies, part )
// try {
// console.log( Object.entries(this._dependencies[ part ] ))
// }
// catch ( err )
// {
// console.log( err )
// console.log( this._dependencies, part, this._dependencies[ part ])
// }
const dependencyEntries = Object.entries(this._dependencies[part])
// .filter( ([ key, dep ]) => {
// return dep.exportEntry === 'root' || dep.exportEntry === undefined
// });
if (this._debug === true) {
console.log(part, 'dep entries', dependencyEntries, this._dependencies);
}
if (dependencyEntries.length === 0) {
// console.log( 'no deps')
// no dependencies!
statusReport[part] = {
// high: {
// status: 'ready',
// dependencies: {}
// },
medium: {
status: 'ready',
dependencies: {}
},
// low: {
// status: 'ready',
// dependencies: {}
// }
};
}
else {
if (this._debug === true) {
console.log('deps!', dependencyEntries);
}
for (let quality of Component.qualities) {
if (this._debug === true) {
console.debug('dep quality', part, quality)
}
const inQualityStateDistribution = {};
const relevantDependencyList = {};
for (let [setting, dep] of dependencyEntries) {
if (Array.isArray(dep)) {
// always check the "main" part of the dependency, regardless of the part
// of the component the report is for
const altDepStates = dep.map(altDep => altDep.status.main[quality]);
const highestAltStatus = Component.sortStatusArray(altDepStates)[0];
relevantDependencyList[setting] = dep[altDepStates.indexOf(highestAltStatus)];
inQualityStateDistribution[highestAltStatus] = (inQualityStateDistribution[highestAltStatus] || 0) + 1;
}
else {
if (this._debug === true) {
console.debug(dep.label, dep.status)
}
inQualityStateDistribution[dep.status.main[quality]] = (inQualityStateDistribution[dep.status.main[quality]] || 0) + 1;
relevantDependencyList[setting] = dep;
}
}
if (this._debug === true) {
console.debug(this.label, inQualityStateDistribution, relevantDependencyList)
}
const inQualityStates = Object.keys(inQualityStateDistribution);
if (inQualityStates.length === 1) {
statusReport[part][quality].status = inQualityStates[0];
if (inQualityStates[0] === 'ready') {
statusReport[part][quality].dependencies = relevantDependencyList;
}
}
else if (inQualityStates.find(s => s === 'error')) {
statusReport[part][quality].status = 'error';
}
else if (inQualityStates.find(s => s === 'loading')) {
statusReport[part][quality].status = 'loading';
}
else if (inQualityStates.find(s => s === 'ready') || inQualityStates.find(s => s === 'partially-loaded')) {
statusReport[part][quality].status = 'partially-loaded';
}
else {
statusReport[part][quality].status = 'not-loaded';
}
}
}
}
return statusReport;
}
/**
* The distinct material variant groups of this components and its dependencies
* @protected
* @type {Array<MaterialSet>}
*/
_applicableMaterialVariantGroups;
get applicableMaterialVariantGroups() {
if (!this._applicableMaterialVariantGroups) {
const mvgs = [];
for (let dep of this.allDependencies) {
if (dep.materialVariantGroup && mvgs.indexOf(dep.materialVariantGroup) === -1) {
mvgs.push(dep.materialVariantGroup);
}
}
this._applicableMaterialVariantGroups = mvgs;
}
return this._applicableMaterialVariantGroups;
}
/**
* Dependencies that have a variable material (not necessarily directly assignable)
* @protected
* @type {Array<Component>}
*/
_materializableDependencies;
get materializableDependencies() {
if (!this._materializableDependencies) {
this._materializableDependencies = this.allDependencies.filter(dep => dep.materializable === true);
}
// console.log(this.label, this.allDependencies.map(dep => dep.label + ' ' + dep.materializable))
return this._materializableDependencies;
}
/**
* Dependencies that can be assigned a material (does not necessarily affect the component itself)
* @protected
* @type {Array<PositionedMesh|PositionedMeshGroup>}
*/
_assignableDependencies;
get assignableDependencies() {
if (!this._assignableDependencies) {
this._assignableDependencies = this.allDependencies.filter(dep => dep.assignable === true);
}
return this._assignableDependencies;
}
/**
* All "lower" components on which this component depends, either directly or indirectly
* @protected
* @type {Array<Component>}
*/
_allDependencies;
get allDependencies() {
if (!this._allDependencies) {
this._allDependencies = this.directDependencies.reduce(
(arr, dep) => {
if (dep.allDependencies) {
return arr.concat(dep, ...dep.allDependencies);
}
else {
return arr.concat(dep);
}
},
[]
).filter(unique);
}
return this._allDependencies;
}
/**
* All "lower" components on which this component depends directly
* @protected
* @type {Array<Component>}
*/
_directDependencies;
get directDependencies() {
if (!this._directDependencies) {
this._directDependencies = Component.parts
.map(part =>
Object.values(this._dependencies[part]))
.flat() // flatten out part arrays
.flat() // flatten out alternative / integral list arrays
.filter(unique);
}
return this._directDependencies;
}
/** @returns {Boolean} */
dependsDirectlyOn(component) {
return this.directDependencies.includes(component);
}
/** @returns {Boolean} */
dependsOn(component) {
return this.allDependencies.includes(component);
// const allDeps = this.directDependencies;
// return allDeps.includes(component) ||
// allDeps.find(dep =>
// dep instanceof BuildableComponent ? dep.dependsOn(component) : false
// );
}
highestMainQuality() {
const dependencyStatus = this.dependencyStatus;
if (dependencyStatus.high.status !== 'ready') {
return { quality: 'high', dependencies: dependencyStatus.dependencies };
}
else if (dependencyStatus.medium.status !== null) {
return { quality: 'medium', dependencies: dependencyStatus.dependencies };
}
else if (dependencyStatus.low.status !== null) {
return { quality: 'low', dependencies: dependencyStatus.dependencies };
}
else {
return null;
}
}
/**
* @async
* @interface
* @protected
* @method
* @param {LoadingQuality} quality
* @param {Object<string,Component>} dependencies - Dependencies that are loaded with the corresponding quality
* @returns {Promise<Component>}
*/
async _build(part, quality, dependencies) {
this.report({ level: 'notice', msg: 'Override BuildableComponent._build interface method!' });
this._setContent(part, quality, '-');
return this;
}
_buildPromises = {
main: {
// high: null,
medium: null,
// low: null
},
UI: {
// high: null,
medium: null,
// low: null
},
specs: {
// high: null,
medium: null,
// low: null
}
};
_rebuild(part = 'main') {
for (let quality of Component.qualities) {
if (this.status[part][quality] === 'ready') {
this._build('main', quality);
}
}
}
/**
* @method
* @param {Object} [instructions]
* @param {ComponentLoader} [instructions.componentLoader=defaultLoader]
* @param {ComponentPart} [instructions.part = 'main']
* @param {LoadingQuality} [instructions.quality = 'medium']
* @param {Boolean} [instructions.highPriority = false]
* @returns {Promise<BuildableComponent>}
*/
async build({ componentLoader, part = 'main', quality = 'medium', highPriority = false } = {}) {
if (this._buildPromises[part][quality]) {
// console.log(this.label, part, 'Already loaded/loading, returning old promise' )
// this.report({ msg: 'Already loaded/loading, returning old promise', level: 'debug' })
return this._buildPromises[part][quality];
}
// if ( part === 'specs' && ! Object.keys( this._settings ).find( setting => BuildableComponent.specsPartSettings.includes(setting))) {
// this._setContent(part,quality,null);
// }
let loader = componentLoader;
if (loader) {
if (!(loader instanceof ComponentLoader)) {
throw new Error('Loader does not instance class ComponentLoader');
}
else {
this.report({ msg: 'Using specified loader' });
}
}
else if (this._tree && this._tree.loader) {
this.report({ msg: 'Using component loader from component tree' });
loader = this._tree.loader;
}
else {
this.report({ msg: `No loader specified, using default loader` });
if (!defaultLoader) {
this.report({ msg: `Creating default loader` });
console.log(`Creating default loader`);
defaultLoader = new ComponentLoader(this._reporter, { name: 'Default loader' });
}
loader = defaultLoader;
}
this._buildPromises[part][quality] = loader.build(this, part, quality, highPriority);
//console.log( this._buildPromises[part][quality] )
return this._buildPromises[part][quality];
}
destroy() {
this.removeDependencyListeners();
if ( super.destroy) {
super.destroy();
}
}
}
export { BuildableComponent };