import * as THREE from './three.module.js' import { OrbitControls } from './OrbitControls.module.js' import * as TWEEN from './tween.module.js' export class Threetobus{ constructor(options){ this._curEventsMapping = [] this._stagedEventsMapping = options.eventsMapping this.commitConfig() this.cameras = {} this.renderers = [] this.tweensRegistry = {} } get EventsMapping() { return this._stagedEventsMapping } get liveEventsMapping() { return this._curEventsMapping } set EventsMapping(newConfig) { this._stagedEventsMapping = newConfig } async commitConfig(){ const chansToAdd = [] const chansToKeep = [] for(const chanObj of this._stagedEventsMapping){ console.log('staged chan:',chanObj.chan,' current ones:', this._curEventsMapping.map(item => item.chan)) if(this._curEventsMapping.map(item => item.chan).includes(chanObj.chan)) chansToKeep.push(chanObj) else chansToAdd.push(chanObj) } const chansToDel = this._curEventsMapping.filter(item => (!chansToKeep.map(c=>c.chan).includes(item.chan) && !chansToAdd.map(c=>c.chan).includes(item.chan))) await app.MessageBus.subscribe(chansToAdd.map(item => item.chan)) await app.MessageBus.unSubscribe(chansToDel.map(item => item.chan)) // console.log('subscribe:', chansToAdd.map(item => item.chan)) // console.log('unSubscribe:', chansToDel) const eventsToAdd = chansToAdd.flatMap(item => item.events.map(ev => ({ chan:item.chan, eventName:ev.eventName }))) let eventsToDel = []//= chansToDel.flatMap(item => item.events.map(ev => ({ chan:item.chan, eventName:ev.eventName }))) for(const oldChan of this._curEventsMapping){ for(const oldEvent of oldChan.events){ for(const keepChan of chansToKeep){ if(!keepChan.events.map(item=>item.eventName).includes(oldEvent.eventName)) eventsToDel.push({chan: oldChan.chan, eventName: oldEvent.eventName}) } } } // console.log('eventsToAdd:', eventsToAdd) // console.log('eventsToDel:', eventsToDel) for(const eventToAdd of eventsToAdd){ app.MessageBus.addBusListener(eventToAdd.eventName, [eventToAdd.chan], this.processBusEvent.bind(this, eventToAdd.eventName,), 'threetobus') } for(const eventToDel of eventsToDel){ app.MessageBus.removeBusListener(eventToDel.eventName, this.processBusEvent.bind(this, eventToDel.eventName), 'threetobus') } this._curEventsMapping = this.deepClone(this._stagedEventsMapping) } deepClone(obj) { // Needed because structuredClone doesn't take functions (and we have transformers) if (obj === null || typeof obj !== 'object') { return obj } if (Array.isArray(obj)) { return obj.map((el => this.deepClone(el))) } const clone = {} for (const key in obj) { clone[key] = this.deepClone(obj[key]) } return clone } processBusEvent(eventType, chan, payload, userId, x){ const chanObj = this._curEventsMapping.find(item => item.chan==chan) if(!chanObj) return const eventObj = chanObj.events.find(item => item.eventName==eventType) if(!eventObj) return for(const mapping of eventObj.mappings){ const id = this.getValueByPath(payload, mapping.id) if(id){ const obj3D = this.scene.getObjectByName(id) this.assignFromConfig(payload, mapping, obj3D) } } } assignFromConfig(payload, mapping, obj3D) { const toTween = {} for (const [path, rule] of Object.entries(mapping.assign)) { let value if (typeof rule === 'string') { // plain path value= this.getValueByPath(payload, rule) } else if((typeof(rule) == 'object') && (typeof(rule.transformer) == 'function')) { // transformer const fnargs = (rule.arguments || []).map(arg => this.getValueByPath(payload,arg)) value = rule.transformer(...fnargs) } if (value !== undefined) { if(mapping.tween && path.startsWith('position.')){ //TODO allow other tweenables toTween[path.substring(9)] = value } else { this.setProp(obj3D, path, value) } } } if(mapping.tween && (Object.keys(toTween).length>0)){ toTween.object = obj3D toTween.delay = mapping.tweenDelay this.smoothMove(toTween) } } setProp(obj3D, path, value) { const parts = path.split('.') let target = obj3D for (let i = 0; i < parts.length - 1; i++) { target = target[parts[i]] if (!target) return // path broken } const last = parts[parts.length - 1] // Handle Three.Color objects if (target[last] && target[last].isColor) { target[last].set(value) } else { target[last] = value } } getValueByPath(obj, path) { return(path.split('.').reduce((acc, key) => acc?.[key], obj)) } initScene(options){ // Scene this.scene = new THREE.Scene() if(options.grid){ this.grid = new THREE.GridHelper(20, 20, 0x8888AA, 0x8888AA) this.grid.layers.set(1) this.scene.add(this.grid) } if(options.axes){ this.axes = new THREE.AxesHelper(5, 5) this.axes.layers.set(2) this.scene.add(this.axes) } // Cameras this.cameras.camPerspective = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000) this.cameras.camPerspective.position.set(3, 3, 5) this.cameras.camPerspective.layers.enable(1) this.cameras.camPerspective.layers.enable(2) const aspect = window.innerWidth / window.innerHeight const frustumSize = 10 this.cameras.cam2Dtop = new THREE.OrthographicCamera( -frustumSize * aspect / 2, frustumSize * aspect / 2, frustumSize / 2, -frustumSize / 2, 0.1, 1000 ) this.cameras.cam2Dtop.position.set(0, 100, 0) this.cameras.cam2Dtop.lookAt(0, 0, 0) this.cameras.cam2Dtop.layers.enable(1) // Lights const light = new THREE.DirectionalLight(0xffffff, 1) light.position.set(5, 5, 5) light.intensity = 2 this.scene.add(light) this.scene.add(new THREE.AmbientLight(0xffffff, 0.4)) } startRendering(canvasEl, mode){ let renderEngine if(mode=='2D'){ renderEngine = new RenderingEngine(canvasEl, this.scene, this.cameras.cam2Dtop, mode) } else if(mode=='3D') { renderEngine = new RenderingEngine(canvasEl, this.scene, this.cameras.camPerspective, mode) } else console.error('Unknown rendering mode !') renderEngine.addControls() renderEngine.render() this.renderers.push(renderEngine) return(renderEngine) } agentFromJSON(id, desc){ let obj if(desc.type === 'Mesh') { const geom = new THREE[desc.geometry.type](...(desc.geometry.args || [])) const mat = new THREE[desc.material.type]( desc.material.color ? { color: desc.material.color } : {} ) obj = new THREE.Mesh(geom, mat) } else if(desc.type === 'Group') { obj = new THREE.Group() } else { throw new Error("Unknown type: " + desc.type) } // Apply transforms if(desc.position) obj.position.set(...desc.position) if(desc.rotation) obj.rotation.set(...desc.rotation) if(desc.scale) obj.scale.set(...desc.scale) // Recursively add children if(desc.children) { desc.children.forEach(childDesc => { const childId = (childDesc.idSuffix) ? `${id}_${childDesc.idSuffix}` : '' obj.add(this.agentFromJSON(childId, childDesc)) }) } obj.name = id this.tweensRegistry[id] = { 'move': null } return obj } smoothMove(options){ // options: object, dX, dY, dZ, delay, easing, easingMode // Absolute: x,y,z // Relative: dx,dy,dz // delay: ms // easings: Linear, Quadratic, Cubic, Quartic, Quintic, Sinusoidal, Exponential, Circular, Elastic, Back, Bounce // easingMode: In → starts slow, accelerates towards the end. // Out → starts fast, decelerates smoothly. // InOut → slow at both ends, faster in the middle. options.easing = options.easing ? options.easing : 'Quadratic' options.easingMode = options.easingMode ? options.easingMode : 'InOut' let newX = parseFloat(options.x) newX = isNaN(newX) ? options.object.position.x : newX newX += (parseFloat(options.dx) || 0) let newY = parseFloat(options.y) newY = isNaN(newY) ? options.object.position.y : newY newY += (parseFloat(options.dy) || 0) let newZ = parseFloat(options.z) newZ = isNaN(newZ) ? options.object.position.z : newZ newZ += (parseFloat(options.dz) || 0) if(this.tweensRegistry[options.object.name]['move']) this.tweensRegistry[options.object.name]['move'].end() this.tweensRegistry[options.object.name]['move'] = new TWEEN.Tween(options.object.position) .to({ x: newX, y: newY, z: newZ, }, options.delay) .easing(TWEEN.Easing[options.easing][options.easingMode]) .onComplete(() => this.tweensRegistry[options.object.name]['move']=null) .start() } } class RenderingEngine{ constructor(canvasEl, scene, camera, mode){ this.canvasEl = canvasEl this.scene = scene this.renderer = new THREE.WebGLRenderer({ antialias: true, canvas: this.canvasEl }) this.camera = camera this.mode = mode } addControls(){ this.controls = new OrbitControls(this.camera, this.canvasEl) if(this.mode=='2D'){ this.controls.maxPolarAngle = 0 // Math.PI / 2 this.controls.minPolarAngle = 0 // Math.PI / 2 } else if(this.mode=='3D'){ } this.controls.mouseButtons = { LEFT: THREE.MOUSE.ROTATE, // keep orbit on left MIDDLE: THREE.MOUSE.PAN, // pan with middle-click RIGHT: THREE.MOUSE.DOLLY // zoom with right-click } } render() { TWEEN.update() if(this.resizeRendererToDisplaySize()) { this.camera.aspect = this.renderer.domElement.clientWidth / this.canvasEl.clientHeight this.camera.updateProjectionMatrix() } this.renderer.render(this.scene, this.camera) requestAnimationFrame(this.render.bind(this)) } resizeRendererToDisplaySize() { const width = this.canvasEl.clientWidth const height = this.canvasEl.clientHeight if (this.canvasEl.width !== width || this.canvasEl.height !== height) { this.renderer.setSize(width, height, false) return true } return false } } // Make this module available to common JS if(!app.LoadedModules) app.LoadedModules = {} app.LoadedModules.Threetobus = Threetobus