Source: project.js

import { Package } from './package/package.js';
import { Timer } from './timer.js';
import { Reporter } from './reporter/reporter.js';
import { Actor } from './actor/actor.js';
import { View } from './view/view.js';
import { Block } from './package/block/block.js';
import { DefaultView } from './view/default_view.js';
import { InformationSource } from './reporter/information_source.js';
import { Configurator } from './configurator/configurator.js';
import { LoadingBase } from './package/loader/loading_base.js';
import { BlockInstance } from './configurator/block_instance.js';
// import { WrappedImage } from './package/image/wrapped_image.js';
// import { GLTF } from './package/mesh/gltf.js';
import { ComponentLoader } from './package/loader/component_loader.js';
import { Scene, WebGLRenderer, Quaternion, Euler, Vector2, Vector3 } from '../node_modules/three/build/three.module.js';
import { makeImage } from './lib.js';
import defaultRendererSettings from './default_settings/renderer.js';
import { SingleBlockInstance } from './actor/single_block_instance.js';
import { ServerConnection } from './server/server_connection.js';
import { StateTracker } from './state_tracker.js';
// import { Transform } from './transform/transform.js';
import { pick } from "./lib.js"
import { Component } from './component/component.js';
import { Theme } from './package/theme/theme.js';
import { Configuration } from './configurator/configuration.js';
import { ImageExporter } from './export/export_image.js';

// App has 1 Scene
// App has n View
// App has n Packages
// App has n Configurator

// Configurator = Package => Config => Object3D




// App moet projecten inladen. 

// Er moet toch iets van een base.productbuilder.com komen, die aan loaders kan vertellen welke packages en materiallibs waar staan.
// Wellicht zou dit met git hooks kunnen werken: package is een repo, repo ergens planten -> meldt zich aan bij packagebase.


/**
 * Base class 
 * @constructor
 * 
 * @mermaid
 *   graph TD;
 *     ComponentTree-->Package;
 *     ComponentTree-->Configuration;
 *     GeometryFile-->Geometry;
 *     Geometry-->Mesh;
 *     WrappedImage-->WrappedTexture;
 *     WrappedTexture-->WrappedMaterial;
 *     WrappedMaterial-->MaterialCategory;
 *     WrappedMaterial-->MaterialSet;
 *     MaterialCategory-->MaterialSet;
 *     WrappedMaterial-->Mesh;
 *     MaterialSet-->Mesh;
 *     Mesh-->MeshGroup;
 *     Mesh-->PositionedMesh;
 *     MeshGroup-->PositionedMeshGroup;
 *     PositionedMesh-->Block;
 *     PositionedMeshGroup-->Block;
 *     ConnectorType-->Connector;
 *     Connector-->Block;
 *     Block-->Package;
 *     Connector-->Configuration;
 *     Block-->Configuration;
 *     Package-->Configuration;
 *     Configuration-->Project;
 */

/**
 * @class Project
 * An actor should not be removed during runtime, or undo/redo will mess up
 */
class Project extends StateTracker {


    /**
     * @param {Reporter} reporter
     * @param {Object} settings
     * @param {URL} [settings.server]
     * @param {Object} [settings.rendererSettings]
     * @param {number} [settings.reconnectTime = 5]
     * @param {number} [settings.maxStateCount = 5]
     * @param {function} [settings.onEvent] Event listener from the front-end client (the UI/app)
     */

