Play / Pause a simulation
+Play / Pause / Stop a simulation
TODO: simulation play / pause controls
+diff --git a/.cursor/rules/sql-rules.mdc b/.cursor/rules/sql-rules.mdc new file mode 100644 index 0000000..865d1ba --- /dev/null +++ b/.cursor/rules/sql-rules.mdc @@ -0,0 +1,39 @@ +--- +description: SQL conventions — no table aliases; columns are always prefixed +globs: "**/*.{js,sql,php}" +alwaysApply: false +--- + +# SQL rules + +All tables use **prefixed column names** (`sim_uuid`, `own_usr_id`, `usr_uuid`, `ekfs_agent_id`, …). There is never column ambiguity across joins, so **do not use table aliases**. + +## Do not + +- Short table aliases: `s`, `o`, `u`, `ekfs`, etc. +- Qualified columns when an alias was the only reason: `s.sim_uuid`, `o.own_sim_uuid` + +## Do + +- Join on bare prefixed column names. +- Use full table names (or `${qualified}` template vars) in `FROM` / `JOIN` only — no alias after the table. + +```sql +-- BAD +SELECT s.sim_name, BIN_TO_UUID(s.sim_uuid) AS simulationUuid +FROM `p42SIM`.simulations s +INNER JOIN `p42GUI`.simowners o ON o.own_sim_uuid = s.sim_uuid +INNER JOIN `p42GUI`.users u ON o.own_usr_id = u.usr_id +WHERE u.usr_uuid = ? AND s.sim_uuid = UUID_TO_BIN(?) + +-- GOOD +SELECT sim_name, BIN_TO_UUID(sim_uuid) AS simulationUuid +FROM `p42SIM`.simulations +INNER JOIN `p42GUI`.simowners ON own_sim_uuid = sim_uuid +INNER JOIN `p42GUI`.users ON own_usr_id = usr_id +WHERE usr_uuid = ? AND sim_uuid = UUID_TO_BIN(?) +``` + +`AS` on **result columns** (e.g. `AS simulationUuid` for the API) is fine — that is not a table alias. + +When adding a query, read an existing join in the same repo first and match its style. diff --git a/app/assets/json/arena/arenaConfig1.json b/app/assets/json/arena/arenaConfig1.json index 609789d..75255be 100644 --- a/app/assets/json/arena/arenaConfig1.json +++ b/app/assets/json/arena/arenaConfig1.json @@ -4,4 +4,4 @@ "y": 100, "z": 100 } -} \ No newline at end of file +} diff --git a/app/assets/json/global/busChannels.json b/app/assets/json/global/busChannels.json new file mode 100644 index 0000000..4f32170 --- /dev/null +++ b/app/assets/json/global/busChannels.json @@ -0,0 +1,11 @@ +{ + "maestro": { + "actionsChannel": "system:requests:maestro", + "lifecycleChannel": "system:maestro:lifecycle:[UID]" + }, + "observer": { + "actionsChannel": "system:requests:observer", + "subscribeFrequencyMs": 1000, + "cameraDebounceMs": 600 + } +} diff --git a/app/assets/json/global/services.json b/app/assets/json/global/services.json index 5afd364..aa8e0a0 100644 --- a/app/assets/json/global/services.json +++ b/app/assets/json/global/services.json @@ -44,20 +44,12 @@ "method": "POST" }, "get": { - "uri": "/api/sims/{simId}", + "uri": "/api/sims/{simulationUuid}", "method": "GET" }, "create": { "uri": "/api/sims", "method": "PUT" - }, - "start": { - "uri": "/api/sims/{simId}/start", - "method": "PUT" - }, - "pause": { - "uri": "/api/sims/{simId}/pause", - "method": "PUT" } } } diff --git a/app/assets/json/threetobus/eventsMapping.json b/app/assets/json/threetobus/eventsMapping.json index f3ca2d9..338aa8d 100644 --- a/app/assets/json/threetobus/eventsMapping.json +++ b/app/assets/json/threetobus/eventsMapping.json @@ -1,6 +1,6 @@ [ { - "chan": "system:gps:agents", + "chan": "system:observer:subscribed[UID]:agents", "events": [ { "eventName": "move", diff --git a/app/assets/styles/app.css b/app/assets/styles/app.css index ce72c0f..166220e 100755 --- a/app/assets/styles/app.css +++ b/app/assets/styles/app.css @@ -397,6 +397,10 @@ div.window > section button[eicbutton][rounded] { color: #222; } +[eicdatagrid] .dataset .row:hover { + background-color: #483; +} + /* Customizations to buildoz*/ bz-select > button{ background: linear-gradient( to bottom, #251, #372 15%, #483 50%, #372 85%, #251 ) !important; diff --git a/app/controllers/WindozAppController.json b/app/controllers/WindozAppController.json index 179b352..f3a60cc 100755 --- a/app/controllers/WindozAppController.json +++ b/app/controllers/WindozAppController.json @@ -14,6 +14,7 @@ ], "json": [ {"name":"global/app-menu-map.json"}, + {"id":"busChannels", "name":"global/busChannels.json"}, {"path": "/app/controllers/common/", "name": "errorController.json", "comment": "Trick to preload errorController stuff, to still have error messages if S3 is down."} ] } diff --git a/app/controllers/live/DashboardsController.js b/app/controllers/live/DashboardsController.js index 9c989ae..a335d8e 100644 --- a/app/controllers/live/DashboardsController.js +++ b/app/controllers/live/DashboardsController.js @@ -3,6 +3,7 @@ class DashboardsController extends WindozController { constructor(params) { super(params) this.arenaConfig = app.Assets.Store.json.arenaConfig + this.busChannels = app.Assets.Store.json.busChannels this.eventsMapping = app.Assets.Store.json.eventsMapping console.log('=============>DashboardsController constructor') } @@ -21,6 +22,7 @@ class DashboardsController extends WindozController { const ttb = new app.LoadedModules.Threetobus({ eventsMapping: this.eventsMapping, sceneSize: this.arenaConfig.arenaSize, + observer: this.busChannels.observer, }) ttb.initScene({ axes: true, diff --git a/app/controllers/sims/SimsController.js b/app/controllers/sims/SimsController.js index e745aae..6078574 100644 --- a/app/controllers/sims/SimsController.js +++ b/app/controllers/sims/SimsController.js @@ -39,7 +39,7 @@ class SimsController extends WindozController { static: true, expanded: false, withSettings: false, - windowStyle: WindozDomContent.boxFromPrefs('sims.managesimview', { x: 50, y: 50, w: 800, h: 600 }), + windowStyle: WindozDomContent.boxFromPrefs('sims.managesimview', { x: 50, y: 50, w: 1000, h: 600 }), }, { models: models, diff --git a/app/models/SimsModel.js b/app/models/SimsModel.js index abd5066..6ff02ba 100644 --- a/app/models/SimsModel.js +++ b/app/models/SimsModel.js @@ -1,20 +1,36 @@ -class SimsModel extends WindozModel { +class SimsModel extends WindozBusModel { constructor() { super() this.ressource = '/sims' + this.busChannels = app.Assets.Store.json.busChannels } async list() { - let endpoint = {...app.config.api[this.ressource].list} - return( - this.request(endpoint.uri, endpoint.method) + const endpoint = {...app.config.api[this.ressource].list} + const [listResponse, statusList] = await Promise.all([ + this.request(endpoint.uri, endpoint.method), + this.getSimulationsStatus(), + ]) + + const sims = listResponse?.payload ?? [] + const statuses = Array.isArray(statusList) ? statusList : (statusList?.simulations ?? []) + const stateBySimulationId = new Map( + statuses.map(item => [item.simulationId, item.state]) ) + + return({ + ...listResponse, + payload: sims.map(sim => ({ + ...sim, + state: stateBySimulationId.get(sim.simulationUuid) ?? 'idle', + })), + }) } - async get(simId) { + async get(simulationUuid) { let endpoint = {...app.config.api[this.ressource].get} - endpoint.uri = endpoint.uri.replace('{simId}', simId) + endpoint.uri = endpoint.uri.replace('{simulationUuid}', simulationUuid) return( this.request(endpoint.uri, endpoint.method) ) @@ -27,20 +43,43 @@ class SimsModel extends WindozModel { ) } - async start(simId) { - let endpoint = {...app.config.api[this.ressource].start} - endpoint.uri = endpoint.uri.replace('{simId}', simId) - return( - this.request(endpoint.uri, endpoint.method) - ) + async startSimulation(simulationUuid) { + return(this.busActionRequest( + this.busChannels.maestro.actionsChannel, + 'STARTSIMULATION', + { + simulationUuid, + infraId: null, + }, + 60000 + )) } - async pause(simId) { - let endpoint = {...app.config.api[this.ressource].pause} - endpoint.uri = endpoint.uri.replace('{simId}', simId) - return( - this.request(endpoint.uri, endpoint.method) - ) + async stopSimulation(simulationUuid) { + return(this.busActionRequest( + this.busChannels.maestro.actionsChannel, + 'STOPSIMULATION', + { simulationUuid }, + 30000 + )) + } + + async pauseSimulation(simulationUuid) { + return(this.busActionRequest( + this.busChannels.maestro.actionsChannel, + 'PAUSESIMULATION', + { simulationUuid }, + 30000 + )) + } + + async getSimulationsStatus() { + return(this.busActionRequest( + this.busChannels.maestro.actionsChannel, + 'GETSIMULATIONSSTATUS', + {}, + 15000 + )) } } diff --git a/app/thirdparty/Threetobus/threetobus.module.js b/app/thirdparty/Threetobus/threetobus.module.js index 3f171cb..f9bbdb2 100644 --- a/app/thirdparty/Threetobus/threetobus.module.js +++ b/app/thirdparty/Threetobus/threetobus.module.js @@ -9,6 +9,16 @@ export class Threetobus{ 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 = {} @@ -19,8 +29,24 @@ export class Threetobus{ } busReconnect(){ - this.commitConfig() // To resubscribe... - //TODO : Not ideal because if we're in the middle of non-commited changes... + 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 } @@ -28,15 +54,21 @@ export class Threetobus{ set EventsMapping(newConfig) { this._stagedEventsMapping = newConfig } async commitConfig(){ + const resolvedMapping = this._resolvedEventsMapping() const chansToAdd = [] const chansToKeep = [] - for(const chanObj of this._stagedEventsMapping){ + 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))) - await app.MessageBus.subscribe(chansToAdd.map(item => item.chan)) - await app.MessageBus.unSubscribe(chansToDel.map(item => item.chan)) + 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) @@ -59,7 +91,7 @@ export class Threetobus{ app.MessageBus.removeBusListener(eventToDel.eventName, this.processBusEvent.bind(this, eventToDel.eventName), 'threetobus') } - this._curEventsMapping = this.deepClone(this._stagedEventsMapping) + this._curEventsMapping = this.deepClone(resolvedMapping) } deepClone(obj) { // Needed because structuredClone doesn't take functions (and we have transformers) @@ -88,7 +120,8 @@ export class Threetobus{ // 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) + let obj3D = this.scene.getObjectByName(id) + if(!obj3D) obj3D = this._ensurePlaceholderAgent(id) if(obj3D){ this.assignFromConfig(payload, mapping, obj3D) } @@ -161,6 +194,99 @@ export class Threetobus{ 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() diff --git a/app/thirdparty/eicui/eicui-2.1.js b/app/thirdparty/eicui/eicui-2.1.js index da3087c..aa34a30 100755 --- a/app/thirdparty/eicui/eicui-2.1.js +++ b/app/thirdparty/eicui/eicui-2.1.js @@ -3369,10 +3369,11 @@ class DataGrid extends EicComponent { let actions = ui.create(`
${action.icon}`); - button.addEventListener('click', action.callback.bind(row.getAttribute('data-id') != '' ? JSON.parse(row.getAttribute('data-id')): this)); - actions.appendChild(button); + for(let action of this.options.rowActions) { + const severityAttr = action.severity ? ` ${action.severity}` : '' + let button = ui.create(``) + button.addEventListener('click', action.callback.bind(row.getAttribute('data-id') != '' ? JSON.parse(row.getAttribute('data-id')): this)) + actions.appendChild(button) } } diff --git a/app/views/sims/CreateSimView.js b/app/views/sims/CreateSimView.js index 5ed44c3..8baa60f 100644 --- a/app/views/sims/CreateSimView.js +++ b/app/views/sims/CreateSimView.js @@ -26,7 +26,10 @@ class CreateSimView extends WindozDomContent { this.models.sims.create({ kfId: this.outputs.keyframesSelector.value, simName: this.outputs.simName.value - }).then(data => ui.growl.append('Simulation created!','success',3000)) + }).then(data => { + const simulationUuid = data?.payload?.simulationUuid ?? 'unknown' + ui.growl.append(`Simulation created (${simulationUuid})`, 'success', 4000) + }) this.unload() } } diff --git a/app/views/sims/ManageSimView.html b/app/views/sims/ManageSimView.html index b49391a..1673d9c 100644 --- a/app/views/sims/ManageSimView.html +++ b/app/views/sims/ManageSimView.html @@ -1,8 +1,18 @@ +TODO: simulation play / pause controls
+