import { Project } from "../project.js";
import { Reporter } from "../reporter/reporter.js";
import { Component } from "./component.js";
import { unique, UUIDRegex, URLRegex, checkPropTypes, pick, copyProps, omit } from '../lib.js';
import { BuildableComponent } from "../package/component/buildable_component.js";
import {
Vector2,
Vector3,
Quaternion,
Color,
PMREMGenerator,
WebGLRenderer
} from '../../node_modules/three/build/three.module.js';
import { ComponentLoader } from "../package/loader/component_loader.js";
// import { Configuration } from "../configurator/configuration.js";
/**
* @typedef {typeof Component} ComponentClass
*/
/**
* The ComponentTree, which is itself a buildable
* @extends BuildableComponent
*/
class ComponentTree extends BuildableComponent {
/**
* @param {Reporter} reporter
* @param {Object} settings
* @param {Object} instructions
* @param {Array<ComponentTree>} [instructions.externalTrees]
* @param {ComponentLoader} [instructions.loader]
* @param {Array<Object<string,ComponentClass>>} [instructions.import]
* @param {Boolean} [instructions.autoLinkDependencies = true]
* @param {WebGLRenderer} [instructions.renderer]
*/
constructor(reporter, settings = {}, instructions = {}) {
const componentsToAdd = [];
const importInstructions = instructions.import || [];
for (let importInstruction of importInstructions) {
const importName = importInstruction.cls.exportName;
if (settings[importName] !== undefined) {
componentsToAdd.push(...settings[importName]);
}
settings[importName] = [];
}
const nonEmptyArrays = Object.keys(settings).filter(setting => Array.isArray(settings[setting]) && settings[setting].length > 0);
if (nonEmptyArrays.length > 0) {
throw new Error(`Component tree found no import instructions for settings: ${nonEmptyArrays.join(', ')}`);
}
//console.log( settings.blockInstances )
//console.log( componentsToAdd )
super(
reporter,
settings,
{
autoLinkDependencies: instructions.autoLinkDependencies !== undefined ? instructions.autoLinkDependencies : true,
...BuildableComponent.autogenIntegralInstruction(settings)
}
);
//console.log(settings.blockInstances)
//console.log( componentsToAdd )
//console.log(this.blockInstances.length)
// this._componentAddRights = 'protected';
this._tree = this;
this._componentsById = {};
if (instructions.renderer) {
this.renderer = instructions.renderer;
this.pmremGenerator = new PMREMGenerator(this.renderer);
}
checkPropTypes(
instructions,
{},
{
externalTrees: val => {
if (!Array.isArray(val)) {
return 'Not an array.';
}
for (let i = 0; i < val.length; i += 1) {
const et = val[i];
if (!(et instanceof ComponentTree)) {
return `Instruction externalTrees[${i}] is not a ComponentTree, but a ${et.constructor.name}`;
}
}
return true;
},
loader: ComponentLoader
}
);
this.addEvent('change');
this.addEvent('import-complete');
this._importInstructions = importInstructions;
this._importInlined = importInstructions
.filter(instruction => instruction.cls.exportLevel === 'inline')
.reduce(
(obj, instruction) =>
Object.assign(obj, { [instruction.cls.exportName]: instruction.cls }),
{}
);
if (instructions.externalTrees) {
this._externalTrees = instructions.externalTrees;
// Component makes settings "gettable" from root level of component
// but for this object, this should include the external trees
const aggregatedTreeSettings = [...Object.keys(settings), ...(instructions.externalTrees.map(et => Object.keys(et.settings)).flat())].filter(unique);
// console.log( aggregatedTreeSettings );
const allTreeValues = {};
for (let setting of aggregatedTreeSettings) {
Object.defineProperty(
allTreeValues,
setting,
{
get: () => {
const localSetting = settings[setting];
// console.log( localSetting )
const externalSettings = instructions.externalTrees.map(extTree => extTree[setting]);
// console.log( externalSettings )
const allValues = [localSetting, ...externalSettings].filter(val => val !== undefined).filter(unique);
// console.log( allValues)
const returnArr = [];
for (let value of allValues) {
if (Array.isArray(value)) {
returnArr.push(...value);
}
else {
returnArr.push(value);
}
}
// console.log( returnArr)
return returnArr;
}
}
)
}
this.inclusive = allTreeValues;
}
this.loader = instructions.loader || new ComponentLoader(this._reporter);
if (componentsToAdd.length > 0) {
// console.log('componentsToAdd',componentsToAdd)
this.addComponent(...componentsToAdd);
// const addedClasses = componentsToAdd.map( comp => comp.constructor).filter(unique);
// this.emit('change', { type: 'components-imported', cls: addedClasses, tree: this });
// this._postChangeHandler();
}
}
_tree;
/**
* External trees that components in this tree can depend on
* @protected
* @type {Array<ComponentTree>}
*/
_externalTrees;
/**
* The classes (constructors) that need to be imported inline,
* documented as any, bc of jsdoc/typescript incompatibility
* @protected
* @type {Object<string,ComponentClass>}
*/
_importInlined;
/**
* During initialization the component itself
* @type {('protected'|'public')}
*/
// _componentAddRights;
/**
* @type {ComponentLoader}
*/
loader;
/**
* @private
* @type {Object<UUID,Component>}
*/
_componentsById;
/**
* Renderer from project
* @type {WebGLRenderer}
*/
renderer;
/**
* @type {PMREMGenerator}
*/
pmremGenerator;
/**
* Find a Component by Id in this class or its external trees
* @param {UUID} id
* @returns {(Component|undefined)}
*/
findComponentById(id) {
const component = this._componentsById[id];
if (component) {
return component;
}
else if (Array.isArray(this._externalTrees)) {
for (const extDep of this._externalTrees) {
const externalComponent = extDep.findComponentById(id);
if (externalComponent) {
return externalComponent;
}
}
}
return undefined;
}
/**
* @param {Component} component
* @returns {Array<Component>}
*/
findDependingComponents(component) {
const dependingComponents = [];
for (let componentArray of Object.values(this.settings).filter(member => Array.isArray(member))) {
// for (let componentArray of Object.values(this.componentArrayMap)) {
for (let elem of componentArray) {
if (elem instanceof BuildableComponent) {
for (let part of Component.parts) {
// 'pkg-set': []
if (Object.values(elem.dependencies[part]).indexOf(component) !== -1) {
dependingComponents.push(elem);
continue;
}
}
}
}
}
return dependingComponents;
}
/**
* @protected
* @param {Component} component
*/
_throwIfDependingComponents(component) {
const dependencies = this.findDependingComponents(component);
if (dependencies.length > 0) {
throw new Error(`Component is depended upon by ${dependencies.map(d => d.constructor.name + ' ' + d.name || d.id).join(', ')}`);
}
}
hasComponent(component) {
if (this._settings[component.exportName] && this._settings[component.exportName].indexOf(component) > -1) {
return true;
}
else if (this._externalTrees && this._externalTrees.find(extTree => extTree[component.exportName] && extTree[component.exportName].indexOf(component) > -1)) {
return true;
}
else {
// console.log(component.label)
return false;
}
}
/**
* @protected
* @param {Component} component
* @param {...Component} moreComponents
*/
addComponent(component, ...moreComponents) {
// const args = [...arguments];
// _addComponent expects multiple arguments spread out
this._addComponent(component, ...moreComponents);
// the postChangeHandler expects an array
this._postChangeHandler([component, ...moreComponents]);
}
/**
* @protected
* @param {Component} component
* @param {...Component} moreComponents
*/
// lastAddType = null;
// lastAddTypeCount = 1;
// startT = 0;
// totalCalls = 0;
_addComponent(component, ...moreComponents) {
// this.totalCalls += 1;
// if (component.constructor.name !== this.lastAddType) {
// const time = new Date().getTime() - this.startT;
// console.log(this.lastAddTypeCount, 'added in', time, 'ms =>', this.lastAddTypeCount / time, 'c/ms');
// this.lastAddType = component.constructor.name;
// this.lastAddTypeCount = 1;
// console.log('time',)
// this.startT = new Date().getTime();
// console.log(`Start adding a ${component.constructor.name}`);
// }
// else {
// this.lastAddTypeCount += 1;
// }
this.report({ msg: `Adding a ${component.constructor.name}`, level: 'debug' });
// hacky!
// needs improvement by refactoring of argument structure
// re-using the "moreComponents" array as flag for dependency inclusion
let autoIncludeDependencies = true;
if ( Array.isArray(moreComponents) && moreComponents[ 0] === false ){
autoIncludeDependencies = false;
moreComponents = undefined;
}
/**
* The array field in this class to which the component
* should be added
*/
const treeComponentArray = this._settings[component.exportName];
// console.log( 'extTrees', this._externalTrees )
if (this._externalTrees && this._externalTrees.find(extTree => extTree[component.exportName] && extTree[component.exportName].indexOf(component) > -1)) {
this.report({ msg: `Component already in external tree ${component.label}`, level: 'debug' });
}
else {
if (!treeComponentArray) {
throw new Error(`${this.label} Can not add unknown component class: ${component.constructor.name} -> ${component.exportName}`);
}
// if (component instanceof BuildableComponent) {
if ((autoIncludeDependencies === true && component instanceof BuildableComponent ) || component.constructor.name === 'Block' || component.constructor.name === 'MeshGroup' ) {
for (let part of Component.parts) {
for (let [setting, dependency] of Object.entries(component.dependencies[part]) || []) {
if (Array.isArray(dependency) && dependency.length > 0) {
const newDeps = dependency.filter(dep => this.hasComponent(dep) === false)
this.report({ msg: `Adding ${newDeps.length} new dependencies of ${dependency.length} total for ${component.constructor.name}.${setting}`, level: 'debug' });
newDeps.forEach(altDep => {
this._addComponent(altDep);
});
}
else {
if (!(dependency instanceof Component)) {
throw new Error('Dependency not an instance of Component!');
}
try {
if (this.hasComponent(dependency) === false) {
this.report({ msg: `Adding dependency: ${component.constructor.name}.${setting}`, level: 'debug' });
this._addComponent(dependency);
}
}
catch (err) {
console.error(err, dependency)
throw err
}
}
}
}
}
// check also externals
if (treeComponentArray.indexOf(component) > -1) {
this.report({ msg: `Component already in tree ${component.label}`, level: 'debug' });
}
else {
const existingComponent = this._componentsById[component.id];
if (existingComponent) {
throw new Error('Component id already in tree: ' + existingComponent.label);
}
treeComponentArray.push(component);
this._componentsById[component.id] = component;
if (!(component instanceof ComponentTree)) {
/** @type {ComponentTree} */
component.tree = this;
}
this.report({ msg: `Added ${component.label}`, level: 'debug' });
// console.log(this._settings[component.exportName])
for (let part of Component.parts) {
for (let quality of Component.qualities) {
const currentState = this._status[part][quality].state;
if (currentState === 'ready') {
this._status[part][quality].setState('partially-loaded');
}
}
}
}
}
if (moreComponents) {
for (let singleComponent of moreComponents) {
this._addComponent(singleComponent);
}
}
return true;
};
_postChangeHandler(components) {
console.assert(Array.isArray(components), `component_tree._postChangeHandler() expects an array, not a ${components.constructor.name}`);
this._autoLink(
this._settings,
BuildableComponent.autogenIntegralInstruction(this._settings)
);
const addedClasses = components.map(comp => comp.constructor).filter(unique);
this.emit('change', { type: 'autolinked-dependencies', tree: this, cls: addedClasses });
}
/**
* @param {Component} component
* @param {Boolean} [includeDependencies]
*/
removeComponent(component, includeDependencies) {
const treeComponentArray = this._settings[component.exportName];
if (!treeComponentArray) {
throw new Error(`Unknown component class ${component.constructor.name}`);
}
this._throwIfDependingComponents(component);
const arrIndex = treeComponentArray.indexOf(component);
if (arrIndex === -1) {
throw new Error(`${component.constructor.name} is not included in tree`);
}
treeComponentArray.splice(arrIndex, 1);
delete this._componentsById[component.id];
if (includeDependencies && component instanceof BuildableComponent) {
for (let dependency of component.dependencies) {
if (this.findDependingComponents(dependency).length === 0) {
try {
this.removeComponent(dependency);
}
catch (err) {
throw new Error(`Could not remove dependency ${dependency.name || dependency.id}. ${err.message}`);
}
}
}
}
this._postChangeHandler([component]);
this.report({ msg: `Removed ${component.label}` });
this.emit('change', { type: 'component-removed', cls: [component.constructor], component, tree: this });
};
/**
* Converts a JSON/text settings object to an object with built objects
* that can be used to instantiate new Components
* @static
* @param {ComponentTree} tree
* @param {string} prop
* @param {any} val
* @returns {any}
*/
static linkSetting(tree, prop, val) {
if (Array.isArray(val)) {
return val.map(subVal => ComponentTree.linkSetting(tree, prop, subVal));
}
else if (typeof val === 'object') {
const keys = Object.keys(val);
if (keys.length === 2 && ['x', 'y'].every(coord => keys.includes(coord))) {
return new Vector2(val.x, val.y);
}
else if (keys.length === 3 && ['x', 'y', 'z'].every(coord => keys.includes(coord))) {
return new Vector3(val.x, val.y, val.z);
}
else if (keys.length === 4 && ['_x', '_y', '_z', '_w'].every(coord => keys.includes(coord))) {
return new Quaternion(val._x, val._y, val._z, val._w);
}
else if (keys.length === 3 && ['r', 'g', 'b'].every(coord => keys.includes(coord))) {
if (val.r > 1 || val.g > 1 || val.b > 1) {
return new Color((val.r / 255), (val.g / 255), (val.b / 255))
}
else {
return new Color(val.r, val.g, val.b)
}
}
else if (tree._importInlined[prop]) {
const linkedObject = ComponentTree.linkJSONObj(tree, val);
const obj = new tree._importInlined[prop](
tree._reporter,
linkedObject
);
return obj;
}
else {
return Object.entries(val).reduce(
(objToReturn, [key, subVal]) =>
Object.assign(objToReturn, { [key]: ComponentTree.linkSetting(tree, prop, subVal) }),
{}
);
}
}
else if (val === undefined) {
return undefined
}
else if (typeof (val) === 'string' && prop !== 'id' && val.match(UUIDRegex) !== null) {
const referencedComponent = tree.findComponentById(val);
if (!referencedComponent) {
throw new Error(`Missing dependency "${prop}": ${val}`);
}
return referencedComponent;
}
else if (typeof (val) === 'string' && val.match(URLRegex) !== null) {
try {
return new URL(val);
}
catch (err) {
return val;
}
}
else if (typeof (val) === 'string' && val.length === 2 && val.match(/[\+|-][x|y|z]/)) {
return Project.standardQuaternions[val];
}
else {
if (prop.toLowerCase().substr(-3) === 'map' || prop === 'thumbnail') {
this.report({
level: 'warning',
msg: `Seems like imported property ${prop} should be linked, but its value is not a (valid) UUID.`,
});
}
else if (prop === 'url') {
this.report({
level: 'warning',
msg: `Seems like imported property ${prop} should be a valid URL, but it isn't recognized as one.`,
});
}
return val;
}
}
/**
* @statuc
* @param {ComponentTree} tree
* @param {Object} obj - Object to link
* @returns {Object}
*/
static linkJSONObj(tree, obj) {
return Object.entries(obj)
.reduce(
(newObj, [prop, val]) => {
const linkedVal = ComponentTree.linkSetting(tree, prop, val);
if (linkedVal !== undefined) {
newObj[prop] = linkedVal;
}
return newObj;
},
{}
);
}
_instantiateLinkedComponentFromJSONObject(singleComponentJSONObject, cls) {
let linkedComponentJSONObj = null;
try {
linkedComponentJSONObj = ComponentTree.linkJSONObj(
this,
singleComponentJSONObject
);
}
catch (err) {
console.log(singleComponentJSONObject)
err.message += ' while importing ' + cls.exportName;
throw new Error(err)
}
// component class expects sources to be wrapped in
// a quality map
if (linkedComponentJSONObj.source && linkedComponentJSONObj.source.path) {
linkedComponentJSONObj.source = {
medium: linkedComponentJSONObj.source
};
}
const newComponent = new cls(
this._reporter,
linkedComponentJSONObj,
{ tree: this }
);
return newComponent;
}
static createFromJSON(JSONObject, reporter, pkg, instructions = {}) {
let settings = {};
// delete section headers
for (let key of Object.keys(JSONObject)) {
if (key.substr(-7).toLowerCase() === 'section') {
delete JSONObject[key];
}
}
const importSettingProps = this.importInstructions.map(obj => {
return obj.cls.exportName;
});
for (let [prop, val] of Object.entries(JSONObject)) {
if (!importSettingProps.includes(prop)) {
settings[prop] = val;
}
}
if (JSONObject.pkg) {
if (!pkg) {
throw new Error('Missing pkg argument');
}
else if (pkg.id !== JSONObject.pkg) {
throw new Error('Wrong pkg argument');
}
else {
// this part parses the non-component-array settings
// such as a configuration thumbnail
settings = ComponentTree.linkJSONObj(
pkg,
omit(['pkg'], settings)
);
settings.pkg = pkg;
}
}
const newThis = new this(reporter, settings, instructions);
// console.log(JSONObject)
newThis.importJSON(JSONObject);
return newThis;
}
/**
* Import from JSON
* @param {Object} JSONObject
* @returns {Array<Component>}
*/
importJSON(JSONObject) {
const startTime = new Date().getTime();
const id = JSONObject.id;
console.debug('Import JSON', id)
if (JSONObject.id && JSONObject.id !== this.id) {
// this is a "complete" export, not just components
// so first a new "this" must be created
// return ComponentTree.createFromJSON(JSONObject);
throw new Error(`Look like a complete export. Did you mean to call ${this.constructor.name}.createFromJSON()?`);
}
this.report({
msg: 'Start importing new JSON object'
});
let error = null;
const newComponents = [];
const configurations = [];
for (let instruction of this._importInstructions) {
if (JSONObject[instruction.cls.exportName] !== undefined) {
this.report({
msg: `Found ${instruction.cls.exportName} entry in JSON obj`
});
// console.log(`Found ${instruction.cls.exportName} entry in JSON obj`, instruction.cls)
if (instruction.cls === Component || instruction.cls.prototype instanceof Component) {
const dataToImport = JSONObject[instruction.cls.exportName];
// if (instruction.cls.prototype instanceof InfoComponent) {
// this.report({
// msg: `Importing (single) InfoComponent`
// });
// const newComponent = this._instantiateLinkedComponentFromJSONObject(dataToImport, instruction.cls);
// newComponents.push(newComponent);
// this._addComponent(newComponent);
// else {
if (instruction.cls.name === 'Configuration') {
// console.log('import config', dataToImport)
// if ( dataToImport.pkg !== this.id ) {
// throw new Error('Config and package do not match')
// }
for (let componentJSONObj of dataToImport) {
configurations.push(componentJSONObj);
}
}
else {
console.assert(Array.isArray(dataToImport));
this.report({
msg: `Importing ${dataToImport.length} component${dataToImport.length > 1 ? 's' : ''}`
});
for (let componentJSONObj of dataToImport) {
try {
const newComponent = this._instantiateLinkedComponentFromJSONObject(componentJSONObj, instruction.cls);
// do not auto include dependencies - slow!
// edit: including them again, this led to an error in configuration._build whereby
// the id of positioned meshes could not be found
this._addComponent(newComponent, false);
newComponents.push(newComponent);
}
catch (err) {
error = err;
break;
}
}
if (error) {
break;
}
}
}
}
}
// const addedClasses = newComponents.map( comp => comp.constructor).filter(unique);
// console.warn(this.label, 'import complete');
// console.log(this)
const basicEndTime = new Date().getTime();
if (error === null) {
this.report({ level: 'info', msg: `Basic import complete in ${basicEndTime - startTime}ms` });
}
else {
this.report({ level: 'error', msg: `Basic import failed` });
throw error;
}
// this.emit('change', { type: 'components-imported', cls: addedClasses, tree: this });
this._postChangeHandler(newComponents);
this.emit('import-complete', { tree: this });
for (let configurationData of configurations) {
const config = ComponentTree.Configuration.createFromJSON(
{
...configurationData
},
this._reporter,
this
);
// eigenlijk is configuration geen "echte" tree, als in
// dat het bestaat uit meer dan alleen components.. doet zelf ook vriuj
// veel - in de constructor zelfs
// dirty, but this way the constructor get executed on the
// components that were just loaded
const configCopy = config.copy();
newComponents.push(configCopy);
this._settings.configurations = this._settings.configurations || [];
this._settings.configurations.push(configCopy)
}
// console.log('done, total comp add calls', this.totalCalls)
const fullEndTime = new Date().getTime();
if (error === null) {
this.report({ level: 'notice', msg: `Full import complete in ${fullEndTime - startTime}ms` });
return newComponents;
}
else {
this.report({ level: 'error', msg: `Full import incomplete` });
throw error;
}
console.log('done')
};
toJSON() {
const exp = super.toJSON();
const importClasses = this.constructor.importInstructions.map(obj => obj.cls);
const exportNames = importClasses.map(cls => cls.exportName);
const inlinedClasses = importClasses.filter(cls => cls.exportLevel === 'inline');
// console.log(exp)
// only export what is in the import instructions
const pickedExp = pick(
exportNames,
exp
);
// console.log(pickedExp)
const objToReturn = {};
Object.assign(
objToReturn,
exp[this.exportName][0],
);
Object.assign(
objToReturn,
pickedExp
);
// remove the inlined exports
if (this.constructor.name === 'Package') {
for (let inlinedClass of inlinedClasses) {
delete objToReturn[inlinedClass.exportName];
}
}
// console.log(objToReturn)
if (this.pkg) {
objToReturn.pkg = this.pkg.id;
}
return objToReturn;
}
destroy() {
if ( typeof super.destroy === 'function' ) {
super.destroy()
}
}
}
export { ComponentTree };