import {AbstractActionManager, AbstractMesh, ActionManager, Observable, Scene, Vector3, WebXRDefaultExperience} from "@babylonjs/core"; import {DiagramEntity, DiagramEntityType, DiagramEvent, DiagramEventType} from "./types/diagramEntity"; import log from "loglevel"; import {appConfigInstance} from "../util/appConfig"; import {buildEntityActionManager} from "./functions/buildEntityActionManager"; import {DefaultScene} from "../defaultScene"; import {DiagramMenuManager} from "./diagramMenuManager"; import {DiagramEventObserverMask} from "./types/diagramEventObserverMask"; import {DiagramObject} from "./diagramObject"; import {getMe} from "../util/me"; import {UserModelType} from "../users/userTypes"; import {vectoxys} from "./functions/vectorConversion"; import {controllerObservable} from "../controllers/controllers"; import {ControllerEvent} from "../controllers/types/controllerEvent"; import {HEX_TO_COLOR_NAME, TEMPLATE_TO_SHAPE} from "../react/types/chatTypes"; export class DiagramManager { private readonly _logger = log.getLogger('DiagramManager'); private readonly _controllerObservable: Observable; private readonly _diagramEntityActionManager: ActionManager; public readonly onDiagramEventObservable: Observable = new Observable(); public readonly onUserEventObservable: Observable = new Observable(); private readonly _diagramMenuManager: DiagramMenuManager; private readonly _scene: Scene; private readonly _diagramObjects: Map = new Map(); private readonly _me: string; private _moving: number = 10; private _i: number = 0; public get diagramMenuManager(): DiagramMenuManager { return this._diagramMenuManager; } public setXR(xr: WebXRDefaultExperience): void { this._diagramMenuManager.setXR(xr); } constructor(readyObservable: Observable) { this._me = getMe(); this._scene = DefaultScene.Scene; this._diagramMenuManager = new DiagramMenuManager(this.onDiagramEventObservable, controllerObservable, readyObservable); this._diagramEntityActionManager = buildEntityActionManager(controllerObservable); this.onDiagramEventObservable.add(this.onDiagramEvent, DiagramEventObserverMask.FROM_DB, true, this); this.onUserEventObservable.add((user) => { if (user.id != this._me) { this._logger.debug('user event', user); } }); window.setInterval(() => { this._i++; const platform = this._scene.getMeshByName('platform'); if (!platform || !platform.physicsBody) { return; } if (platform.physicsBody) { if ((this._i % this._moving) == 0) { this.onUserEventObservable.notifyObservers( { id: this._me, name: 'me', type: 'user', base: { position: vectoxys(platform.absolutePosition), rotation: vectoxys(platform.absoluteRotationQuaternion.toEulerAngles()), velocity: vectoxys(platform.physicsBody.getLinearVelocity()) }, } ); } if (platform.physicsBody.getLinearVelocity().length() > 0.01) { this._moving = 1; } else { this._moving = 10; } } }, 100); document.addEventListener('uploadImage', (event: CustomEvent) => { const diagramEntity: DiagramEntity = { template: '#image-template', image: event.detail.data, text: event.detail.name, type: DiagramEntityType.ENTITY, position: {x: 0, y: 1.6, z: 0}, rotation: {x: 0, y: Math.PI, z: 0}, scale: {x: 1, y: 1, z: 1}, } const object = new DiagramObject(this._scene, this.onDiagramEventObservable, {diagramEntity: diagramEntity}); this._diagramObjects.set(diagramEntity.id, object); //const newMesh = buildMeshFromDiagramEntity(diagramEntity, this._scene); if (this.onDiagramEventObservable) { this.onDiagramEventObservable.notifyObservers({ type: DiagramEventType.ADD, entity: diagramEntity }, DiagramEventObserverMask.TO_DB); } }); // Chat event listeners for AI-powered diagram creation document.addEventListener('chatCreateEntity', (event: CustomEvent) => { const {entity} = event.detail; this._logger.debug('chatCreateEntity', entity); // Generate a default label if none is provided // Use strict check to allow empty string "" (explicit no label) while still // generating labels for undefined/null (user didn't specify) if (entity.text === undefined || entity.text === null) { entity.text = this.generateDefaultLabel(entity); this._logger.debug('Generated default label:', entity.text); } const object = new DiagramObject(this._scene, this.onDiagramEventObservable, { diagramEntity: entity, actionManager: this._diagramEntityActionManager }); this._diagramObjects.set(entity.id, object); this.onDiagramEventObservable.notifyObservers({ type: DiagramEventType.ADD, entity: entity }, DiagramEventObserverMask.TO_DB); }); document.addEventListener('chatRemoveEntity', (event: CustomEvent) => { const {target} = event.detail; this._logger.debug('chatRemoveEntity', target); const entity = this.findEntityByIdOrLabel(target); if (entity) { const diagramObject = this._diagramObjects.get(entity.id); if (diagramObject) { diagramObject.dispose(); this._diagramObjects.delete(entity.id); this.onDiagramEventObservable.notifyObservers({ type: DiagramEventType.REMOVE, entity: entity }, DiagramEventObserverMask.TO_DB); } } }); document.addEventListener('chatModifyEntity', (event: CustomEvent) => { const {target, updates} = event.detail; this._logger.debug('chatModifyEntity', target, updates); const entity = this.findEntityByIdOrLabel(target); if (entity) { const diagramObject = this._diagramObjects.get(entity.id); if (diagramObject) { // Apply updates using setters (each setter handles its own DB notification) if (updates.text !== undefined) { diagramObject.text = updates.text; } if (updates.color !== undefined) { diagramObject.color = updates.color; } if (updates.template !== undefined) { diagramObject.template = updates.template; } if (updates.position !== undefined) { diagramObject.position = updates.position; } if (updates.scale !== undefined) { diagramObject.scale = updates.scale; } if (updates.rotation !== undefined) { diagramObject.rotation = updates.rotation; } } } else { this._logger.warn('chatModifyEntity: entity not found:', target); } }); document.addEventListener('chatModifyConnection', (event: CustomEvent) => { const {target, updates} = event.detail; this._logger.debug('chatModifyConnection', target, updates); let connection: DiagramEntity | undefined; // Check if target is a connection:fromId:toId format if (target.startsWith('connection:')) { const parts = target.split(':'); if (parts.length === 3) { const fromId = parts[1]; const toId = parts[2]; // Find connection by from/to connection = Array.from(this._diagramObjects.values()) .map(obj => obj.diagramEntity) .find(e => e.template === '#connection-template' && e.from === fromId && e.to === toId); } } else { // Find by label (text) connection = this.findEntityByIdOrLabel(target); // Verify it's a connection if (connection && connection.template !== '#connection-template') { this._logger.warn('chatModifyConnection: found entity is not a connection:', target); connection = undefined; } } if (connection) { const diagramObject = this._diagramObjects.get(connection.id); if (diagramObject) { if (updates.text !== undefined) { diagramObject.text = updates.text; } if (updates.color !== undefined) { diagramObject.color = updates.color; } } } else { this._logger.warn('chatModifyConnection: connection not found:', target); } }); document.addEventListener('chatListEntities', (event: CustomEvent) => { const requestId = event.detail?.requestId; this._logger.debug('chatListEntities', requestId ? `(request: ${requestId})` : ''); const entities = Array.from(this._diagramObjects.values()).map(obj => ({ id: obj.diagramEntity.id, label: obj.diagramEntity.text || '', template: obj.diagramEntity.template, text: obj.diagramEntity.text || '', color: obj.diagramEntity.color, position: obj.diagramEntity.position, // Include from/to for connections from: obj.diagramEntity.from, to: obj.diagramEntity.to })); const responseEvent = new CustomEvent('chatListEntitiesResponse', { detail: { entities, requestId }, bubbles: true }); document.dispatchEvent(responseEvent); }); // Resolve entity label/ID to actual entity ID and label document.addEventListener('chatResolveEntity', (event: CustomEvent) => { const {target, requestId} = event.detail; this._logger.debug('chatResolveEntity', target); const entity = this.findEntityByIdOrLabel(target); const responseEvent = new CustomEvent('chatResolveEntityResponse', { detail: { requestId, target, entityId: entity?.id || null, entityLabel: entity?.text || null, found: !!entity }, bubbles: true }); document.dispatchEvent(responseEvent); }); // Clear all entities from the diagram document.addEventListener('chatClearDiagram', () => { this._logger.debug('chatClearDiagram - removing all entities'); const entitiesToRemove = Array.from(this._diagramObjects.keys()); for (const id of entitiesToRemove) { const diagramObject = this._diagramObjects.get(id); if (diagramObject) { const entity = diagramObject.diagramEntity; diagramObject.dispose(); this._diagramObjects.delete(id); this.onDiagramEventObservable.notifyObservers({ type: DiagramEventType.REMOVE, entity: entity }, DiagramEventObserverMask.TO_DB); } } this._logger.debug(`Cleared ${entitiesToRemove.length} entities`); }); // Get current camera position and orientation // Camera may be parented to a platform, so we use world-space coordinates document.addEventListener('chatGetCamera', () => { this._logger.debug('chatGetCamera'); const camera = this._scene.activeCamera; if (!camera) { this._logger.warn('No active camera found'); return; } // World-space position (accounts for parent transforms) const position = camera.globalPosition; // World-space forward direction (where camera is looking) const forward = camera.getForwardRay(1).direction; // World up vector const worldUp = new Vector3(0, 1, 0); // Compute ground-projected forward (for intuitive forward/back movement) // This ignores pitch so looking up/down doesn't affect horizontal movement const groundForward = new Vector3(forward.x, 0, forward.z); const groundForwardLength = groundForward.length(); if (groundForwardLength > 0.001) { groundForward.scaleInPlace(1 / groundForwardLength); } else { // Looking straight up/down - use a fallback forward groundForward.set(0, 0, -1); } // Compute right vector (perpendicular to groundForward in XZ plane) // Right = Cross(groundForward, worldUp) gives left, so we negate or swap const groundRight = Vector3.Cross(worldUp, groundForward).normalize(); const responseEvent = new CustomEvent('chatGetCameraResponse', { detail: { position: {x: position.x, y: position.y, z: position.z}, forward: {x: forward.x, y: forward.y, z: forward.z}, groundForward: {x: groundForward.x, y: groundForward.y, z: groundForward.z}, groundRight: {x: groundRight.x, y: groundRight.y, z: groundRight.z} }, bubbles: true }); document.dispatchEvent(responseEvent); }); this._logger.debug("DiagramManager constructed"); } public get actionManager(): AbstractActionManager { return this._diagramEntityActionManager; } public getDiagramObject(id: string) { return this._diagramObjects.get(id); } public isDiagramObject(mesh: AbstractMesh) { return this._diagramObjects.has(mesh?.id) } public createCopy(id: string): DiagramObject { const diagramObject = this._diagramObjects.get(id); if (!diagramObject) { this._logger.warn('createCopy called with invalid diagram object', id); return null; } const obj = diagramObject.clone(); return obj; } public addObject(diagramObject: DiagramObject) { this._diagramObjects.set(diagramObject.diagramEntity.id, diagramObject); } public get config() { return appConfigInstance; } private findEntityByIdOrLabel(target: string): DiagramEntity | null { // First try direct ID match const byId = this._diagramObjects.get(target); if (byId) { return byId.diagramEntity; } // Then try label match (case-insensitive) const targetLower = target.toLowerCase(); for (const [, obj] of this._diagramObjects) { if (obj.diagramEntity.text?.toLowerCase() === targetLower) { return obj.diagramEntity; } } return null; } /** * Generates a default label for an entity based on its color and shape. * Format: "{color} {shape} {number}" e.g., "blue box 1", "red sphere 2" * The number is determined by counting existing entities with the same prefix. */ private generateDefaultLabel(entity: DiagramEntity): string { // Get color name from hex const colorHex = entity.color?.toLowerCase() || '#0000ff'; const colorName = HEX_TO_COLOR_NAME[colorHex] || 'blue'; // Get shape name from template const shapeName = TEMPLATE_TO_SHAPE[entity.template] || 'box'; // Create the prefix (e.g., "blue box") const prefix = `${colorName} ${shapeName}`; // Count existing entities with labels starting with this prefix let maxNumber = 0; for (const [, obj] of this._diagramObjects) { const label = obj.diagramEntity.text?.toLowerCase() || ''; if (label.startsWith(prefix)) { // Extract the number from the end of the label const match = label.match(new RegExp(`^${prefix}\\s*(\\d+)$`)); if (match) { const num = parseInt(match[1], 10); if (num > maxNumber) { maxNumber = num; } } } } // Return the next number in sequence return `${prefix} ${maxNumber + 1}`; } private onDiagramEvent(event: DiagramEvent) { let diagramObject = this._diagramObjects.get(event?.entity?.id); switch (event.type) { case DiagramEventType.CLEAR: this._diagramObjects.forEach((value) => { value.dispose(); }); this._diagramObjects.clear(); break; case DiagramEventType.ADD: if (diagramObject) { diagramObject.fromDiagramEntity(event.entity); } else { diagramObject = DiagramObject.CreateObject(this._scene, this.onDiagramEventObservable, {diagramEntity: event.entity, actionManager: this._diagramEntityActionManager}); } if (diagramObject) { this._diagramObjects.set(event.entity.id, diagramObject); } else { this._logger.error('failed to create diagram object for ', event.entity); } break; case DiagramEventType.REMOVE: if (diagramObject) { diagramObject.dispose(); } this._diagramObjects.delete(event?.entity?.id); break; case DiagramEventType.MODIFY: this._logger.debug(event); //diagramObject = this._diagramObjects.get(event.entity.id); if (diagramObject && event.entity.text && event.entity.text != diagramObject.text) { diagramObject.text = event.entity.text; } else { this._logger.warn('Skipping text update for', event); } if (event.entity.position) { //diagramObject.position = event.entity.position; } break; } } }