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 { ComponentLoader } from './package/loader/component_loader.js';
import { Scene, WebGLRenderer, Quaternion, Euler, 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 () {

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

            }

        }

        this.exportConfig()


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

        //default renderer for the perspective camera
        this.renderer = new WebGLRenderer(this.rendererSettings);
        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
    };

    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) {
        this.projectIOPromiseChain = this.projectIOPromiseChain
            .then( () => this._addPackage(packageURL) )
            .catch( err => new Error( err ) );
        return this.projectIOPromiseChain;
    }

    async _addPackage(packageURL) {

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

        console.assert(packageURL instanceof URL);

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

        //console.log( response )

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

        const packageJSON = await response.json();

        // console.log( packageJSON  )

        const packageURLBase = new LoadingBase(
            this._reporter,
            { name: 'Package URL base', url: packageURL }
        );

        const loader = new ComponentLoader(this._reporter, { name: 'PackageURLLoader', bases: [packageURLBase] });

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

        Object.freeze(pkg)

        // const pkg = new Package(
        //     this._reporter,
        //     {},
        //     {
        //         loader,
        //         renderer: this.renderer
        //     }
        // );

        // await pkg.importJSON(packageJSON);

        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.addActor(configurator, this.configurators, overwritePreviousState);

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





    /** 
     * 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) {
        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;

        // Clear views to release DOM listeners
        while (this.views.length > 0) {
            const view = this.views.pop();
            if (view.destroy) {
                view.destroy();
            }
        }
    }


    /**
     * Fully tear down the Project, releasing all resources:
     * views, configurators, scene, renderers, timer, server.
     */

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

        // Destroy views (removes DOM listeners, markers, cameras)
        while (this.views.length > 0) {
            const view = this.views.pop();
            if (view.destroy) {
                view.destroy();
            }
        }

        // Destroy configurators and single block instances
        if (this.configurators) {
            for (let configurator of this.configurators) {
                configurator.destroy();
            }
            this.configurators = [];
        }
        if (this.singleBlockInstances) {
            for (let sbi of this.singleBlockInstances) {
                sbi.destroy();
            }
            this.singleBlockInstances = [];
        }

        // Dispose renderers
        if (this.renderer) {
            this.renderer.dispose();
            this.renderer = null;
        }
        if (this.rendererOrtho) {
            this.rendererOrtho.dispose();
            this.rendererOrtho = null;
        }

        // Clear scene
        if (this.scene) {
            while (this.scene.children.length > 0) {
                this.scene.remove(this.scene.children[0]);
            }
            this.scene = null;
        }

        // Stop timer
        if (this.timer) {
            const updateRequestIds = Object.keys(this.timer.updateRequests);
            for (let id of updateRequestIds) {
                this.timer.removeUpdateRequest(id);
            }
            if (this.timer.destroy) {
                this.timer.destroy();
            }
            this.timer = null;
        }

        // Close server connection
        if (this.server) {
            if (this.server.close) {
                this.server.close();
            }
            this.server = null;
        }

        // Clear packages
        if (this.pkgs) {
            for (let pkg of this.pkgs) {
                pkg.removeAllListeners();
            }
            this.pkgs = [];
        }

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



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

            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) {
                projectData = JSON.parse(projectDataStr);
            }
        }
        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;
    }
    

    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' })

        // wait for configs to build, otherwise assignables array can still be
        // empty in export (and perhaps other issues too)
        await Promise.all(this.configurators.map(c => c.configuration.build()));

        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.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
            // })
            // )
        }

        // console.log(exp)

        const expStr = JSON.stringify(exp);

        if (localStorage) {
            // console.log('autosave');
            localStorage.setItem('autosave', expStr);
        }

        let returnObject = { export: exp };

        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) {

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


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

            case 'list-presets':
                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 configuratorId = request.data?.configuratorId;

                if ( ! configuratorId ) {
                    throw new Error( 'Missing configuratorId property' );
                }

                const presetId = request.data?.presetId;

                if ( ! presetId ) {
                    throw new Error( 'Missing presetId property' );
                }

                const configurator = this.findEntityById( 'configurator', configuratorId );
                const preset = this.findEntityById( 'preset', presetId );

                const buildSetting = {
                    part: 'main',
                    highPriority: true,
                };

                try {
                    const build = await preset.build(buildSetting);
                    const options = await configurator.update(build);
                    return true;
                } catch (err) {
                    throw new Error('Could not select preset. ', err);
                }

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

            case 'price':
                return this.price;

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


}

export { Project as Project };