Files
P42_UI/app/thirdparty/Threetobus/threetobus.module.js
T
2025-09-29 17:28:55 +00:00

298 lines
11 KiB
JavaScript

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 = []
}
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)
}
buildFromJSON(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.buildFromJSON(childId, childDesc))
})
}
obj.name = id
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)
new TWEEN.Tween(options.object.position)
.to({ x: newX,
y: newY,
z: newZ,
}, options.delay)
.easing(TWEEN.Easing[options.easing][options.easingMode])
.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