import { InformationSource } from '../reporter/information_source.js';
import { Reporter } from '../reporter/reporter.js';
import { Atom } from '../atom.js';
import { pick, UUIDRegex, URLRegex, unique } from '../lib.js';
import { Package } from '../package/package.js';
/** Base class for loadable components */
class Component extends InformationSource {
/**
* @param {Reporter} reporter
* @param {Object} settings
* @param {UUID} [settings.id]
* @param {string} [settings.name]
* @param {Boolean} [settings.debug]
*/
constructor(reporter, settings) {
super(reporter, pick(['id', 'name'], settings));
if (!this.constructor._exportName) {
throw new Error('Class constructor is missing a static exportName field');
}
this._resetContent();
// remove debug flag from settings
this._debug = settings.debug || false;
delete settings.debug;
// this object is used for export
this._settings = settings;
// events this class can emit
this.addEvent('status-change');
// subscribe to quality part status atoms
// and emit an event on change
for (let part of Component.parts) {
for (let quality of Component.qualities) {
// for (let quality of ['low','medium','high']) {
const statusAtom = this._status[part][quality];
statusAtom.on('change', ({ state, previousState, bubbleState }) => {
// if (this.constructor.name === 'MaterialCategoryType') {
// // bubbleState = false;
// console.log('on change MaterialCategoryType, bubbleState:', bubbleState);
// }
if (part === 'main' && quality === 'medium' && state === 'ready') {
this.report({ msg: `${quality} quality ${part} status: ${previousState} -> ${state}`, level: 'debug' });
}
if (state === 'error') {
console.error(this.label, `${quality} quality ${part} status: ${previousState} -> ${state}`);
}
if (bubbleState !== false) {
return this.emit('status-change', { quality, part, status: state, previousStatus: previousState, bubbleState });
}
else {
return true;
}
});
}
}
// make settings "gettable" from root level of component
for (let setting of Object.keys(this._settings)) {
if (setting === 'source') {
// skip - this is a special case with regards to the LoadableComponent
// which sets an augmented source object to the root later
continue;
}
// this[ setting ]
if (this[setting] === undefined && Object.getOwnPropertyDescriptor(this, setting) === undefined) {
Object.defineProperty(
this,
setting,
{
get: () => {
return this._settings[setting];
}
}
)
// this.setting
}
}
}
/**
* @static @interface
* @type {ExportLevel}
*/
static _exportLevel = 'root';
static get exportLevel() {
return this._exportLevel;
}
get exportLevel() {
return this.constructor.exportLevel;
}
/**
* @static @interface
* @type {Object}
* @property {string} singular
* @property {string} plural
*/
static _exportName = {
singular: 'component',
plural: 'components'
};
// default export name
static get exportName() {
return this._exportName.plural;
}
get exportName() {
return this.constructor.exportName;
}
/**
* @type {Array<LoadingQuality>}
*/
static qualities = ['medium']; // removed other qualities while we're not using them
/**
* @type {Array<ComponentPart>}
*/
static parts = ['UI', 'main', 'specs'];
/**
* Builds a component part quality atom
* @param {string} name
* @param {Reporter} reporter
* @returns {Atom}
*/
static partQualityStatusAtom(name, reporter) {
return new Atom(
reporter,
`${name} status`,
{
'not-loaded': ['loading', 'partially-loaded', 'ready', 'error'],
'loading': ['partially-loaded', 'ready', 'error'],
'partially-loaded': ['not-loaded', 'loading', 'ready', 'error'], // a package can start out empty (and so, ready) and then load a json and be "not-loaded"
'ready': ['not-loaded', 'loading', 'partially-loaded', 'error'], // in case a component is added to a tree during runtime
'error': []
},
'not-loaded'
);
}
/**
* Inner status tracking
* @protected
* @todo object props should not be hardcoded
*/
_status = {
main: {
// high: Component.partQualityStatusAtom(`${this.label} high quality main`, this._reporter),
medium: Component.partQualityStatusAtom(`${this.label} medium quality main`, this._reporter),
// low: Component.partQualityStatusAtom(`${this.label} low quality main`, this._reporter),
},
UI: {
// high: Component.partQualityStatusAtom(`${this.label} high quality UI`, this._reporter),
medium: Component.partQualityStatusAtom(`${this.label} medium quality UI`, this._reporter),
// low: Component.partQualityStatusAtom(`${this.label} low quality UI`, this._reporter),
},
specs: {
// high: Component.partQualityStatusAtom(`${this.label} high quality specs`, this._reporter),
medium: Component.partQualityStatusAtom(`${this.label} medium quality specs`, this._reporter),
// low: Component.partQualityStatusAtom(`${this.label} low quality specs`, this._reporter),
}
};
/**
* Component status
* @type {ComponentStatus}
* @todo object props should not be hardcoded
*/
get status() {
return {
main: {
// high: this._status.main.high.state,
medium: this._status.main.medium.state,
// low: this._status.main.low.state,
},
UI: {
// high: this._status.UI.high.state,
medium: this._status.UI.medium.state,
// low: this._status.UI.low.state,
},
specs: {
// high: this._status.specs.high.state,
medium: this._status.specs.medium.state,
// low: this._status.specs.low.state,
}
}
}
/** @type {Boolean} */
get materializable() {
// overwritten in positioned component and block instance
return this._settings.materialVariantGroup !== undefined;
}
/**
* Debug flag
* @type {Boolean}
*/
_debug = false;
/**
* 3D Object to use as placeholder in the scene
* @type {Object3D}
*/
mainPlaceholder;
/**
* @protected
* @type {Object}
*/
_settings;
/**
* @type {Object}
*/
get settings() {
return this._settings;
}
/**
* The name under which this component will be exported.
* @type {string}
*/
// exportName;
/**
* If "root" component be exported in the root of the exported
* json. If a "Component" it will be exported inline.
* @type {string}
* @default 'root'
*/
// exportEntry = 'root';
/**
* @protected
* @param {ComponentTree} _tree
*/
_tree;
static treeLock = true;
/**
* @protected
* @interface
* @method
*/
_onTreeSet() { }
/**
* @protected
* @interface
* @method
*/
_onTreeUnset() { };
/**
* @type {ComponentTree}
*/
get tree() {
return this._tree;
}
set tree(treeToSet) {
if (treeToSet === undefined) {
this._onTreeUnset();
this._tree = undefined;
} else {
if (this._tree !== undefined && this.constructor.treeLock === true) {
throw new Error(`${this.label} tree property already set.`)
}
/** @type {ComponentTree} */
this._tree = treeToSet;
this._onTreeSet();
}
}
/**
* The "usable" part of this component. The product. The fruit.
* @type {ComponentContent}
* @protected
* @todo object props should not be hardcoded
*/
_content = {};
get content() {
return this._content;
}
_resetContent = () => {
this._content = {
main: {
// low: null,
medium: null,
// high: null
},
UI: {
// low: null,
medium: null,
// high: null
},
specs: {
// low: null,
medium: null,
// high: null
}
};
}
/**
* Error register.
* @type {ComponentContent}
* @protected
* @todo object props should not be hardcoded
*/
_error = {
main: {
// low: null,
medium: null,
// high: null
},
UI: {
// low: null,
medium: null,
// high: null
},
specs: {
// low: null,
medium: null,
// high: null
}
};
get error() {
return this._error;
}
/**
* Set content and update status to "ready"
* @protected
* @method
* @param {ComponentPart} part
* @param {LoadingQuality} quality
* @param {any} content
* @returns {Promise<Array<any>>}
*/
_setContent(part, quality, content, bubbleState = true) {
this._content[part][quality] = content;
return this._status[part][quality].setState('ready', bubbleState);
}
/**
* Set error and update status to "error"
* @protected
* @method
* @param {ComponentPart} part
* @param {LoadingQuality} quality
* @param {Error} error
* @returns {Promise<Array<any>>}
*/
_setError(part, quality, error) {
if (this._error[part][quality] !== null) {
console.error(error);
throw new Error(`Can not set ${this.label} ${part} ${quality} to error state twice. New error ignored.`);
}
this._error[part][quality] = error;
return this._status[part][quality].setState('error');
}
// get mainContent() {
// return this._content.main;
// }
// get mainReadyContent() {
// // get highest quality ready content
// return this._content.main;
// }
// get UIContent() {
// return this._content.UI;
// }
// get UIReadyContent() {
// // get highest quality ready content
// return this._content.UI;
// }
clone(newSettings = {}) {
return new this.constructor(this._reporter, { ...this._settings, ...newSettings });
}
/**
* Relative value of states
* @type {Object<ComponentPartQualityStatus,number>}
*/
static loadStatusSortMap = {
'not-loaded': 1,
'loading': 2,
'partially-loaded': 3,
'ready': 4,
'error': 5,
};
/**
* Sorts loading status arrays high to low
* @static
* @param {Array<ComponentPartQualityStatus>} arr
* @returns {Array<ComponentPartQualityStatus>}
*/
static sortStatusArray(arr) {
return arr.slice().sort((a, b) => Component.loadStatusSortMap[b] - Component.loadStatusSortMap[a]);
}
/**
* Relative value of loading qualities
* @type {Object<LoadingQuality,number>}
*/
static qualitySortMap = {
high: 3,
medium: 2,
low: 1
};
/**
* Sorts quality arrays high to low
* @static
* @param {Array<LoadingQuality>} arr
* @returns {Array<LoadingQuality>}
*/
static sortQualityArray(arr) {
return arr.slice().sort((a, b) => Component.qualitySortMap[b] - Component.qualitySortMap[a]);
}
static highestQualityLoaded(contentObject) {
return contentObject.high || contentObject.medium || contentObject.low || null;
}
static unlink(val) {
if (Array.isArray(val)) {
return val.map(Component.unlink);
}
else if (val instanceof Package) {
// ignore
return undefined;
}
else if (val instanceof Component) {
return val.id;
}
else if (val instanceof URL) {
return val.href;
}
else if (typeof val === 'object') {
return Object.entries(val).reduce(
(objToReturn, [key, subVal]) =>
Object.assign(objToReturn, { [key]: Component.unlink(subVal) }),
{}
);
}
else {
return val;
}
}
/**
* Export to JSON
* @return {Object}
*/
toJSON() {
// this.report({
// msg: 'Export start'
// });
const selfExport = Object.entries(this._settings)
.reduce(
(obj, [prop, val]) => {
const unlinkedValue = Component.unlink(val);
if (unlinkedValue === undefined) {
return obj;
}
else {
obj[prop] = unlinkedValue;
return obj;
}
},
{
id: this.id,
name: this.name
}
);
// console.log(this.label, this.constructor.name)
if (this.constructor.name === 'Configuration') {
if (!this.assignedMaterials) {
console.warn('Not exporting assigned materials, configuration not build yet');
}
else {
const materialOverview = {};
for (let bi of this.blockInstances || []) {
materialOverview[bi.id] = {
assignables: [],
block: {
id: bi.block.id,
name: bi.block.name,
}
};
for (let assignedMatObj of this.assignedMaterials[bi.id] || []) {
const assignable = { assignments: [] };
for (let [mvgId, material] of Object.entries(assignedMatObj.mvg)) {
const mvg = this.pkg.findComponentById(mvgId);
const assignment = {
mvg: {
id: mvgId,
name: mvg.name
},
material: {
id: material.id,
name: material.name
}
};
assignable.assignments.push(assignment);
}
if (assignedMatObj.positionedMeshGroup) {
assignable.positionedMeshGroup = {
id: positionedMeshGroup.id,
name: positionedMeshGroup.id
}
}
if (assignedMatObj.positionedMesh) {
assignable.positionedMesh = {
id: positionedMesh.id,
name: positionedMesh.id
}
}
// assignable.block = {
// id: bi.block.id,
// name: bi.block.name,
// };
materialOverview[bi.id].assignables.push(assignable);
}
}
selfExport.materialOverview = materialOverview;
// console.log(materialOverview)
}
}
// if there is only one source, do not export the
// quality indicator "medium"
if (this._settings && this._settings.source && Object.keys(this._settings.source).length === 1) {
selfExport['source'] = this._settings.source.medium;
}
const exportObject = {
[this.exportName]: [selfExport]
};
// inline components have circular dependencies,
// so those are not exported
// if ( this.exportEntry === 'root' ) {
// getting around the type checker..
// if (this instanceof BuildableComponent) ..
for (let part of Component.parts) {
const partDependencies = this['dependencies'] ? Object.values(this['dependencies'][part]) : [];
if (part === 'main' && this['_exportOnlyDependencies']) {
const exportOnlyDeps = Object.values(this['_exportOnlyDependencies']);
if (exportOnlyDeps.length > 0) {
partDependencies.push(...exportOnlyDeps);
}
}
if (partDependencies.length > 0) {
const partDepsFlat = partDependencies.flat();
let order = [];
if (this.constructor.name === 'ComponentTree') {
order = this.constructor.importInstructions.map(obj => obj.cls);
}
else if (this._tree) {
order = this._tree.constructor.importInstructions.map(obj => obj.cls);
}
else {
order = partDepsFlat.filter(unique).map(obj => obj.constructor)
}
// console.log(order)
for (let cls of order) {
const clsDeps = partDepsFlat.filter(dep => dep.constructor === cls);
for (let dep of clsDeps) {
// for (let dep of partDependencies.flat()) {
// console.log('Export', dep.label)
if (dep.constructor.name === 'ComponentTree') {
this.report({ msg: 'Not exporting external component tree ' + ComponentTree.label });
continue;
}
else if (dep.exportName === 'configurations') {
exportObject.configurations = exportObject.configurations || [];
exportObject.configurations.push(dep.toJSON())
continue;
}
const depExport = dep.toJSON();
// console.log( dep.label, dep.exportLevel );
// console.log( depExport );
// if ( dep.exportEntry !== 'root' ) {
// // export inlined
// const depExportData = depExport[ dep.exportName ][ 0 ];
// // since the dependency is exported under this component
// // an entry specifying the super component's id is
// // superfluous
// // delete depExportData[ dep.componentName ];
// selfExport[ dep.exportName ] = selfExport[ dep.exportName ] || [];
// selfExport[ dep.exportName ].push( depExportData );
// }
// else {
// export in root
for (let [entry, components] of Object.entries(depExport)) {
if (dep.exportLevel !== 'root' && entry === dep.exportName) {
// console.log(JSON.stringify(exportObject))
selfExport[dep.exportName] = selfExport[dep.exportName] || [];
selfExport[dep.exportName].push(...components);
// remove the "old" id entry
const idIndex = selfExport[dep.exportName].indexOf(dep.id);
selfExport[dep.exportName].splice(idIndex, 1);
delete exportObject[dep.exportName];
// console.log(JSON.stringify(exportObject))
// console.log('Removed exported', dep.label, 'from root', selfExport)
}
else {
exportObject[entry] = exportObject[entry] || [];
const newComponents = components
.filter(c => exportObject[entry].find(existingComponent => existingComponent.id === c.id) === undefined)
;
exportObject[entry].push(...newComponents);
}
}
}
}
// console.log(exportObject)
}
}
// }
for (let [name, val] of Object.entries(exportObject)) {
if (Array.isArray(val)) {
val.sort((a, b) => (a.id > b.id) ? 1 : -1);
}
}
return exportObject;
};
destroyed = false;
destroy() {
// console.log('destroy', this.label);
if (this.destroyed !== false) {
console.warn(this.label, 'was already destroyed');
return;
}
this.destroyed = true;
for (let part of Component.parts) {
for (let quality of Component.qualities) {
const statusAtom = this._status[part][quality];
statusAtom.removeAllListeners();
if (this._content[part][quality]) {
if (typeof (this._content[part][quality].dispose) === 'function') {
this._content[part][quality].dispose();
// console.log('dispose', this.label, part, quality)
}
}
}
}
this._resetContent();
for (let setting of Object.keys(this.settings)) {
if(! ['options'].includes(setting)){
this.settings[setting] = undefined;
}
}
for (let prop of Object.keys(this)) {
// console.log('del props', this.label)
this[prop] = undefined;
}
if (super.destroy) {
super.destroy();
}
}
};
export { Component };