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