357 lines
15 KiB
JavaScript
357 lines
15 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.commitConfig()
|
|
|
|
this.cameras = {}
|
|
this.renderers = []
|
|
this.tweensRegistry = {}
|
|
app.events.addEvent('MessageBus.Connected', this.busReconnect.bind(this), 'threetobus')
|
|
|
|
}
|
|
|
|
busReconnect(){
|
|
this.commitConfig() // To resubscribe...
|
|
//TODO : Not ideal because if we're in the middle of non-commited changes...
|
|
}
|
|
|
|
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){
|
|
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 => 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){
|
|
const obj3D = this.scene.getObjectByName(id)
|
|
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))
|
|
}
|
|
|
|
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
|