@2112-lab/pathfinder
Version:
Pure JavaScript 3D pathfinding algorithm library for industrial plant pipe routing
431 lines (368 loc) • 17.7 kB
JavaScript
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]);
}
}