UNPKG

@2112-lab/pathfinder

Version:

Pure JavaScript 3D pathfinding algorithm library for industrial plant pipe routing

431 lines (368 loc) 17.7 kB
import { Vector3 } from './Vector3.js'; /** * Manages connector-related operations for the pathfinding system * @class ConnectorManager */ export class ConnectorManager { /** * Create a new ConnectorManager instance * @param {Object} config - The configuration object * @param {Array<Object>} config.connections - Array of connections between objects * @param {string} config.connections[].from - UUID of the source object * @param {string} config.connections[].to - UUID of the target object * @param {number} minSegmentLength - Minimum length for straight pipe segments in world units * @param {SceneManager} sceneManager - Instance of SceneManager for scene operations * @param {GridSystem} gridSystem - Instance of GridSystem for grid operations */ constructor(config, minSegmentLength, sceneManager, gridSystem) { this.config = config; this.MIN_SEGMENT_LENGTH = minSegmentLength; this.sceneManager = sceneManager; this.gridSystem = gridSystem; } /** * Check if an object is a connector by checking if it's mentioned in connections * @param {Object} object - Scene object * @returns {boolean} True if the object is a connector */ isConnector(object) { if (!this.config.connections) return false; // Check if the object's UUID appears in any connection return this.config.connections.some(conn => conn.from === object.uuid || conn.to === object.uuid ); } /** * Calculate direction vector for a connector * @param {Object} connector - Connector object * @returns {Vector3|null} Direction vector or null if no parent or direction */ getConnectorDirection(connector) { // First check if direction is directly specified in userData if (connector.userData && Array.isArray(connector.userData.direction)) { const [x, y, z] = connector.userData.direction; return new Vector3(x, y, z); } // If no direction in userData and no parent, return null if (!connector.parent) { return null; } // For backward compatibility, calculate direction from parent if no userData.direction console.log(`[WARNING] No direction in userData for ${connector.name || connector.uuid} - using parent-based calculation (legacy mode)`); // Get world position of the connector const connectorPos = this.sceneManager.getWorldPosition(connector); // For the parent, use the center of its bounding box if available let parentPos; const parentBBox = this.sceneManager.getBoundingBox(connector.parent); if (parentBBox) { // Calculate the center of the parent's bounding box parentPos = new Vector3( (parentBBox.min.x + parentBBox.max.x) / 2, (parentBBox.min.y + parentBBox.max.y) / 2, (parentBBox.min.z + parentBBox.max.z) / 2 ); } else { // Fall back to parent's position if bounding box is not available parentPos = this.sceneManager.getWorldPosition(connector.parent); } // Calculate the direction vector from parent to connector const direction = new Vector3( connectorPos.x - parentPos.x, connectorPos.y - parentPos.y, connectorPos.z - parentPos.z ).normalize(); // Enforce orthogonality by aligning with the dominant axis const absX = Math.abs(direction.x); const absY = Math.abs(direction.y); const absZ = Math.abs(direction.z); if (absX >= absY && absX >= absZ) { // X is dominant direction.y = 0; direction.z = 0; direction.x = Math.sign(direction.x); } else if (absY >= absX && absY >= absZ) { // Y is dominant direction.x = 0; direction.z = 0; direction.y = Math.sign(direction.y); } else { // Z is dominant direction.x = 0; direction.y = 0; direction.z = Math.sign(direction.z); } return direction; } /** * Create a virtual segment for a connector * @param {Object} connector - Connector object * @param {Vector3} connectorPos - World position of the connector * @returns {Object|null} Virtual segment info or null if no parent */ createVirtualSegment(connector, connectorPos) { const direction = this.getConnectorDirection(connector); if (!direction) { return null; } // Create a virtual segment starting from the connector position // and extending in the direction vector for MIN_SEGMENT_LENGTH const endPos = new Vector3( connectorPos.x + direction.x * this.MIN_SEGMENT_LENGTH, connectorPos.y + direction.y * this.MIN_SEGMENT_LENGTH, connectorPos.z + direction.z * this.MIN_SEGMENT_LENGTH ); // Convert to voxel keys const startKey = this.gridSystem.voxelKey(connectorPos); const endKey = this.gridSystem.voxelKey(endPos); return { startPos: connectorPos, endPos: endPos, direction: direction, startKey: startKey, endKey: endKey }; } /** * Process all connectors in the scene and mark their virtual segments as occupied * @param {Object} sceneRoot - Root scene object * @param {Set<string>} pathOccupied - Set of occupied voxel keys * @param {string} excludeUUID1 - UUID of first object to exclude * @param {string} excludeUUID2 - UUID of second object to exclude */ processConnectors(sceneRoot, pathOccupied, excludeUUID1, excludeUUID2) { this._processConnectorsRecursive(sceneRoot, pathOccupied, excludeUUID1, excludeUUID2); } /** * Recursive helper for processConnectors * @private * @param {Object} object - Current object to process * @param {Set<string>} pathOccupied - Set of occupied voxel keys * @param {string} excludeUUID1 - UUID of first object to exclude * @param {string} excludeUUID2 - UUID of second object to exclude */ _processConnectorsRecursive(object, pathOccupied, excludeUUID1, excludeUUID2) { // Skip the excluded objects if (object.uuid === excludeUUID1 || object.uuid === excludeUUID2) { return; } const connectorPos = this.sceneManager.getWorldPosition(object); const segment = this.createVirtualSegment(object, connectorPos); if (segment) { this._markVirtualSegmentAsOccupied(segment, connectorPos, pathOccupied); } // Process children recursively if (object.children) { for (const child of object.children) { this._processConnectorsRecursive(child, pathOccupied, excludeUUID1, excludeUUID2); } } } /** * Mark voxels in a virtual segment as occupied * @private * @param {Object} segment - Virtual segment info * @param {Vector3} connectorPos - Position of the connector * @param {Set<string>} pathOccupied - Set of occupied voxel keys */ _markVirtualSegmentAsOccupied(segment, connectorPos, pathOccupied) { const direction = segment.direction; const distance = this.gridSystem.worldToVoxelDistance(this.MIN_SEGMENT_LENGTH + (this.gridSystem.safetyMargin * 2)); for (let i = 0; i <= distance; i++) { const x = Math.round(connectorPos.x / this.gridSystem.gridSize + direction.x * i); const y = Math.round(connectorPos.y / this.gridSystem.gridSize + direction.y * i); const z = Math.round(connectorPos.z / this.gridSystem.gridSize + direction.z * i); const key = `${x},${y},${z}`; pathOccupied.add(key); } } /** * Cluster connections by shared objects * @param {Array<Object>} connections - Array of connections between objects * @returns {Array<Object>} Array of clusters with enriched metadata */ clusterConnections(connections) { // Input validation if (!connections || !Array.isArray(connections)) { console.warn('[ConnectorManager] clusterConnections: Invalid connections input'); return []; } if (connections.length === 0) { return []; } const clusters = new Map(); // Map of object UUID to its cluster ID const clusterMap = new Map(); // Map of cluster ID to set of object UUIDs let nextClusterId = 0; // Build clusters using union-find approach connections.forEach((conn, index) => { // Validate connection structure if (!conn || typeof conn.from !== 'string' || typeof conn.to !== 'string') { console.warn(`[ConnectorManager] Invalid connection at index ${index}:`, conn); return; // Skip invalid connection } const { from, to } = conn; const fromClusterId = clusters.get(from); const toClusterId = clusters.get(to); // Case 1: Neither object has a cluster - create new cluster if (fromClusterId === undefined && toClusterId === undefined) { const clusterId = nextClusterId++; clusters.set(from, clusterId); clusters.set(to, clusterId); clusterMap.set(clusterId, new Set([from, to])); } // Case 2: Only 'from' has a cluster - add 'to' to it else if (fromClusterId !== undefined && toClusterId === undefined) { clusters.set(to, fromClusterId); clusterMap.get(fromClusterId).add(to); } // Case 3: Only 'to' has a cluster - add 'from' to it else if (fromClusterId === undefined && toClusterId !== undefined) { clusters.set(from, toClusterId); clusterMap.get(toClusterId).add(from); } // Case 4: Both have clusters - merge if different else if (fromClusterId !== toClusterId) { // Merge smaller cluster into larger one for efficiency const fromCluster = clusterMap.get(fromClusterId); const toCluster = clusterMap.get(toClusterId); const [sourceId, targetId, sourceSet, targetSet] = fromCluster.size >= toCluster.size ? [toClusterId, fromClusterId, toCluster, fromCluster] : [fromClusterId, toClusterId, fromCluster, toCluster]; // Update all objects in source cluster sourceSet.forEach(objId => { clusters.set(objId, targetId); targetSet.add(objId); }); clusterMap.delete(sourceId); } // Case 5: Both in same cluster - no action needed }); // Convert to array format const clustersArray = Array.from(clusterMap.entries()).map(([clusterId, objects]) => ({ clusterId, objects: Array.from(objects) })); console.log('[Pathfinder] Initial clusters:', clustersArray); // Filter clusters based on gateway rules const filteredClusters = this._filterGatewayClusters(clustersArray); console.log('[Pathfinder] Filtered clusters:', filteredClusters); // Enrich clusters with spatial data const enrichedClusters = this._enrichClustersWithSpatialData(filteredClusters); return enrichedClusters; } /** * Filter out clusters containing only computed gateways * @private * @param {Array<Object>} clusters - Array of cluster objects * @returns {Array<Object>} Filtered clusters */ _filterGatewayClusters(clusters) { return clusters.filter(cluster => { // Find all gateway UUIDs in this cluster const gatewayUUIDs = cluster.objects.filter(uuid => this._isGatewayUUID(uuid) ); // No gateways? Keep the cluster if (gatewayUUIDs.length === 0) { return true; } // Has gateways - check if any are manually declared const hasManualGateway = gatewayUUIDs.some(uuid => { const gatewayObj = this.sceneManager.findObjectByUUID(uuid); if (!gatewayObj) { console.warn(`[ConnectorManager] Gateway object not found: ${uuid}`); return false; // Treat missing gateways as computed (filter out) } // A gateway is "manual" (declared) if isDeclared is explicitly true // Computed gateways have isDeclared === false or undefined const isDeclared = gatewayObj.userData?.isDeclared; const isManual = isDeclared === true; if (isManual) { console.log(`[Pathfinder] Found manual (declared) gateway: ${uuid} - preserving cluster`); } return isManual; }); return hasManualGateway; }); } /** * Check if a UUID represents a gateway object * @private * @param {string} uuid - Object UUID to check * @returns {boolean} True if UUID is for a gateway */ _isGatewayUUID(uuid) { return typeof uuid === 'string' && uuid.includes('Gateway'); } /** * Enrich clusters with position and direction data * @private * @param {Array<Object>} clusters - Array of cluster objects * @returns {Array<Object>} Enriched clusters */ _enrichClustersWithSpatialData(clusters) { return clusters.map(cluster => { // Extract object points with error handling cluster.objectPoints = cluster.objects .map(uuid => { try { const object = this.sceneManager.findObjectByUUID(uuid); if (!object) { console.warn(`[ConnectorManager] Object not found for UUID: ${uuid}`); return null; } // Check if object has required spatial data const hasPosition = object.userData?.position; const hasBoundingBox = object.userData?.worldBoundingBox; if (!hasPosition && !hasBoundingBox) { console.warn(`[ConnectorManager] No spatial data for: ${uuid}`); return null; } const center = this.sceneManager.getWorldPosition(object); const direction = this._extractDirection(object); return { uuid, point: center, direction }; } catch (error) { console.error(`[ConnectorManager] Error processing object ${uuid}:`, error); return null; } }) .filter(point => point !== null); // Generate displaced points for pathfinding cluster.displacedPoints = cluster.objectPoints.map(objPoint => { const displacedPoint = objPoint.direction ? objPoint.point.clone().add( objPoint.direction.clone().multiplyScalar(0.5) ) : objPoint.point.clone(); return { uuid: objPoint.uuid, originalPoint: objPoint.point, displacedPoint, direction: objPoint.direction }; }); return cluster; }); } /** * Extract direction vector from object userData * @private * @param {Object} object - Scene object * @returns {Vector3|null} Direction vector or null */ _extractDirection(object) { if (!object.userData?.direction) { return null; } const dir = object.userData.direction; if (!Array.isArray(dir) || dir.length !== 3) { console.warn(`[ConnectorManager] Invalid direction format for ${object.uuid}`); return null; } return new Vector3(dir[0], dir[1], dir[2]); } }