    constructor(reporter, settings = {}) {

        super(reporter, settings);

        this.addEvent( 'price' );

        window.onerror = (message, source, lineno, colno, error) => {
            this.report({
                msg: 'Uncaught error: ' + message,
                level: 'error'
            });
            console.error(error);
        }

        Object.defineProperty(window, 'uuid', {
            get: InformationSource.uuid
        });

        const project = this;

        // handler for events from the UI
        this.frontEndClientEventListener = settings.onEvent || (() => '');

        // origins that are allowed to use this app's iframe api
        this.allowedIFrameAPIOrigins = settings.allowedIFrameOrigins;

        this.exportConfig = function () {

            var project = this;
            var config;

    
                window.exportConfig = function () {

                    for( let configurator of project.configurators ){

                        if( configurator.visible ){

                            config = JSON.stringify(pick(
                                    ['info', 'blockInstances', 'connections', 'materialAssignments', 'themes'],
                                    configurator.configuration.toJSON()
                                )   
                            )
                            console.debug('Exported config', config);
                        }
                    }
    
                }

                
            

        }

        this.exportConfig()


        this.rendererSettings = {
            ...defaultRendererSettings,
            ...(settings.rendererSettings || {})
        };

        // console.debug( this.rendererSettings )

        //default renderer for the perspective camera
        this.renderer = new WebGLRenderer();

        // Loop through the settings object and assign values to the renderer properties
        // This is needed because applying the settings directly in new WebGLRenderer does not seam to work!
        for (const key in  this.rendererSettings) {
            if ( this.rendererSettings.hasOwnProperty(key) && this.renderer.hasOwnProperty(key)) {
                this.renderer[key] = this.rendererSettings[key];
            }
            if( key === "name"){
                this.renderer[key] = this.rendererSettings[key]
            }
        }

        //console.log( this.renderer )

        this.renderer.setPixelRatio(window.devicePixelRatio);
        this.renderer.setClearColor(0x000000, 0);

        //renderer for the orthographic camera
        this.rendererOrtho = new WebGLRenderer(this.rendererSettings);
        this.rendererOrtho.setPixelRatio(window.devicePixelRatio);

        Project.maxAnisotropy = this.renderer.capabilities.getMaxAnisotropy()

        this.timer = new Timer(this._reporter);

        this.timer.on(
            'update',
            async () => {

                if (this.bodyChangeHandlePromise === null) {
                    // console.log('update views')
                    for (let view of this.views) {
                        view.update();
                    }
                }
                else {
                    // for (let transform of Object.values(this.transforms)) {
                    //     transform.relinkObject3Ds();
                    // }

                    for (let view of this.views) {

                        const projectData = this.configurators.map(configurator => ({
                            boundingBox: configurator.configuration.boundingBox,
                            configurationId: configurator.configuration.id,
                            id: configurator.id
                        }));

                        view.hideDimensions()

                        view.onSceneUpdate(projectData); // also updates view
                    }

                    let stateChange = false;
                    let newState = {};

                    for (let actor of this.actors) {

                        if (actor.visible === false) {
                            continue;
                        }

                        newState[actor.slug] = actor.cursor;

                        if (this.state?.[actor.slug] === undefined || this.state[actor.slug] !== newState[actor.slug]) {
                            // console.log('state change', actor.slug, this.state?.[actor.slug], '=>', newState[actor.slug]);
                            stateChange = true;
                        }
                    }

                    newState.cleanUp = function({ state, stateRegister, maxStateCount }) {

                        console.log('Project state clean up');

                        // if the configuration in this state is not in the relevant states anywhere, destroy it
                        const removedStateActorSlugs = Object.keys( state );
                        const oldestRelevantActorSlugs = Object.keys( stateRegister[ 0 ] );
                        const actorSlugsToRemove = removedStateActorSlugs.filter( rsaSlug => oldestRelevantActorSlugs.includes( rsaSlug ) === false );

                        console.info( 'Removing actors', actorSlugsToRemove );

                        for ( let slug of actorSlugsToRemove ) {
                            const configuratorToRemove = project.configurators.find(c => c.slug === slug);
                            if ( ! configuratorToRemove ) {
                                console.error(`Missing configurator with slug ${slug}`);
                                continue;
                            }
                            project.removeConfigurator( configuratorToRemove ); 
                        }
                    }

                    if (stateChange) {

                        // console.log('Adding new state')

                        await this.addState({
                            newState,
                            moveCursor: 'silent'
                        });

                        // console.log(this._stateRegister);

                        // console.log('Added new state')
                    }

                    this.bodyChangeHandlePromiseResolver();
                    this.bodyChangeHandlePromise = null;
                    this.bodyChangeHandlePromiseResolver = null;
                }
            }
            // destroy old actors and configurations
        );

        if (settings.server) {
            this.server = new ServerConnection(
                reporter,
                {
                    url: settings.server || new URL('wss://backend.productbuilder.nl'),
                    reconnectTime: settings.reconnectTime || 5
                }
            );
        }

        // this.addTransformClass(DefaultSelectionTransform);
        // this.addTransformClass(ColorTransform);

        // StateTracker must have an initial state

        // this._stateRegister = [
        //     // {}
        // ];

        const autosaveData = this.loadFromLocalStorage('autosave');

        if (autosaveData) {
            this.autosave = {
                age: ( new Date().getTime() - autosaveData.meta.time ) / 1000,
                data: autosaveData,
                restore: async () => {
                    console.log('Restoring autosave');
                    return project.buildProjectFromExport(autosaveData);
                }
            };
            // console.log('autosave found', autosaveData.meta.time);
        }

        this.reset();

        this.initIFrameAPI();
    }


    /**
     * Map of 3D content type to THREE layer to keep everything nicely (and consistently) separated
     * @type {Object.<string,number>}
     */

    static layerMap = {
        grid: 1,
        boundingBoxes: 2,
        dimLines: 3,
        connectorHelpers: 4,
        lightHelpers: 5,
        visibleActors: 6,
        hiddenActors: 7,
        shadowPlane: 8,
        castShadow: 9
    };

    static maxAnisotropy

    /**
     * Set of quaternions that are used often
     * @type {Object<string,Quaternion>}
     */

    static standardQuaternions = {
        '+x': new Quaternion().setFromEuler(new Euler(Math.PI, 0, 0)),
        '-x': new Quaternion().setFromEuler(new Euler(-Math.PI, 0, 0)),
        '+y': new Quaternion().setFromEuler(new Euler(0, Math.PI, 0)),
        '-y': new Quaternion().setFromEuler(new Euler(0, - Math.PI, 0)),
        '+z': new Quaternion().setFromEuler(new Euler(0, 0, Math.PI)),
        '-z': new Quaternion().setFromEuler(new Euler(0, 0, -Math.PI)),
    };


    /**
     * @type {Object<string,HTMLImageElement>}
     */

