Source: component/component_tree.js

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 };