import { Component } from "../../component/component.js";
import { Reporter } from "../../reporter/reporter.js";
import { ComponentLoader } from "../loader/component_loader.js";
import {
checkPropTypes,
copyProps,
pause,
unique,
UUIDRegex,
} 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() {
let statusReport = {};
for (let part of Component.parts) {
statusReport[part] = {};
for (let quality of Component.qualities) {
statusReport[part][quality] = {};
}
}
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) {
statusReport[part] = {};
for (let quality of Component.qualities) {
statusReport[part][quality] = {
status: "ready",
dependencies: {},
};
}
// console.log( 'no deps')
} 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) {
const deps = this.directDependencies.reduce(
(arr, dep) => {
if (dep.allDependencies) {
return arr.concat(dep, ...dep.allDependencies);
} else {
return arr.concat(dep);
}
},
[],
).filter(unique);
// pkg is frozen on load by project
if (Object.isFrozen(this)) {
return deps;
} else {
this._allDependencies = deps;
}
}
return this._allDependencies;
}
/**
* All "lower" components on which this component depends directly
* @protected
* @type {Array<Component>}
*/
_directDependencies;
get directDependencies() {
if (!this._directDependencies) {
const deps = Component.parts
.map((part) => Object.values(this._dependencies[part]))
.flat() // flatten out part arrays
.flat() // flatten out alternative / integral list arrays
.filter(unique);
// pkg is frozen on load by project
//
if (Object.isFrozen(this)) {
return deps;
} else {
this._directDependencies = deps;
}
}
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 {ComponentLoader} [componentLoader=defaultLoader]
* @param {ComponentPart} [part = 'main']
* @param {LoadingQuality} [quality = 'medium']
* @param {Boolean} [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 };