    static defaultImages = {
        missing: makeImage(128, 128, 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAEzklEQVR4nO3dsW7bSBCAYRVJe5DTOEiRGK4CuNEj6BHYEJxZN2pOCFKm8iPcA6W4IkHUB4HdnZtAblLHb5Brdn3rxeYkWTtLkfo/wI2L3SVnSJGzS3IyAQAAAAAAAAAAAAAAAAAAAAAAAEoSkYWqXjdNM+17LE/VNM1UVa9FZNH3WAbFB/+X/xtkEoTgh+0gCbaUBH+QSZAGnyTYUib4d0NLgkzw70iCLYjIWbqjmqaZishN9P9V3+PcRFVX0Tbc+G1YJNt21vc4D1LYUfFREpJARO6dc7Meh7cV59xMRO5D8MP/c9uGjNzR0TTNdAjBD5xzs9zPFUc+AHAqzDiafWJxMdR13QdVPS3V3ib+juV9wfaO4wLR4nZIVa98e7c1kkBEzkRk7cf/rkR7R1EnyAR/sW+by+XyeVJ0+ccyCeLg+234Op/PnxVot/i+OSiWG+icO0mS4NY596ZU+0Em+N/atn1RsP3xJkFczRORdemSbiYJ7komgXXwJ5OHglfcx03J9nuVKekWr+tbJUHbti9rBD+ZObwZwrzHTmongYj8cM6d79umiLxS1e/hGoPg7yFOAqvavnPuREQ+qepFwTbPVfVz27YvS7UZtT0TkfvRBz/wGb8aUm3fmnNupqqr0QcfAIANLH8Lc6uNa6ze5fd9SyLyl1VdX1Wb9DY0s4avKd1vVEi6Kt32qIjIn3FJt/RtV64WYX0PnlYRu65rS7Y/Kqp66o/+hyQofSbIJIFZ8GtUEUenjySwCr6fnXyYqST4W/JJcF0jCSoGv3gVcdQykzt/l+4jXASWbldEPlpeyxyNkASlJnZqcc6di8gPjvwCfBIUm9ipRVXfEnwAAOyoatNHnd2XkIuXjbGDaCVt1XcHxPMHo1q9OzTWawxzcmv4rPvEb9RYaJr2dzQLOIeiVhIQ/ANVKzC1zzbYQu2jkiQ4MNZTujm5JLDuE78RPVdf9fc4ecBlUatfZFAIAgBgnPz6urd9j2NXqnrhnDvpexyDFhZXDnhJ2DVJ8EQ++LfRff7H0n1YLQrV6IXRJMET1FhWHVURLd5U8iZ5toEk2Fbl4JuVdDMPuJAEm7Rt+6KH4JMEh6Lrujb6zV+3bfu6ZPu5ySPryZ3MtcyiZPujo6pXIrK2eHmyRo+Hh/mDzORO8fJudCbg8fBtWL8gIp08ip4TXFj1e3l5+YdV2wAADIH/vf3CiyL/498W+mX0y8uS27CfFkngb7s+l5w7UNULEflkcQ/v3xL6c/RrDGss4EyqiN9F5NW+bUYTOyaFnKNYaFop+I+qiL6QtHcV0df1H33CliTYUbJxdxbBF5FvcfBLFpJqJUHaR8n2e2X5ORTr4AfWSTDqT8ZMJjYbOJ/Pn4nIV+vgB7kkWC6Xz/dtd/TBDyw+Gyci72oEP0jm+veu7R/NZ+MCMfhIoqq+rxH8qL/Trus+lGrPYp8ctJrBGgr2CQBkT4VN00yHNHfgnJvlah2c5jfIXQxFVUSTuYPSotr+o2re0V3o7Sp3O5RZwLnqe5ybaPJMgC/zFr/9HaXMjoo/tjCId/JkvkWwThO77zEetDQJhhT84H++SrLoe2yDECfB0IIfZM4Ei77HNCi51btDU2O1MQAAAAAAAAAAAAAAAAAAAAAAAI7UvxmoXFiIe/GjAAAAAElFTkSuQmCC')
    };


    /**
     * @type {string}
     */

    static basis = '+X+Y+Z';



    /** @type {(Promise|null)} */

    bodyChangeHandlePromise = null;
    bodyChangeHandlePromiseResolver = null;


    /** @type {Timer} */

    timer;


    /**
     * @type {Scene}
     */

    scene;


    /**
     * @type {WebGLRenderer}
     */

    renderer;


    /**
     * @type {ServerConnection}
     */

    server;


    /** 
     * List of packages
     * @type {Array<Package>} 
     */

    pkgs;


    /**
      * Promise chain of async IO operations such as load and save,
      * which should not be executed simultaneously
      */

    projectIOPromiseChain = Promise.resolve();


    /**
      * @param {URL} packageURL
      */

    async addPackage(packageURL, loadingBases = []) {
        this.projectIOPromiseChain = this.projectIOPromiseChain
            .then( () => this._addPackage(packageURL, loadingBases) )
            .catch( err => {
                console.error(err);
                return new Error( err );
            });
        return this.projectIOPromiseChain;
    }

    async _addPackage(packageURL, loadingBases = []) {

        this.report({ msg: `Load package ${packageURL}`, level: 'notice' });

        console.assert(packageURL instanceof URL);
        console.assert(Array.isArray(loadingBases));

        const indexURL = `${packageURL.href}${packageURL.href.substr(-1) !== '/' ? '/' : ''}index.json?r=${Math.random()}`;

        this.report({ msg: `Loading index from ${indexURL}` });

        const response = await fetch(indexURL);

        if (!response.ok) {
            console.warn(response);
            throw new Error('Load package failed');
        }

        const packageJSON = await response.json();

        // remove loading base info from package json 
        // so it doesn't have to be processed by the tree 
        if (packageJSON.loadingBases) {
            const pkgBasesData = packageJSON.loadingBases;
            delete packageJSON.loadingBases;
            for (let pkgBaseData of pkgBasesData) {

                let baseUrl = null;
                try {
                    let urlCandidate = pkgBaseData.url === "%%package_url%%" ? packageURL : pkgBaseData.url;
                    baseUrl = new URL(urlCandidate);
                } catch (err) {
                    throw new Error(`Could not parse loading base url ${pkgBaseData.url}`);
                }

                let baseFor = pkgBaseData.for;

                if (!baseFor) {
                    throw new Error(`Loading base ${pkgBaseData.url} is missing "for" array, maybe it should be "for: [ 'default' ]"?`)
                }

                let pkgBase = new LoadingBase(this._reporter, {
                    for: baseFor,
                    url: baseUrl
                });

                loadingBases.push(pkgBase);
            }
        }

        if (loadingBases.length === 0) {

            // always one package specific loading base and 0 or more other bases

            const packageURLBase = new LoadingBase(
                this._reporter,
                { 
                    name: 'Package URL base', 
                    for: ["default"],
                    url: packageURL, 
                 },
            );
            loadingBases.push(packageURLBase);

            // const defaultMaterialBase = new LoadingBase(
            //     this._reporter,
            //     { 
            //         name: 'Material base', 
            //         url: new URL('https://materials.productbuilder.nl'), 
            //         loadableComponentTypes: [ WrappedImage ] 
            //     }
            // );
            // loadingBases.push(defaultMaterialBase);
        }


        const loader = new ComponentLoader(this._reporter, { name: 'PackageLoader', bases: loadingBases });

        const pkg = Package.createFromJSON(
            packageJSON,
            this._reporter,
            null,
            {
                loader,
                renderer: this.renderer
            });

        // should this be done here?
        Object.freeze(pkg)

        this._addPackageObject(pkg);

        this.report({ msg: `Package load complete: ${pkg.label}`, level: 'notice' });
        this.triggerWindowAPIEvent( 'pkg-loaded', pkg.id );

        return pkg;
    }

    /**
     * Add a new package to the internal register
     * @param {Package} pkg
     */

    _addPackageObject(pkg) {
        this.report({ msg: `Adding ${pkg.label}`, level: 'notice' });
        this.pkgs.push(pkg);
    }



    get actors() {
        return [...this.configurators, ...this.singleBlockInstances];
    }

    /**
     * @param {Actor} actor
     */

    addActor(actor, list, overwritePreviousState = false) {
        console.assert(actor instanceof Actor);

        this.report({ msg: `Adding ${actor.label}`, level: 'notice' });
        list.push(actor);
        this.scene.add(actor.body);

        actor.on('bodychange', async (amendPreviousState = false) => {

            // console.log(`${actor.label} body change`);

            if (this.bodyChangeHandlePromise === null) {
                let resolve = null;
                this.bodyChangeHandlePromise = new Promise(res => this.bodyChangeHandlePromiseResolver = res);
                this.timer.trigger();
                // this.bodyChangeHandlePromiseResolver.resolve = resolve;
            }
            await this.bodyChangeHandlePromise;
            // console.log('resolved');


            if (overwritePreviousState === true) {

                // the bodychange event will trigger a state addition


                // console.log('execute squash')
                this.overwritePreviousState();
                overwritePreviousState = false; // only do this the first time

            }
            this.timer.trigger();   
        });

        this.timer.trigger();
    }




    hideActor( actor ) {
        
    }



    /** 
     * List of sbi's
     * @type {Array<SingleBlockInstance>}
     */

    singleBlockInstances = [];

    /**
     * @param {Block} block
     * @param {Vector3} position
     * @param {Quaternion} quaternion
     * @returns {SingleBlockInstance}
     */

    addSingleBlockInstance(block, position, quaternion) {

        if (this.pkgs.indexOf(block.tree) === -1) {
            throw new Error('Block belongs to unknown tree');
        }

        const blockInstance = new BlockInstance(this._reporter, { block });

        const SBI = new SingleBlockInstance(
            this._reporter,
            {
                blockInstance: blockInstance,
                loader: block.tree.loader,
                position: position || new Vector3(),
                quaternion: quaternion || new Quaternion()
            }
        );

        this.addActor(SBI, this.singleBlockInstances);

        return SBI;
    }



    /** 
     * List of configurators
     * @type {Array<Configurator>} 
     */

    configurators;

    /**
     * @param {Object} settings
     * @param {Package} settings.pkg
     * @param {Boolean} [overwritePreviousState=false]
     * @returns {Promise<Configurator>}
     */

    async addConfigurator(settings, overwritePreviousState = false) {

        if (this.pkgs.indexOf(settings.pkg) === -1) {
            throw new Error('Unknown package');
        }

        // console.log('squash=', overwritePreviousState)

        const configurator = new Configurator(this._reporter, settings);

        this.timer.trigger();

        this.addActor(configurator, this.configurators, overwritePreviousState);

        this.timer.trigger();

        this.triggerWindowAPIEvent( 'configurator-added', configurator.id );

        await configurator.initialConfigBuiltPromise;

        this.triggerWindowAPIEvent( 'configurator-initialized', configurator.id );

        return configurator;
    }





    // /** 
    //  * List of transforms
    //  * @type {Object<string,Transform>} 
    //  */

    // transforms = {};

    // /**
    //  * @param {typeof Transform} transformClass
    //  * @returns {Transform}
    //  */

    // addTransformClass(transformClass) {
    //     this.transforms[transformClass.name] = new transformClass(
    //         this._reporter,
    //         {
    //             timer: this.timer,
    //             scene: this.scene,
    //         }
    //     );

    //     return this.transforms[transformClass.name];
    // }





    findConfigurator( configuration ) {

        return this.configurators.find(c => c.configuration === configuration && c.visible === true );

    }





    /** 
     * List of views
     * @type {Array<View>} 
     */

    views = [];

    /**
     * @param {View} [view]
     */

    addView(view) {

        if (!view) {
            view = new DefaultView(
                this._reporter,
                {
                    scene: this.scene,
                    renderer: this.renderer,
                    rendererOrtho: this.rendererOrtho,
                    timer: this.timer,
                    findConfigurator: this.findConfigurator.bind(this),
                    addConfigurator: this.addConfigurator.bind(this),
                    findComponentById: this.findComponentById.bind(this)
                }
            );
        }

        this.views.push(view);

        view.onSceneUpdate();

        return view;
    }


    // the cursor was moved



    /** @param {any} selectedState */

    onStateCursorMoved(selectedState) {

        // console.log('state cursor moved, selected state:', selectedState)
        // console.log(this._stateRegister, this._cursor)

        for (let actor of this.actors) {
            if (selectedState[actor.slug] === undefined) {
                if (actor.visible === true) {
                    // console.log('hide', actor.label)
                    actor.hide();
                }
            }
        }

        for (let actor of this.actors) {
            if (selectedState[actor.slug] !== undefined) {
                if (actor.visible !== true) {
                    // console.log('show', actor.label)
                    actor.show();
                }
                actor.setCursor(selectedState[actor.slug]);
            }
        }
    }


    /**
     * @param {number} [steps =1]
     * @returns {Promise<Object>}
     */
    undo(steps = 1) {
        // this.disableTransforms();
        for (let view of this.views) {
            view.removeAllMarkers();
        }
        return super.undo(steps);
    }


    /**
     * @param {number} [steps =1]
     * @returns {Promise<Object>}
     */

    redo(steps = 1) {
        // this.disableTransforms();
        for (let view of this.views) {
            view.removeAllMarkers();
        }
        return super.redo(steps);
    }


    removeConfigurator( configurator ) {

        const index = this.configurators.indexOf(configurator);

        if (index === -1) {
            throw new Error('Unknown configurator', configurator);
        }

        if (this.scene) {
            this.scene.remove(configurator.body);
        }

        configurator.destroy();

        this.configurators.splice(index, 1);
    }

    // disableTransforms() {
    //     for (let transform of Object.values(this.transforms)) {
    //         transform.removeAll(true);
    //     }
    // }


    reset(removePkgs = false) {

        //console.log( "Resetting project");

        this.report({ msg: `Project reset`, level: 'notice' });

        this.resetState();
        this.removeAllListeners();

        if (this.configurators) {
            for (let configurator of this.configurators) {
                this.removeConfigurator(configurator);
            }
        }

        this.configurators = [];

        // pkgs weggooien is niet nodig
        if (this.pkgs && removePkgs === true) {
            for (let pkg of this.pkgs) {
                pkg.removeAllListeners();
            }
        }

        if (!this.pkgs || removePkgs === true) {
            this.pkgs = [];
        }



        // clear scene 

        if (!this.scene) {
            this.scene = new Scene();
        }
        // if ( this.scene ) {
        //     while (this.scene.children.length > 0) {
        //         this.scene.remove(this.scene.children[0]);
        //     }
        // }

        // this.scene = new Scene();


        // remove any old update requests

        for (let urId of Object.keys(this.timer.updateRequests)) {
            this.timer.removeUpdateRequest(urId);
        }


        // create bounding box for the entire project

        this.boundingBox = null;
    }



    async load(identifier) {
        // if the load fails, we cannot throw an error
        // because it is a promise chain. Instead, it is returned
        this.projectIOPromiseChain = this.projectIOPromiseChain
            .then( () => this._load( identifier ))
            .catch( err => new Error( err ) );
        
        return this.projectIOPromiseChain;
    }

    /**
     * @method
     * @param {UUID|String} identifier Project id or slug
     */

    async _load(identifier) {

        this.report({ msg: `Loading project ${identifier}`, level: `notice`});

        if ((!this.server) || (!this.server.connected)) {
            throw new Error('Unable to load project, not connected to server.');
        }

        let projectData = null;

        //try {
            const response = await this.server.request({
                endpoint: 'project',
                method: 'read',
                data: {
                    identifier
                }
            });

            projectData = JSON.parse(response.data.export);

            console.log( projectData )

            this.report({ msg: `Project data load success`, level: 'notice' });
        //}
        //catch (err) {
            //console.error('Error while loading project:', err);
        //}


        //console.log('loaded project data', projectData);

        const configurators = this.buildProjectFromExport(projectData);

        this.report({ msg: `Project ${this.id} load complete`, level: 'notice' });

        return configurators;
    }

    loadFromLocalStorage(key) {
        let projectData = null;
        if (localStorage) {
            const projectDataStr = localStorage.getItem(key);
            if (projectDataStr) {
                try {
                    projectData = JSON.parse(projectDataStr);
                } catch( err) {
                    console.error(`Could not load project from local storage.`, err);
                }
            }
        }
        return projectData;
    }



    /**
      * Resets the current project and imports the data from 
      * a previously exported project by loading its packages and re-creating
      * its configurators. It also sets the previous project's id.
      *
      * @method
      * @param {Object} projectData - JSON object, project export
      */ 

    async buildProjectFromExport(projectData) {

        this.report({ msg: `Building project ${projectData.meta?.id}`, level: 'notice' });

        this.reset();

        for (let pkgToLoad of projectData.packages) {
            const loadedPkg = this.pkgs.find(pkg => pkg.id === pkgToLoad.id);
            if (loadedPkg) {
                console.debug(loadedPkg.label, 'already loaded, not loading again');
            }
            else {
                await this.addPackage(new URL(pkgToLoad.url));
            }
        }

        const configurators = await Promise.all( projectData.configurations.map(async (configuratorData)=>{
            const pkg = this.pkgs.find(pkg => pkg.id === configuratorData.configuration.pkg);
            // console.log(pkg, this.pkgs, configuratorData)
            const configuration = Configuration.createFromJSON(configuratorData.configuration, this._reporter, pkg);
            const configCopy = configuration.copy()

            // adds configurator to project
            
            const configurator = await this.addConfigurator({
                pkg,
                position: new Vector3(configuratorData.position.x, configuratorData.position.y, configuratorData.position.z),
                quaternion: new Quaternion(configuratorData.quaternion._x, configuratorData.quaternion._y, configuratorData.quaternion._z, configuratorData.quaternion._w),
                configuration: configCopy
            });
            
            // console.log('update configurator with config copy');
            await configurator.update(configCopy)

            // console.log('updated', configurator.cursor, configurator._stateRegister);
            // console.log(configCopy);

            // position: new Vector3( configuratorData.position.x, configuratorData.position.y,  configuratorData.position.z ),
            //     quaternion: new Quaternion(  configuratorData.quaternion._x,configuratorData.quaternion._y,configuratorData.quaternion._z,configuratorData.quaternion._w)
            this.timer.trigger();
            return configurator
        }))

        // todo: create failsafe.. what if loading fails?
        this.id = projectData.meta.id;
        //console.log('set id')

        return configurators
    }



    async share() {
        this.projectIOPromiseChain = this.projectIOPromiseChain
            .then( () => this._save({ copy: true }) )
            .catch( err => new Error( err ) );

        return this.projectIOPromiseChain;
    }
    
    toJSON() {

        for (let c of this.configurators){
            const configurationStatus = c.configuration.status.main.medium;
            
            if (configurationStatus !== 'ready') {
                throw new Error(`Can't export project to JSON, configurator ${c.id} has a configuration that has not been built yet. (status = ${configurationStatus})`);
            }
        }

        const exp = {
            meta: {
                id: this.id,
                time: new Date().getTime()
            },
            packages: this.pkgs.map(pkg => ({
                id: pkg.id,
                url: pkg.loader.loadingBases[0].url.href
            })),
            configurations: this.configurators.filter(configurator => configurator.visible).map(
                configurator =>
                ({
                    configuration: configurator.configuration.toJSON(),
                    position: configurator.state.position,
                    quaternion: configurator.state.quaternion
                })
            )
            // singleBlockInstances: this.singleBlockInstances.map(SBI =>
            // ({
            //     sbi: SBI.toJSON(),
            //     position: SBI.state.position,
            //     quaternion: SBI.state.quaternion
            // })
            // )
        }


        const expStr = JSON.stringify(exp);

        return expStr;
    }

    async save( params ) {
        this.projectIOPromiseChain = this.projectIOPromiseChain
            .then( () => this._save( params ) )
            .catch( err => new Error( err ) );

        return this.projectIOPromiseChain;
    }

    async _save({ copy = false, metadata = undefined } = {}) {

        this.report({ msg: `Save project ${this.id}, copy=${copy}`, level: 'notice' })

        await Promise.all(this.configurators.filter(configurator => configurator.visible).map(c => c.configuration.build()));

        const expStr = this.toJSON();

        // wait for configs to build, otherwise assignables array can still be
        // empty in export (and perhaps other issues too)
        if (localStorage) {
            // console.log('autosave');
            localStorage.setItem('autosave', expStr);
        }

        let returnObject = { export: expStr };

        if ((!this.server) || (!this.server.connected)) {
            throw new Error('Unable to save project to server, not connected.');
        }
        else {

            try {
                if ( copy === true ) {

                    const requestObj = {
                        endpoint: 'project',
                        method: 'create',
                        data: {
                            name: '',
                            description: '',
                            export: expStr,
                            readonly: true,
                            metadata: metadata ? JSON.stringify( metadata ) : undefined
                        }
                    };

                    const shareResponse = await this.server.request( requestObj );

                    if (shareResponse.data) {
                        this.report({ msg: `Project shared as ${shareResponse.data.slug}`, level: 'notice' });
                        returnObject.slug = shareResponse.data.slug;
                        returnObject.id = shareResponse.data.id;
                    }
                    else {
                        this.report({ msg: 'Project could not be shared: ' + shareResponse.errors[0]?.description, level: 'error' });
                    }
                }
                else {
                    const saveResponse = await this.server.request({
                        endpoint: 'project',
                        method: 'update',
                        data: {
                            id: this.id || '<no id>',
                            name: '',
                            description: '',
                            export: expStr,
                            metadata: metadata ? JSON.stringify( metadata ) : undefined
                        }
                    });

                    if (saveResponse.data) {
                        this.report({ msg: 'Project saved', level: 'notice' });
                        returnObject.id = this.id;
                        this.slug = returnObject.slug = saveResponse.data.slug;
                        this.price = returnObject.price = saveResponse.data.price;
                    }
                    else {
                        this.report({ msg: 'Project could not be saved: ' + saveResponse.errors[0]?.description, level: 'error' });
                        this.price = undefined;
                    }

                    this.emit( 'price', this.price );
                }
                // console.log('saveResponse', saveResponse)
            }
            catch (err) {
                console.error('Error while saving project:', err);
            }
        }

        return returnObject;
    }



    /**
    * Search the loaded packages and current confugurations for a component
    * with the supplied id
    * @param {UUID} id 
    * @returns {Component}
    */

    findComponentById(id) {
        let component = undefined;
        for (let pkg of this.pkgs) {
            component = pkg.findComponentById(id);
            if (component) {
                break;
            }
        }
        if (component === undefined) {

            for (let configurator of this.configurators) {
                
                if( configurator.visible  ){

                    component = configurator.configuration.findComponentById(id);

                    if (component) {
                        break;
                    }

                }
                
            }
        }

        return component;
    }


    /**
    * Sets a theme accross all configurators for the relevant package
    * and squashes their state updates into one
    * @param {Theme} theme
    */

    setTheme(theme) {
        for (let i = 0, l = this.configurators.length; i < l; i += 1) {
            this.configurators[i].setTheme(theme);
            if (i > 0) {
                this.overwritePreviousState();
            }
        }
    }


    registerUI({ name = 'Unnamed UI', onEvent }) {
        this.UI = {
            name,
            onEvent
        };
    }


    makeMsgApiObject({ id, type, error = false, data = '' } = {} ) {
        return {
            id,
            type,
            target: 'pb',
            error,
            data,
            apiVersion: 1
        };
    }

    getEntityArray( entityType ) {
        switch ( entityType ) {
            case 'preset':
                return this.pkgs.reduce( ( psts, pkg ) => [ ...psts, ...pkg.configurations ], []);
            case 'actor':
                return this.actors;
            case 'configurator':
                return this.configurators;
            case 'pkg':
                return this.pkgs;
            default:
                throw new Error( 'Unknown entity type ' + entityType );
        }
    }

    findEntityById( entityType, id ) {

        const list = this.getEntityArray( entityType );


        if ( ! id ) {
            throw new Error( 'Can not find ${entityType} without id' );
        }

        const entity = list.find( e => e.id === id );

        if ( ! entity ) {
            throw new Error( `Unknown ${entityType} id ${id}` );
        }
        else {
            return entity;
        }
    }

    /**
     * Might be better to only give back presets for "active" configurators 
     */

    listPresets(packageId) {
        // console.log(this.pkgs[0].configurations);

        const presets = [];

        let pkgsToScan = [];

        if (packageId === undefined ) {
            pkgsToScan = this.pkgs;
        }
        else {
            pkgsToScan = this.pkgs.find(p => p.id === packageId);
        }

        for (let pkg of this.pkgs){
            for ( let preset of pkg.configurations ) {
                let thumbnailURL = undefined;
                if ( preset.thumbnail ){
                    const loadingBase = pkg.loader.findLoadingBaseFor(preset.thumbnail?.loadingBase || 'default');
                    // console.log(loadingBase)
                    thumbnailURL = `${loadingBase.url.href}${preset.thumbnail.source.medium.path}`;
                }
                presets.push({
                    id: preset.id,
                    pkgId: pkg.id,
                    name: preset.name,
                    thumbnail: thumbnailURL
                });
            }
        }

        return presets;
    }

    /**
     * Select a preset in one of the configurators. Configurator id is required 
     * because multiple configurators can have the same package.
     * @returns {Promise<Object>}
     */

    async selectPreset({configuratorId, presetId, keepDefaultMaterials = false} = {}){
        const configurator = this.configurators.find(c => c.id === configuratorId);
        if ( ! configurator) {
            throw new Error(`Unknown configurator id ${configuratorId}`);
        }
        const presets = this.listPresets();
        const presetData = presets.find(pr => pr.id === presetId );
        if ( ! presetData ){
            throw new Error(`Unknown preset id ${presetId}`);
        }
        if (configurator.pkg.id !== presetData.pkgId) {
            throw new Error(`Preset/Package mismatch error. Preset with id ${presetId} does not match the package of configurator ${configuratorId}`);
        }

        const preset = configurator.pkg.configurations.find(c => c.id === presetId);
        const presetJSON = preset.toJSON();

        if ( keepDefaultMaterials === true ) {
            const currentConfigJSON = configurator.configuration.toJSON();
            presetJSON.defaultMaterials = currentConfigJSON.defaultMaterials;

            // not sure whether the material assignments should be deleted here
            // delete presetJSON.materialAssignments;
            // delete presetJSON.materialOverview; // has no real value, but prevents confusion
        }
        
        // console.debug('Final', presetJSON)

        const config = Configuration.createFromJSON(presetJSON, this._reporter, configurator.pkg);

        // console.debug(config);

        const configCopy = config.copy(); // would be nice to find out why this is necessary (and solve it)

        return configurator.update(configCopy);
    }


    async listComponentOptions() {
        return this.configurators.reduce(
                    ( res, con ) => ([
                        ...res,
                        ...(con.configuration.options.map( p => ({
                            id: p.id,
                            pkgId: con.id
                        })))
                    ]),
                    []
                );
    }

    async listAssignableMaterials({configuratorId}) {

        const configurator = this.configurators.find(c => c.id === configuratorId)
        if ( ! configurator) {
            throw new Error(`Unknown configurator id ${configuratorId}`);
        }
        const optionMaterials = configurator.configuration.options.materials;
        // console.log(optionMaterials[0])
        const mapped = optionMaterials.map(m => {

            let thumbnailURL = undefined;

            if ( m.thumbnail ){
                const loadingBase = configurator.pkg.loader.findLoadingBaseFor(m.thumbnail?.loadingBase || 'default');
                // console.log(loadingBase)
                thumbnailURL = `${loadingBase.url.href}${m.thumbnail.source.medium.path}`;
            }
            const baseObj = { 
                id: m.id,
                name: m.name,
                supplierReference: m.supRef,
                thumbnail: thumbnailURL
            };

            for ( const cat of m.categories ) {
                baseObj[cat.type.name] = cat.name;
            }

            return baseObj;
        });
        return mapped;
    }

    async setDefaultMaterial({ configuratorId, materialId }) {

        const configurator = this.configurators.find(c => c.id === configuratorId)
        if ( ! configurator) {
            throw new Error(`Unknown configurator id ${configuratorId}`);
        }
        const material = this.findComponentById(materialId);
        const newConfig = configurator.configuration.setDefaultMaterial(material);
        await configurator.update(newConfig);
        return true;
    }

    async messageApiRequest( request ) {
        switch ( request.method ) {

            case 'set-locale':
                if ((!this.server) || (!this.server.connected)) {
                    response = makeResponse( true, 'Unable to set locale, not connected.');
                }
                else {

                    //try {
                        const serverResponse = await this.server.request({
                            endpoint: 'locale',
                            method: 'update',
                            data: {
                                locale: request.locale
                            }
                        });

                        // console.log('saveResponse', saveResponse)

                        if (serverResponse.data) {
                            this.report({ msg: 'Locale updated', level: 'notice' });
                            return true; 
                        }
                        else {
                            const msg = 'Locale could not be updated: ' + serverResponse.errors[0]?.description;
                            this.report({ msg, level: 'error' });
                            throw new Error( msg );
                        }
                    //}
                    //catch (err) {
                        //console.error('Error while updating locale:', err);
                    //}
                }
                break;
                
            case 'project-id':
                return this.id;

            case 'project-slug':
                return this.slug;

            case 'share-project':
                return this.share();

            case 'save':
            case 'save-project':

                const saveResult = await this.save(); 

                if ( saveResult instanceof Error ) {
                    throw saveResult;
                }
                else {
                    return saveResult;
                }
            case 'load-project':

                const loadresult = await this.load( request.data.identifier ); 

                if ( loadresult instanceof Error ) {
                    // if the load fails it will return an error, but not throw it
                    // because of the promise chain construction
                    // however, outside the chain, the error should be thrown anyway
                    throw loadresult;
                }
                else {
                    // load result holds the created configurators
                    // which can not be sent to the parent window
                    // so create another return object here
                    return { id: this.id, slug: this.slug };
                }

            case 'list-available-packages':
                // entails server communication
                break;

            case 'list-loaded-packages':
                return this.pkgs.map( pkg => ({ id: pkg.id }) );

            case 'list-configurators':
                return this.configurators.map( c => ({ id: c.id, pkgId: c.pkg.id, initialized: c.initialConfigBuilt }) );

            case 'list-presets':
                return this.listPresets();
                // const presets = this.pkgs.reduce(
                //     ( res, pkg ) => ([
                //         ...res,
                //         ...(pkg.configurations.map( p => ({
                //             id: p.id,
                //             pkgId: pkg.id
                //         })))
                //     ]),
                //     []
                // );
                //
                // return presets;

            case 'screenshot':
                const imageExporter = new ImageExporter( this.reporter, { view: this.views[ 0 ] } );
                const dataURI = await imageExporter.export( 600, 400 );
                return dataURI;

            case 'configuration-summary':
                break;

            case 'select-preset':

                const options = await this.selectPreset(request.data);
                return true;

            case 'list-component-options':
                return this.listComponentOptions(request.data);

            case 'list-assignable-materials':
                return this.listAssignableMaterials(request.data);

            case 'set-default-material':
                return this.setDefaultMaterial(request.data);

            case 'ui':
                return this.frontEndClientEventListener( request.data );

            case 'price':
                return this.price;

            case 'export-project':
                await Promise.all(this.configurators.filter(configurator => configurator.visible).map(c => c.configuration.build()));
                const expStr = this.toJSON();
                return expStr;

            default:
                throw new Error( `Unknown Window API method name: ${request.method}` );
        }
    }


    triggerWindowAPIEvent( type, data ) {
        for( let [ origin, sub ] of Object.entries( this.messageApiSubscriptions ) ) {
            sub.source.postMessage(
                this.makeMsgApiObject({
                    id: InformationSource.uuid(),
                    type,
                    data
                }),
                origin
            );
        }
    }


    /**
    * @method
    * Let the front-end client trigger events
    */

    messageApiSubscriptions = {};

    allowedIFrameAPIOrigins = [];


    /**
    * Allows controlling through postMessage API
    */

    enableWindowAPI(allowedOrigins) {
        //console.debug( 'Set allowed origins for IFrame API', allowedOrigins );
        //Changed according to mail on 9-10-2023 from Paulo Ferreira Jorge
        allowedOrigins.push(/^https:\/\/moooi-next-ecom-platform.*vercel.app$/g);
        this.allowedIFrameAPIOrigins = allowedOrigins;
        return true;
    }

    setAllowedIFrameOrigins(allowedOrigins) {
        return this.enableWindowAPI( allowedOrigins );
    }


    initIFrameAPI() {

        const project = this;

        this.on( 'price', function( price ) {
            project.triggerWindowAPIEvent( 'price', price );
        });

        window.addEventListener(
            "message",
            async event => {
                //console.log('msg event', event.data);
            
                // this is replaced to allow regexes 
                /* const originAllowed = this.allowedIFrameAPIOrigins.includes( event.origin ); */

                let originAllowed = false;

                for (let allowedOrigin of this.allowedIFrameAPIOrigins) {
                    if( typeof allowedOrigin === 'string' && event.origin === allowedOrigin) {
                        originAllowed = true;
                        break;
                    }
                    else if (allowedOrigin instanceof RegExp && event.origin.match(allowedOrigin)) {
                        originAllowed = true;
                        break;
                    }
                }

                if ( event.data === 'pbping' ) {
                    const pingResponse = { target: 'pb', type: 'pbinfo', originAllowed, origin: event.origin };
                    event.source.postMessage( pingResponse, event.origin );
                    return;
                }

                if ( ! originAllowed ) {
                    return;
                }

                let request = event.data;
                let response = null;

                if ( typeof request !== 'object' ) {
                    return;
                }

                // engine commands should have .type === 'pb'
                if ( request.target !== 'pb' ) {
                    return;
                }

                if ( ! request.id ) {
                    request.id = InformationSource.uuid();
                }

                if ( request.method === 'subscribe' ) {

                    this.messageApiSubscriptions[ event.origin ] = {
                        source: event.source,
                        // events: request.data.events || [ 'price' ]
                    }

                    response = project.makeMsgApiObject({
                        id: request.id,
                        type: 'response',
                        error: false,
                        data: true
                    });
                }
                else if ( request.method === 'unsubscribe' ) {
                    delete this.messageApiSubscriptions[ event.origin ];
                    response = project.makeMsgApiObject({
                        id: request.id,
                        type: 'response',
                        error: false,
                        data: true
                    });
                }
                else {
                    try {
                        const responseData = await project.messageApiRequest( request );
                        response = project.makeMsgApiObject({
                            id: request.id,
                            type: 'response',
                            error: false,
                            data: responseData
                        });
                    }
                    catch ( err ) {
                        console.log( err )
                        response = project.makeMsgApiObject({
                            id: request.id,
                            type: 'response',
                            error: true,
                            data: err.message
                        });
                    }
                }

                event.source.postMessage( response, event.origin );
            }
        );
    }

    async getGeolocation(){

        //console.log( "getGeolocation()" );

        const serverResponse = await this.server.request(
            {
                endpoint: 'geo',
                method: 'read',
            }
        )

        if (serverResponse.data) {
            return serverResponse.data;
        }
        else {
            throw new Error( serverResponse.errors[0].description );
        }

    }


}

export { Project as Project };