import * as THREE from '../Three/three.module.js' import { OrbitControls } from '../Three/OrbitControls.module.js' import * as TWEEN from '../Three/tween.module.js' export class Threetobus{ constructor(options){ Object.assign(this, app.helpers.helpers3D) this._curEventsMapping = [] this._stagedEventsMapping = options.eventsMapping this.sceneSize = options.sceneSize this._observerConfig = { actionsChannel: 'system:requests:observer', subscribeFrequencyMs: 1000, cameraDebounceMs: 600, ...options.observer, } this._frustumRenderEngine = null this._frustumDebounceTimer = null this._frustumCameraChangeHandler = null this._frustumWatching = false this.commitConfig() this.cameras = {} this.renderers = [] this.tweensRegistry = {} app.events.addEvent('MessageBus.Connected', this.busReconnect.bind(this), 'threetobus') } busReconnect(){ this.commitConfig() if(this._frustumWatching) this._scheduleFrustumResubscribe() } resolveSubscriberChan(chanTemplate) { if(typeof(chanTemplate) !== 'string') return(null) const uid = app.User?.identity?.uuid if(!uid) return(null) return(chanTemplate.replace(/\[UID\]/g, uid).replace(/\{uid\}/g, uid)) } _resolvedEventsMapping() { if(!Array.isArray(this._stagedEventsMapping)) return([]) return(this._stagedEventsMapping.map(chanObj => { const chan = this.resolveSubscriberChan(chanObj.chan) if(!chan) return(null) return({ ...chanObj, chan }) }).filter(Boolean)) } get EventsMapping() { return this._stagedEventsMapping } get liveEventsMapping() { return this._curEventsMapping } set EventsMapping(newConfig) { this._stagedEventsMapping = newConfig } async commitConfig(){ const resolvedMapping = this._resolvedEventsMapping() const chansToAdd = [] const chansToKeep = [] for(const chanObj of resolvedMapping){ 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) )) if(app.MessageBus?.connected) { if(chansToAdd.length) await app.MessageBus.subscribe(chansToAdd.map(item => item.chan)) if(chansToDel.length) 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(resolvedMapping) } 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 => app.MessageBus.chanMatch(chan, item.chan)) if(!chanObj) { console.warn('Not a configured chan!'); return } const eventObj = chanObj.events.find(item => item.eventName==eventType) if(!eventObj) { console.warn('Not a configured event!'); return } for(const mapping of eventObj.mappings){ let id = this.getValueByPath(payload, mapping.id) //TODO Child selection is static in mapping... does it make sense to also have the event select the child ? // if yes : how to discriminate static value from event-mapping definition ? if(mapping.child) id += '_'+mapping.child if(id){ let obj3D = this.scene.getObjectByName(id) if(!obj3D) obj3D = this._ensurePlaceholderAgent(id) if(obj3D){ this.assignFromConfig(payload, mapping, obj3D) } } } } assignFromConfig(payload, mapping, obj3D) { const tweenProps = { position: { props: {}, method: this.smoothMove.bind(this), }, rotation:{ props: {}, method: this.smoothRotate.bind(this), }, } 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){ if(path.startsWith('position.')){ tweenProps.position.props[path.substring(9)] = value } else if(path.startsWith('rotation.')){ tweenProps.rotation.props[path.substring(9)] = value } } else { this.setProp(obj3D, path, value) } } // else console.warn('Could not get value from rule:',rule) } if(mapping.tween){ for(const tweenGroup in tweenProps){ if((Object.keys(tweenProps[tweenGroup].props).length>0) && (typeof(tweenProps[tweenGroup].method)=='function')){ tweenProps[tweenGroup].props.object = obj3D tweenProps[tweenGroup].props.delay = mapping.tweenDelay tweenProps[tweenGroup].props.easing = mapping.tweenEasing tweenProps[tweenGroup].method(tweenProps[tweenGroup].props) } // else { console.log('avoided tween', tweenGroup)} } } } 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)) } watchCameraFrustum(renderEngine) { if(!renderEngine || renderEngine.mode !== '3D') return if(!app.MessageBus?.config?.enabled) { console.warn('[Threetobus] MessageBus disabled — camera frustum watch skipped') return } this._frustumRenderEngine = renderEngine this._frustumWatching = true if(!this._frustumCameraChangeHandler) { this._frustumCameraChangeHandler = () => this._scheduleFrustumResubscribe() renderEngine.controls.addEventListener('change', this._frustumCameraChangeHandler) } app.MessageBus.whenConnected(() => { this.commitConfig() this._scheduleFrustumResubscribe() }) } stopWatchingCameraFrustum() { this._frustumWatching = false if(this._frustumDebounceTimer) { clearTimeout(this._frustumDebounceTimer) this._frustumDebounceTimer = null } if(this._frustumRenderEngine?.controls && this._frustumCameraChangeHandler) { this._frustumRenderEngine.controls.removeEventListener('change', this._frustumCameraChangeHandler) } this._frustumRenderEngine = null this._frustumCameraChangeHandler = null } _scheduleFrustumResubscribe() { if(!this._frustumWatching) return if(this._frustumDebounceTimer) clearTimeout(this._frustumDebounceTimer) this._frustumDebounceTimer = setTimeout(() => { this._frustumDebounceTimer = null this._resubscribeCameraFrustum() }, this._observerConfig.cameraDebounceMs) } async _resubscribeCameraFrustum() { if(!this._frustumWatching || !this._frustumRenderEngine) return if(!app.MessageBus?.connected) return const camera = this._frustumRenderEngine.camera if(!camera?.isPerspectiveCamera) return const planes = this._cameraFrustumPlanes(camera) const chan = this._observerConfig.actionsChannel try { await app.MessageBus.requestBusAction( chan, 'SUBSCRIBEFRUSTUM', { planes, frequency: this._observerConfig.subscribeFrequencyMs, }, 10000 ) } catch(err) { console.warn('[Threetobus] SUBSCRIBEFRUSTUM failed:', err) } } _cameraFrustumPlanes(camera) { camera.updateMatrixWorld(true) const matrix = new THREE.Matrix4().multiplyMatrices( camera.projectionMatrix, camera.matrixWorldInverse ) const frustum = new THREE.Frustum().setFromProjectionMatrix(matrix) return(frustum.planes.map(plane => ({ nx: plane.normal.x, ny: plane.normal.y, nz: plane.normal.z, d: plane.constant, }))) } _ensurePlaceholderAgent(agentId) { const geo = new THREE.BoxGeometry(0.4, 0.4, 0.4) const mat = new THREE.MeshStandardMaterial({ color: 0x44aa88 }) const obj3D = new THREE.Mesh(geo, mat) obj3D.name = agentId this.tweensRegistry[agentId] = { move: null, rotate: null } this.scene.add(obj3D) return(obj3D) } initScene(options){ // Scene this.scene = new THREE.Scene() if(options.grid){ this.grid = new THREE.GridHelper(this.sceneSize.x, this.sceneSize.y, 0x8888AA, 0x8888AA) this.grid.layers.set(1) this.scene.add(this.grid) } if(options.axes){ this.axes = new THREE.AxesHelper(this.sceneSize.x/2, this.sceneSize.y/2) this.axes.layers.set(2) this.scene.add(this.axes) } // Base plane const planeGeo = new THREE.PlaneGeometry(100, 100) const planeMat = new THREE.MeshBasicMaterial({ color: 0xaaaacc, opacity: 0.3, transparent: true, // needed for opacity < 1 to take effect side: THREE.DoubleSide }) this.basePlane = new THREE.Mesh(planeGeo, planeMat) this.basePlane.rotation.x = -Math.PI / 2 // lay it flat (like the grid) this.basePlane.position.y=-0.01 // to avoid artefacts on objets bases this.scene.add(this.basePlane) // 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, 2) light.position.set(5, 5, 5) this.scene.add(light) this.scene.add(new THREE.AmbientLight(0xffffff, 1)) } 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) } createAgent(id, desc){ this.tweensRegistry[id] = { 'move': null, 'rotate': null } return(this.agentFromJSON(id, desc)) } 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: None (mandatory for 'Linear') // 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 : ((options.easing !='Linear') ? 'InOut' : 'None') 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() } smoothRotate(options){ // options: object, dX, dY, dZ, delay, easing, easingMode // Absolute: x,y,z angle, in degrees // Relative: dx,dy,dz angle in deg // 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.rotation.x * Math.PI / 180: newX * Math.PI / 180 newX += (parseFloat(options.dx) || 0) let newY = parseFloat(options.y) newY = isNaN(newY) ? options.object.rotation.y * Math.PI / 180: newY * Math.PI / 180 newY += (parseFloat(options.dy) || 0) let newZ = parseFloat(options.z) newZ = isNaN(newZ) ? options.object.rotation.z* Math.PI / 180 : newZ * Math.PI / 180 newZ += (parseFloat(options.dz) || 0) if(this.tweensRegistry[options.object.name]['rotate']) this.tweensRegistry[options.object.name]['rotate'].end() this.tweensRegistry[options.object.name]['rotate'] = new TWEEN.Tween(options.object.rotation) .to({ x: newX, y: newY, z: newZ, }, options.delay) .easing(TWEEN.Easing[options.easing][options.easingMode]) .onComplete(() => this.tweensRegistry[options.object.name]['rotate']=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 //TODO resubscribe on connection loss & re-open