@shopware-ag/dive
Version:
Shopware Spatial Framework
809 lines (703 loc) • 26.1 kB
text/typescript
import { Actions } from './actions/index.ts';
import { generateUUID } from 'three/src/math/MathUtils';
import { isSelectTool } from '../toolbox/select/SelectTool.ts';
import { merge } from 'lodash';
import { DIVEModule } from '../module/Module.ts';
// type imports
import { type Color, type MeshStandardMaterial } from 'three';
import {
type COMLight,
type COMModel,
type COMEntity,
type COMPov,
type COMPrimitive,
type COMGroup,
} from './types';
import { type DIVEScene } from '../scene/Scene.ts';
import type DIVEToolbox from '../toolbox/Toolbox.ts';
import type DIVEOrbitControls from '../controls/OrbitControls.ts';
import { type DIVEModel } from '../model/Model.ts';
import { type DIVEMediaCreator } from '../mediacreator/MediaCreator.ts';
import { type DIVERenderer } from '../renderer/Renderer.ts';
import { type DIVESelectable } from '../interface/Selectable.ts';
import { type DIVEIO } from '../io/IO.ts';
import { type DIVEAR } from '../ar/AR.ts';
type EventListener<Action extends keyof Actions> = (
payload: Actions[Action]['PAYLOAD'],
) => void;
type Unsubscribe = () => boolean;
/**
* Main class for communicating with DIVE.
*
* You can subscribe to actions and perform them from outside and inside DIVE.
*
* ```ts
* import { DIVE } from "@shopware-ag/dive";
*
* const dive = new DIVE();
*
* dive.Communication.Subscribe('GET_ALL_SCENE_DATA', () => {
* // do something
* }));
*
* dive.Communication.PerformAction('GET_ALL_SCENE_DATA', {});
* ```
*
* @module
*/
export class DIVECommunication {
private static __instances: DIVECommunication[] = [];
public static get(id: string): DIVECommunication | undefined {
const fromComID = this.__instances.find(
(instance) => instance.id === id,
);
if (fromComID) return fromComID;
return this.__instances.find((instance) =>
Array.from(instance.registered.values()).find(
(object) => object.id === id,
),
);
}
private _id: string;
public get id(): string {
return this._id;
}
private renderer: DIVERenderer;
private scene: DIVEScene;
private controller: DIVEOrbitControls;
private toolbox: DIVEToolbox;
private _mediaGenerator: DIVEModule<DIVEMediaCreator> = new DIVEModule(
'../mediacreator/MediaCreator.ts',
'DIVEMediaCreator',
);
private _io: DIVEModule<DIVEIO> = new DIVEModule('../io/IO.ts', 'DIVEIO');
private _ar: DIVEModule<DIVEAR> = new DIVEModule('../ar/AR.ts', 'DIVEAR');
private registered: Map<string, COMEntity> = new Map();
// private listeners: { [key: string]: EventListener[] } = {};
private listeners: Map<keyof Actions, EventListener<keyof Actions>[]> =
new Map();
constructor(
renderer: DIVERenderer,
scene: DIVEScene,
controls: DIVEOrbitControls,
toolbox: DIVEToolbox,
) {
this._id = generateUUID();
this.renderer = renderer;
this.scene = scene;
this.controller = controls;
this.toolbox = toolbox;
DIVECommunication.__instances.push(this);
}
public DestroyInstance(): boolean {
const existingIndex = DIVECommunication.__instances.findIndex(
(entry) => entry.id === this.id,
);
if (existingIndex === -1) return false;
DIVECommunication.__instances.splice(existingIndex, 1);
return true;
}
public PerformAction<Action extends keyof Actions>(
action: Action,
payload?: Actions[Action]['PAYLOAD'],
): Actions[Action]['RETURN'] {
let returnValue: Actions[Action]['RETURN'] = false;
switch (action) {
case 'START_RENDER': {
this.renderer.StartRenderer(this.scene, this.controller.object);
returnValue = true;
break;
}
case 'GET_ALL_SCENE_DATA': {
returnValue = this.getAllSceneData(
payload as Actions['GET_ALL_SCENE_DATA']['PAYLOAD'],
);
break;
}
case 'GET_ALL_OBJECTS': {
returnValue = this.getAllObjects(
payload as Actions['GET_ALL_OBJECTS']['PAYLOAD'],
);
break;
}
case 'GET_OBJECTS': {
returnValue = this.getObjects(
payload as Actions['GET_OBJECTS']['PAYLOAD'],
);
break;
}
case 'ADD_OBJECT': {
returnValue = this.addObject(
payload as Actions['ADD_OBJECT']['PAYLOAD'],
);
break;
}
case 'UPDATE_OBJECT': {
returnValue = this.updateObject(
payload as Actions['UPDATE_OBJECT']['PAYLOAD'],
);
break;
}
case 'DELETE_OBJECT': {
returnValue = this.deleteObject(
payload as Actions['DELETE_OBJECT']['PAYLOAD'],
);
break;
}
case 'SELECT_OBJECT': {
returnValue = this.selectObject(
payload as Actions['SELECT_OBJECT']['PAYLOAD'],
);
break;
}
case 'DESELECT_OBJECT': {
returnValue = this.deselectObject(
payload as Actions['DESELECT_OBJECT']['PAYLOAD'],
);
break;
}
case 'SET_BACKGROUND': {
returnValue = this.setBackground(
payload as Actions['SET_BACKGROUND']['PAYLOAD'],
);
break;
}
case 'DROP_IT': {
returnValue = this.dropIt(
payload as Actions['DROP_IT']['PAYLOAD'],
);
break;
}
case 'PLACE_ON_FLOOR': {
returnValue = this.placeOnFloor(
payload as Actions['PLACE_ON_FLOOR']['PAYLOAD'],
);
break;
}
case 'SET_CAMERA_TRANSFORM': {
returnValue = this.setCameraTransform(
payload as Actions['SET_CAMERA_TRANSFORM']['PAYLOAD'],
);
break;
}
case 'GET_CAMERA_TRANSFORM': {
returnValue = this.getCameraTransform(
payload as Actions['GET_CAMERA_TRANSFORM']['PAYLOAD'],
);
break;
}
case 'MOVE_CAMERA': {
returnValue = this.moveCamera(
payload as Actions['MOVE_CAMERA']['PAYLOAD'],
);
break;
}
case 'RESET_CAMERA': {
returnValue = this.resetCamera(
payload as Actions['RESET_CAMERA']['PAYLOAD'],
);
break;
}
case 'COMPUTE_ENCOMPASSING_VIEW': {
returnValue = this.computeEncompassingView(
payload as Actions['COMPUTE_ENCOMPASSING_VIEW']['PAYLOAD'],
);
break;
}
case 'SET_CAMERA_LAYER': {
returnValue = this.setCameraLayer(
payload as Actions['SET_CAMERA_LAYER']['PAYLOAD'],
);
break;
}
case 'ZOOM_CAMERA': {
returnValue = this.zoomCamera(
payload as Actions['ZOOM_CAMERA']['PAYLOAD'],
);
break;
}
case 'SET_GIZMO_MODE': {
returnValue = this.setGizmoMode(
payload as Actions['SET_GIZMO_MODE']['PAYLOAD'],
);
break;
}
case 'SET_GIZMO_VISIBILITY': {
returnValue = this.setGizmoVisibility(
payload as Actions['SET_GIZMO_VISIBILITY']['PAYLOAD'],
);
break;
}
case 'SET_GIZMO_SCALE_LINKED': {
returnValue = this.setGizmoScaleLinked(
payload as Actions['SET_GIZMO_SCALE_LINKED']['PAYLOAD'],
);
break;
}
case 'USE_TOOL': {
returnValue = this.useTool(
payload as Actions['USE_TOOL']['PAYLOAD'],
);
break;
}
case 'MODEL_LOADED': {
returnValue = this.modelLoaded(
payload as Actions['MODEL_LOADED']['PAYLOAD'],
);
break;
}
case 'UPDATE_SCENE': {
returnValue = this.updateScene(
payload as Actions['UPDATE_SCENE']['PAYLOAD'],
);
break;
}
case 'GENERATE_MEDIA': {
returnValue = this.generateMedia(
payload as Actions['GENERATE_MEDIA']['PAYLOAD'],
);
break;
}
case 'SET_PARENT': {
returnValue = this.setParent(
payload as Actions['SET_PARENT']['PAYLOAD'],
);
break;
}
case 'EXPORT_SCENE': {
returnValue = this.exportScene(
payload as Actions['EXPORT_SCENE']['PAYLOAD'],
);
break;
}
case 'LAUNCH_AR': {
returnValue = new Promise<void>((resolve, reject) => {
this._ar
.get()
.then((ar) => {
resolve(
ar.Launch(
payload as Actions['LAUNCH_AR']['PAYLOAD'],
),
);
})
.catch(reject);
});
break;
}
default: {
console.warn(
`DIVECommunication.PerformAction: has been executed with unknown Action type ${action}`,
);
}
}
this.dispatch(action, payload);
return returnValue;
}
public Subscribe<Action extends keyof Actions>(
type: Action,
listener: EventListener<Action>,
): Unsubscribe {
if (!this.listeners.get(type)) this.listeners.set(type, []);
// casting to any because of typescript not finding between Action and typeof Actions being equal in this case
this.listeners
.get(type)!
.push(listener as EventListener<keyof Actions>);
return () => {
const listenerArray = this.listeners.get(type);
if (!listenerArray) return false;
const existingIndex = listenerArray.findIndex(
(entry) => entry === listener,
);
if (existingIndex === -1) return false;
listenerArray.splice(existingIndex, 1);
return true;
};
}
private dispatch<Action extends keyof Actions>(
type: Action,
payload: Actions[Action]['PAYLOAD'],
): void {
const listenerArray = this.listeners.get(type);
if (!listenerArray) return;
listenerArray.forEach((listener) => listener(payload));
}
private getAllSceneData(
payload: Actions['GET_ALL_SCENE_DATA']['PAYLOAD'],
): Actions['GET_ALL_SCENE_DATA']['RETURN'] {
const sceneData = {
name: this.scene.name,
mediaItem: null,
backgroundColor:
'#' + (this.scene.background as Color).getHexString(),
floorEnabled: this.scene.Floor.visible,
floorColor:
'#' +
(
this.scene.Floor.material as MeshStandardMaterial
).color.getHexString(),
userCamera: {
position: this.controller.object.position.clone(),
target: this.controller.target.clone(),
},
spotmarks: [],
lights: Array.from(this.registered.values()).filter(
(object) => object.entityType === 'light',
) as COMLight[],
objects: Array.from(this.registered.values()).filter(
(object) => object.entityType === 'model',
) as COMModel[],
cameras: Array.from(this.registered.values()).filter(
(object) => object.entityType === 'pov',
) as COMPov[],
primitives: Array.from(this.registered.values()).filter(
(object) => object.entityType === 'primitive',
) as COMPrimitive[],
groups: Array.from(this.registered.values()).filter(
(object) => object.entityType === 'group',
) as COMGroup[],
};
Object.assign(payload, sceneData);
return sceneData;
}
private getAllObjects(
payload: Actions['GET_ALL_OBJECTS']['PAYLOAD'],
): Actions['GET_ALL_OBJECTS']['RETURN'] {
Object.assign(payload, this.registered);
return this.registered;
}
private getObjects(
payload: Actions['GET_OBJECTS']['PAYLOAD'],
): Actions['GET_OBJECTS']['RETURN'] {
if (payload.ids.length === 0) return [];
const objects: COMEntity[] = [];
this.registered.forEach((object) => {
if (!payload.ids.includes(object.id)) return;
objects.push(object);
});
return objects;
}
private addObject(
payload: Actions['ADD_OBJECT']['PAYLOAD'],
): Actions['ADD_OBJECT']['RETURN'] {
if (this.registered.get(payload.id)) return false;
if (payload.parentId === undefined) payload.parentId = null;
this.registered.set(payload.id, payload);
this.scene.AddSceneObject(payload);
return true;
}
private updateObject(
payload: Actions['UPDATE_OBJECT']['PAYLOAD'],
): Actions['UPDATE_OBJECT']['RETURN'] {
const objectToUpdate = this.registered.get(payload.id);
if (!objectToUpdate) return false;
this.registered.set(payload.id, merge(objectToUpdate, payload));
const updatedObject = this.registered.get(payload.id)!;
this.scene.UpdateSceneObject({
...payload,
id: updatedObject.id,
entityType: updatedObject.entityType,
});
Object.assign(payload, updatedObject);
return true;
}
private deleteObject(
payload: Actions['DELETE_OBJECT']['PAYLOAD'],
): Actions['DELETE_OBJECT']['RETURN'] {
const deletedObject = this.registered.get(payload.id);
if (!deletedObject) return false;
// If the object has a parent, detach it first
if (deletedObject.parentId) {
// First detach from parent group
this.setParent({
object: { id: deletedObject.id },
parent: null,
});
}
// If deleting a group, update all children to have no parent
if (deletedObject.entityType === 'group') {
this.registered.forEach((object) => {
if (object.parentId === deletedObject.id) {
this.updateObject({
id: object.id,
parentId: null,
});
}
});
}
// copy object to payload to use later
Object.assign(payload, deletedObject);
this.registered.delete(payload.id);
// detach all children from parent if we delete a group
Array.from(this.registered.values()).forEach((object) => {
if (!object.parentId) return;
if (object.parentId !== payload.id) return;
object.parentId = null;
});
this.scene.DeleteSceneObject(deletedObject);
return true;
}
private selectObject(
payload: Actions['SELECT_OBJECT']['PAYLOAD'],
): Actions['SELECT_OBJECT']['RETURN'] {
const object = this.registered.get(payload.id);
if (!object) return false;
const sceneObject = this.scene.GetSceneObject(object);
if (!sceneObject) return false;
if (!('isSelectable' in sceneObject)) return false;
const activeTool = this.toolbox.GetActiveTool();
if (activeTool && isSelectTool(activeTool)) {
activeTool.AttachGizmo(sceneObject as DIVESelectable);
}
// copy object to payload to use later
Object.assign(payload, object);
return true;
}
private deselectObject(
payload: Actions['DESELECT_OBJECT']['PAYLOAD'],
): Actions['DESELECT_OBJECT']['RETURN'] {
const object = this.registered.get(payload.id);
if (!object) return false;
const sceneObject = this.scene.GetSceneObject(object);
if (!sceneObject) return false;
if (!('isSelectable' in sceneObject)) return false;
const activeTool = this.toolbox.GetActiveTool();
if (activeTool && isSelectTool(activeTool)) {
activeTool.DetachGizmo();
}
// copy object to payload to use later
Object.assign(payload, object);
return true;
}
private setBackground(
payload: Actions['SET_BACKGROUND']['PAYLOAD'],
): Actions['SET_BACKGROUND']['RETURN'] {
this.scene.SetBackground(payload.color);
return true;
}
private dropIt(
payload: Actions['DROP_IT']['PAYLOAD'],
): Actions['DROP_IT']['RETURN'] {
const object = this.registered.get(payload.id);
if (!object) return false;
const model = this.scene.GetSceneObject(object) as DIVEModel;
model.DropIt();
return true;
}
private placeOnFloor(
payload: Actions['PLACE_ON_FLOOR']['PAYLOAD'],
): Actions['PLACE_ON_FLOOR']['RETURN'] {
const object = this.registered.get(payload.id);
if (!object) return false;
this.scene.PlaceOnFloor(object);
return true;
}
private setCameraTransform(
payload: Actions['SET_CAMERA_TRANSFORM']['PAYLOAD'],
): Actions['SET_CAMERA_TRANSFORM']['RETURN'] {
this.controller.object.position.copy(payload.position);
this.controller.target.copy(payload.target);
this.controller.update();
return true;
}
private getCameraTransform(
payload: Actions['GET_CAMERA_TRANSFORM']['PAYLOAD'],
): Actions['GET_CAMERA_TRANSFORM']['RETURN'] {
const transform = {
position: this.controller.object.position.clone(),
target: this.controller.target.clone(),
};
Object.assign(payload, transform);
return transform;
}
private moveCamera(
payload: Actions['MOVE_CAMERA']['PAYLOAD'],
): Actions['MOVE_CAMERA']['RETURN'] {
let position = { x: 0, y: 0, z: 0 };
let target = { x: 0, y: 0, z: 0 };
if ('id' in payload) {
position = (this.registered.get(payload.id) as COMPov).position;
target = (this.registered.get(payload.id) as COMPov).target;
} else {
position = payload.position;
target = payload.target;
}
this.controller.MoveTo(
position,
target,
payload.duration,
payload.locked,
);
return true;
}
private setCameraLayer(
payload: Actions['SET_CAMERA_LAYER']['PAYLOAD'],
): Actions['SET_CAMERA_LAYER']['RETURN'] {
this.controller.object.SetCameraLayer(payload.layer);
return true;
}
private resetCamera(
payload: Actions['RESET_CAMERA']['PAYLOAD'],
): Actions['RESET_CAMERA']['RETURN'] {
this.controller.RevertLast(payload.duration);
return true;
}
private computeEncompassingView(
payload: Actions['COMPUTE_ENCOMPASSING_VIEW']['PAYLOAD'],
): Actions['COMPUTE_ENCOMPASSING_VIEW']['RETURN'] {
const sceneBB = this.scene.ComputeSceneBB();
const transform = this.controller.ComputeEncompassingView(sceneBB);
Object.assign(payload, transform);
return transform;
}
private zoomCamera(
payload: Actions['ZOOM_CAMERA']['PAYLOAD'],
): Actions['ZOOM_CAMERA']['RETURN'] {
if (payload.direction === 'IN') this.controller.ZoomIn(payload.by);
if (payload.direction === 'OUT') this.controller.ZoomOut(payload.by);
return true;
}
private setGizmoMode(
payload: Actions['SET_GIZMO_MODE']['PAYLOAD'],
): Actions['SET_GIZMO_MODE']['RETURN'] {
this.toolbox.SetGizmoMode(payload.mode);
return true;
}
private setGizmoVisibility(
payload: Actions['SET_GIZMO_VISIBILITY']['PAYLOAD'],
): Actions['SET_GIZMO_VISIBILITY']['RETURN'] {
this.toolbox.SetGizmoVisibility(payload);
return payload;
}
private setGizmoScaleLinked(
payload: Actions['SET_GIZMO_SCALE_LINKED']['PAYLOAD'],
): Actions['SET_GIZMO_SCALE_LINKED']['RETURN'] {
this.toolbox.SetGizmoScaleLinked(payload);
return payload;
}
private useTool(
payload: Actions['USE_TOOL']['PAYLOAD'],
): Actions['USE_TOOL']['RETURN'] {
this.toolbox.UseTool(payload.tool);
return true;
}
private modelLoaded(
payload: Actions['MODEL_LOADED']['PAYLOAD'],
): Actions['MODEL_LOADED']['RETURN'] {
(this.registered.get(payload.id) as COMModel).loaded = true;
return true;
}
private updateScene(
payload: Actions['UPDATE_SCENE']['PAYLOAD'],
): Actions['UPDATE_SCENE']['RETURN'] {
if (payload.name !== undefined) this.scene.name = payload.name;
if (payload.backgroundColor !== undefined)
this.scene.SetBackground(payload.backgroundColor);
if (payload.gridEnabled !== undefined)
this.scene.Grid.SetVisibility(payload.gridEnabled);
if (payload.floorEnabled !== undefined)
this.scene.Floor.SetVisibility(payload.floorEnabled);
if (payload.floorColor !== undefined)
this.scene.Floor.SetColor(payload.floorColor);
// fill payload with current values
// TODO optmize this
payload.name = this.scene.name;
payload.backgroundColor =
'#' + (this.scene.background as Color).getHexString();
payload.gridEnabled = this.scene.Grid.visible;
payload.floorEnabled = this.scene.Floor.visible;
payload.floorColor =
'#' +
(
this.scene.Floor.material as MeshStandardMaterial
).color.getHexString();
return true;
}
private generateMedia(
payload: Actions['GENERATE_MEDIA']['PAYLOAD'],
): Actions['GENERATE_MEDIA']['RETURN'] {
let position = { x: 0, y: 0, z: 0 };
let target = { x: 0, y: 0, z: 0 };
if ('id' in payload) {
position = (this.registered.get(payload.id) as COMPov).position;
target = (this.registered.get(payload.id) as COMPov).target;
} else {
position = payload.position;
target = payload.target;
}
return this._mediaGenerator.get().then((module) => {
return module.GenerateMedia(
position,
target,
payload.width,
payload.height,
);
});
}
private setParent(
payload: Actions['SET_PARENT']['PAYLOAD'],
): Actions['SET_PARENT']['RETURN'] {
const object = this.registered.get(payload.object.id);
if (!object) return false;
const sceneObject = this.scene.GetSceneObject(object);
if (!sceneObject) return false;
if (payload.parent === null) {
// detach from current parent
this.scene.Root.attach(sceneObject);
// Update registration to reflect no parent
this.updateObject({
id: object.id,
parentId: null,
});
return true;
}
if (payload.object.id === payload.parent.id) {
// cannot attach object to itself
return false;
}
const parent = this.registered.get(payload.parent.id);
if (!parent) {
// detach from current parent
this.scene.Root.attach(sceneObject);
// Update registration to reflect no parent
this.updateObject({
id: object.id,
parentId: null,
});
return true;
}
// attach to new parent
const parentObject = this.scene.GetSceneObject(parent);
if (!parentObject) {
// detach from current parent
this.scene.Root.attach(sceneObject);
// Update registration to reflect no parent
this.updateObject({
id: object.id,
parentId: null,
});
return true;
}
// attach to new parent
parentObject.attach(sceneObject);
// Update registration to reflect new parent
this.updateObject({
id: object.id,
parentId: parent.id,
});
return true;
}
private exportScene(
payload: Actions['EXPORT_SCENE']['PAYLOAD'],
): Actions['EXPORT_SCENE']['RETURN'] {
return new Promise<string | null>((resolve, reject) => {
this._io
.get()
.then((io) => {
resolve(io.Export(payload.type));
})
.catch(reject);
});
}
}
export type { Actions } from './actions/index.ts';