Files
P42_UI/app/thirdparty/Threetobus/threetobus.module.js
T
2026-06-21 21:09:21 +00:00

485 lines
19 KiB
JavaScript

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