@2112-lab/pathfinder
Version:
A lightweight 3D pathfinding library for finding orthogonal paths between objects in 3D space
1,324 lines (1,163 loc) • 55.6 kB
JavaScript
/**
* A simple 3D vector class for internal use
* @class Vector3
*/
class Vector3 {
/**
* Create a new Vector3
* @param {number} [x=0] - The x component
* @param {number} [y=0] - The y component
* @param {number} [z=0] - The z component
*/
constructor(x = 0, y = 0, z = 0) {
this.x = x;
this.y = y;
this.z = z;
}
/**
* Create a copy of this vector
* @returns {Vector3} A new vector with the same components
*/
clone() {
return new Vector3(this.x, this.y, this.z);
}
/**
* Convert the vector to an array
* @returns {Array<number>} Array representation [x, y, z]
*/
toArray() {
return [this.x, this.y, this.z];
}
/**
* Normalize this vector (make it unit length)
* @returns {Vector3} This vector (for chaining)
*/
normalize() {
const length = Math.sqrt(this.x * this.x + this.y * this.y + this.z * this.z);
if (length > 0) {
this.x /= length;
this.y /= length;
this.z /= length;
}
return this;
}
/**
* Subtract another vector from this one
* @param {Vector3} v - Vector to subtract
* @returns {Vector3} This vector (for chaining)
*/
sub(v) {
this.x -= v.x;
this.y -= v.y;
this.z -= v.z;
return this;
}
/**
* Multiply this vector by a scalar value
* @param {number} scalar - The scalar value to multiply by
* @returns {Vector3} This vector (for chaining)
*/
multiplyScalar(scalar) {
this.x *= scalar;
this.y *= scalar;
this.z *= scalar;
return this;
}
/**
* Add another vector to this one
* @param {Vector3} v - Vector to add
* @returns {Vector3} This vector (for chaining)
*/
add(v) {
this.x += v.x;
this.y += v.y;
this.z += v.z;
return this;
}
}
/**
* Manages scene operations including object finding, position calculations, and hierarchy traversal
*/
class SceneManager {
/**
* Create a new SceneManager instance
* @param {Object} scene - The scene configuration object
*/
constructor(scene) {
this.scene = scene;
}
/**
* Get the root scene object
* @returns {Object} The root scene object
*/
getRoot() {
return this.scene.object;
}
/**
* Get world position from worldBoundingBox center
* @param {Object} object - Scene object
* @returns {Vector3} World position
*/
getWorldPosition(object) {
if (!object.userData?.worldBoundingBox) {
console.log(`[DEBUG] Object ${object.uuid} has no worldBoundingBox in userData`);
return new Vector3();
}
const { min, max } = object.userData.worldBoundingBox;
return new Vector3(
(min[0] + max[0]) / 2,
(min[1] + max[1]) / 2,
(min[2] + max[2]) / 2
);
}
/**
* Get bounding box from worldBoundingBox in userData
* @param {Object} object - Scene object
* @returns {{min: Vector3, max: Vector3}|null} Bounding box or null if no worldBoundingBox
*/
getBoundingBox(object) {
if (!object.userData?.worldBoundingBox) {
console.log(`[DEBUG] Object ${object.uuid} has no worldBoundingBox in userData`);
return null;
}
const { min, max } = object.userData.worldBoundingBox;
return {
min: new Vector3(min[0], min[1], min[2]),
max: new Vector3(max[0], max[1], max[2])
};
}
/**
* Find an object by UUID in the scene hierarchy
* @param {string} uuid - UUID to search for
* @returns {Object|null} Found object or null
*/
findObjectByUUID(uuid) {
return this._findObjectByUUIDRecursive(this.getRoot(), uuid);
}
/**
* Recursive helper for findObjectByUUID
* @private
* @param {Object} node - Current node to search
* @param {string} uuid - UUID to search for
* @returns {Object|null} Found object or null
*/
_findObjectByUUIDRecursive(node, uuid) {
if (node.uuid === uuid) {
return node;
}
if (node.children) {
for (const child of node.children) {
const found = this._findObjectByUUIDRecursive(child, uuid);
if (found) return found;
}
}
return null;
}
/**
* Find the parent object of a given object in the scene hierarchy
* @param {Object} target - Target object to find parent for
* @returns {Object|null} Parent object or null if not found or if parent is the scene
*/
findParentObject(target) {
if (target.userData && Array.isArray(target.userData.direction)) {
return { isDirectionParent: true };
}
return this._findParentObjectRecursive(this.getRoot(), target);
}
/**
* Recursive helper for findParentObject
* @private
* @param {Object} root - Root object to search from
* @param {Object} target - Target object to find parent for
* @returns {Object|null} Parent object or null
*/
_findParentObjectRecursive(root, target) {
if (root.children) {
for (const child of root.children) {
if (child.uuid === target.uuid) {
if (root.type === 'Scene') {
return null;
}
return root;
}
}
for (const child of root.children) {
const parent = this._findParentObjectRecursive(child, target);
if (parent) {
return parent;
}
}
}
return null;
}
/**
* Check if a voxel is occupied by any mesh object
* @param {string} voxelKey - Voxel key to check
* @param {number} gridSize - Size of each grid cell
* @returns {boolean} True if the voxel is occupied by a mesh object
*/
isVoxelOccupiedByMesh(voxelKey, gridSize) {
const [x, y, z] = voxelKey.split(',').map(Number);
const worldPos = new Vector3(
x * gridSize,
y * gridSize,
z * gridSize
);
return this._checkObjectOccupancy(this.getRoot(), worldPos);
}
/**
* Recursive helper for isVoxelOccupiedByMesh
* @private
* @param {Object} object - Object to check
* @param {Vector3} worldPos - World position to check
* @returns {boolean} True if the position is occupied
*/
_checkObjectOccupancy(object, worldPos) {
if (object.type === 'Mesh') {
const bbox = this.getBoundingBox(object);
if (bbox) {
if (worldPos.x >= bbox.min.x && worldPos.x <= bbox.max.x &&
worldPos.y >= bbox.min.y && worldPos.y <= bbox.max.y &&
worldPos.z >= bbox.min.z && worldPos.z <= bbox.max.z) {
return true;
}
}
}
if (object.children) {
for (const child of object.children) {
if (this._checkObjectOccupancy(child, worldPos)) return true;
}
}
return false;
}
}
/**
* Manages grid operations including voxel conversions, neighbor calculations, and distance metrics
*/
class GridSystem {
/**
* Create a new GridSystem instance
* @param {number} gridSize - Size of each grid cell in world units
* @param {number} safetyMargin - Safety margin around obstacles in world units
*/
constructor(gridSize = 0.5, safetyMargin = 0) {
this.gridSize = gridSize;
this.safetyMargin = safetyMargin;
}
/**
* Convert a position to a voxel key
* @param {Vector3|Object} pos - Position to convert
* @param {number} pos.x - X coordinate
* @param {number} pos.y - Y coordinate
* @param {number} pos.z - Z coordinate
* @returns {string} Voxel key in format "x,y,z"
*/
voxelKey(pos) {
return [
Math.round(pos.x / this.gridSize),
Math.round(pos.y / this.gridSize),
Math.round(pos.z / this.gridSize)
].join(',');
}
/**
* Convert a voxel key to a Vector3
* @param {string} key - Voxel key
* @returns {Vector3} Vector3 position
*/
voxelToVec3(key) {
const [x, y, z] = key.split(',').map(Number);
return new Vector3(x * this.gridSize, y * this.gridSize, z * this.gridSize);
}
/**
* Get neighboring voxels (axis-aligned)
* @param {string} vox - Voxel key
* @returns {string[]} Array of neighboring voxel keys
*/
getNeighbors(vox) {
const [x, y, z] = vox.split(',').map(Number);
return [
[x+1, y, z], [x-1, y, z],
[x, y+1, z], [x, y-1, z],
[x, y, z+1], [x, y, z-1]
].map(arr => arr.join(','));
}
/**
* Calculate Manhattan distance between two voxels
* @param {string} a - First voxel key
* @param {string} b - Second voxel key
* @returns {number} Manhattan distance
*/
manhattan(a, b) {
const [ax, ay, az] = a.split(',').map(Number);
const [bx, by, bz] = b.split(',').map(Number);
return Math.abs(ax-bx) + Math.abs(ay-by) + Math.abs(az-bz);
}
/**
* Calculate the number of voxels needed for a given world distance
* @param {number} worldDistance - Distance in world units
* @returns {number} Number of voxels
*/
worldToVoxelDistance(worldDistance) {
return Math.ceil(worldDistance / this.gridSize);
}
/**
* Calculate the world distance for a given number of voxels
* @param {number} voxelCount - Number of voxels
* @returns {number} Distance in world units
*/
voxelToWorldDistance(voxelCount) {
return voxelCount * this.gridSize;
}
/**
* Get the safety margin in voxel units
* @returns {number} Safety margin in voxels
*/
getSafetyMarginVoxels() {
return this.worldToVoxelDistance(this.safetyMargin);
}
/**
* Check if a voxel is within the safety margin of a bounding box
* @param {string} voxelKey - Voxel key to check
* @param {Object} bbox - Bounding box
* @param {Vector3} bbox.min - Minimum coordinates
* @param {Vector3} bbox.max - Maximum coordinates
* @returns {boolean} True if the voxel is within the safety margin
*/
isVoxelInSafetyMargin(voxelKey, bbox) {
const voxelPos = this.voxelToVec3(voxelKey);
const margin = this.safetyMargin;
return (
voxelPos.x >= bbox.min.x - margin && voxelPos.x <= bbox.max.x + margin &&
voxelPos.y >= bbox.min.y - margin && voxelPos.y <= bbox.max.y + margin &&
voxelPos.z >= bbox.min.z - margin && voxelPos.z <= bbox.max.z + margin
);
}
/**
* Get all voxel keys within a bounding box plus safety margin
* @param {Object} bbox - Bounding box
* @param {Vector3} bbox.min - Minimum coordinates
* @param {Vector3} bbox.max - Maximum coordinates
* @returns {string[]} Array of voxel keys
*/
getVoxelsInBoundingBox(bbox) {
const margin = this.getSafetyMarginVoxels();
const min = bbox.min;
const max = bbox.max;
const voxels = [];
for (let x = Math.round(min.x / this.gridSize) - margin; x <= Math.round(max.x / this.gridSize) + margin; x++) {
for (let y = Math.round(min.y / this.gridSize) - margin; y <= Math.round(max.y / this.gridSize) + margin; y++) {
for (let z = Math.round(min.z / this.gridSize) - margin; z <= Math.round(max.z / this.gridSize) + margin; z++) {
voxels.push(`${x},${y},${z}`);
}
}
}
return voxels;
}
}
/**
* Manages connector-related operations for the pathfinding system
* @class ConnectorManager
*/
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) {
// Process current object if it's a mesh
if (object.type === 'Mesh') {
// 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
*/
clusterConnections(connections) {
const clusters = new Map(); // Map of object UUID to its cluster
const clusterMap = new Map(); // Map of cluster ID to set of object UUIDs
let nextClusterId = 0;
// First pass: create initial clusters for each object
connections.forEach(conn => {
const { from, to } = conn;
// If neither object is in a cluster, create new cluster
if (!clusters.has(from) && !clusters.has(to)) {
const clusterId = nextClusterId++;
clusters.set(from, clusterId);
clusters.set(to, clusterId);
clusterMap.set(clusterId, new Set([from, to]));
}
// If only 'from' is in a cluster, add 'to' to that cluster
else if (clusters.has(from) && !clusters.has(to)) {
const clusterId = clusters.get(from);
clusters.set(to, clusterId);
clusterMap.get(clusterId).add(to);
}
// If only 'to' is in a cluster, add 'from' to that cluster
else if (!clusters.has(from) && clusters.has(to)) {
const clusterId = clusters.get(to);
clusters.set(from, clusterId);
clusterMap.get(clusterId).add(from);
}
// If both are in different clusters, merge the clusters
else if (clusters.has(from) && clusters.has(to)) {
const fromCluster = clusters.get(from);
const toCluster = clusters.get(to);
if (fromCluster !== toCluster) {
// Merge toCluster into fromCluster
const toClusterObjects = clusterMap.get(toCluster);
toClusterObjects.forEach(objId => {
clusters.set(objId, fromCluster);
clusterMap.get(fromCluster).add(objId);
});
clusterMap.delete(toCluster);
}
}
});
// Convert clusters to array format
const clustersArray = Array.from(clusterMap.entries()).map(([clusterId, objects]) => ({
clusterId,
objects: Array.from(objects)
}));
// Filter out clusters containing objects with "GATEWAY" in their UUID
const filteredClusters = clustersArray.filter(cluster =>
!cluster.objects.some(uuid => uuid.includes('GATEWAY'))
);
// Enrich clusters with point and direction information
filteredClusters.forEach(cluster => {
cluster.objectPoints = cluster.objects.map(uuid => {
const object = this.sceneManager.findObjectByUUID(uuid);
if (object && object.userData.worldBoundingBox) {
const center = this.sceneManager.getWorldPosition(object);
return {
uuid,
point: center,
direction: object.userData.direction ?
new Vector3(...object.userData.direction) : null
};
}
return null;
}).filter(point => point !== null);
// Add displaced points to each cluster
cluster.displacedPoints = cluster.objectPoints.map(objPoint => {
const displacedPoint = objPoint.direction
? objPoint.point.clone().add(objPoint.direction.multiplyScalar(0.5))
: objPoint.point.clone();
return {
uuid: objPoint.uuid,
originalPoint: objPoint.point,
displacedPoint: displacedPoint,
direction: objPoint.direction
};
});
});
return filteredClusters;
}
}
/**
* Manages path-related operations for the pathfinding system
* @class PathManager
*/
class PathManager {
/**
* Create a new PathManager instance
* @param {SceneManager} sceneManager - Instance of SceneManager for scene operations
* @param {GridSystem} gridSystem - Instance of GridSystem for grid operations
* @param {ConnectorManager} connectorManager - Instance of ConnectorManager for connector operations
* @param {number} minSegmentLength - Minimum length for straight pipe segments in world units
* @param {number} astarTimeout - Timeout for A* pathfinding in milliseconds
*/
constructor(sceneManager, gridSystem, connectorManager, minSegmentLength, astarTimeout) {
this.sceneManager = sceneManager;
this.gridSystem = gridSystem;
this.connectorManager = connectorManager;
this.MIN_SEGMENT_LENGTH = minSegmentLength;
this.ASTAR_TIMEOUT = astarTimeout;
}
/**
* A* pathfinding algorithm
* @private
* @param {string} start - Start voxel key
* @param {string} goal - Goal voxel key
* @param {Set<string>} occupied - Set of occupied voxel keys
* @returns {string[]|null} Path as array of voxel keys or null if no path found
*/
astar(start, goal, occupied) {
const open = new Set([start]);
const cameFrom = {};
const gScore = {[start]: 0};
const fScore = {[start]: this.gridSystem.manhattan(start, goal)};
// Add timeout to prevent infinite loops
const startTime = Date.now();
while (open.size > 0) {
// Check for timeout
if (Date.now() - startTime > this.ASTAR_TIMEOUT) {
console.log('[Warning] Pathfinding timed out');
return null;
}
// Get node in open with lowest fScore
let current = null;
let minF = Infinity;
for (const node of open) {
if (fScore[node] < minF) {
minF = fScore[node];
current = node;
}
}
if (current === goal) {
// Reconstruct path
const path = [current];
while (cameFrom[current]) {
current = cameFrom[current];
path.unshift(current);
}
return path;
}
open.delete(current);
for (const neighbor of this.gridSystem.getNeighbors(current)) {
if (occupied.has(neighbor)) continue;
const tentativeG = gScore[current] + 1;
if (tentativeG < (gScore[neighbor] ?? Infinity)) {
cameFrom[neighbor] = current;
gScore[neighbor] = tentativeG;
fScore[neighbor] = tentativeG + this.gridSystem.manhattan(neighbor, goal);
open.add(neighbor);
}
}
}
return null;
}
/**
* Find a path respecting virtual segments
* @private
* @param {string} startKey - Start voxel key
* @param {string} endKey - End voxel key
* @param {Set<string>} occupied - Set of occupied voxel keys
* @param {Object} startSegment - Virtual segment for start connector (optional)
* @param {Object} endSegment - Virtual segment for end connector (optional)
* @returns {string[]|null} Path as array of voxel keys or null if no path found
*/
findPathWithVirtualSegments(startKey, endKey, occupied, startSegment, endSegment) {
// If we have virtual segments, we need to ensure the path starts and ends with them
let actualStartKey = startKey;
let actualEndKey = endKey;
// Create a copy of occupied set to avoid modifying the original
const pathOccupied = new Set(occupied);
// If we have a start segment, use its end as the actual start for pathfinding
// and mark the segment as occupied
if (startSegment) {
actualStartKey = startSegment.endKey;
// Mark all voxels in the start segment as occupied
const startPos = startSegment.startPos;
const direction = startSegment.direction;
const distance = this.gridSystem.worldToVoxelDistance(this.MIN_SEGMENT_LENGTH);
// Note: we go up to distance-1 to exclude the connecting end point
for (let i = 0; i < distance; i++) {
const x = Math.round(startPos.x / this.gridSystem.gridSize + direction.x * i);
const y = Math.round(startPos.y / this.gridSystem.gridSize + direction.y * i);
const z = Math.round(startPos.z / this.gridSystem.gridSize + direction.z * i);
const key = `${x},${y},${z}`;
pathOccupied.add(key);
}
}
// If we have an end segment, use its end as the actual end for pathfinding
// and mark the segment as occupied
if (endSegment) {
actualEndKey = endSegment.endKey;
// Mark all voxels in the end segment as occupied
const endPos = endSegment.startPos;
const direction = endSegment.direction;
const distance = this.gridSystem.worldToVoxelDistance(this.MIN_SEGMENT_LENGTH);
// Note: we go up to distance-1 to exclude the connecting end point
for (let i = 0; i < distance; i++) {
const x = Math.round(endPos.x / this.gridSystem.gridSize + direction.x * i);
const y = Math.round(endPos.y / this.gridSystem.gridSize + direction.y * i);
const z = Math.round(endPos.z / this.gridSystem.gridSize + direction.z * i);
const key = `${x},${y},${z}`;
pathOccupied.add(key);
}
}
// Find a path between the actual start and end
const middlePath = this.astar(actualStartKey, actualEndKey, pathOccupied);
if (!middlePath) {
return null;
}
// Construct the full path
let fullPath = [];
// Add the start segment if we have one
if (startSegment) {
fullPath.push(startSegment.startKey);
}
// Add the middle path
fullPath = fullPath.concat(middlePath);
// Add the end segment if we have one
if (endSegment) {
fullPath.push(endSegment.startKey);
}
return fullPath;
}
/**
* Mark all mesh objects' voxels as occupied in the scene
* @private
* @param {Object} sceneRoot - Root scene object
* @returns {Object} Object containing occupied sets and maps
*/
markAllMeshesAsOccupiedVoxels(sceneRoot) {
const occupied = new Set();
const occupiedByUUID = new Map();
const processObject = (object) => {
// Skip non-mesh objects
if (object.type !== 'Mesh') return;
// Get object's bounding box
const bbox = this.sceneManager.getBoundingBox(object);
if (!bbox) return;
// Get all voxels in the bounding box plus safety margin
const voxels = this.gridSystem.getVoxelsInBoundingBox(bbox);
// Create a set for this object's occupied voxels
const objectOccupied = new Set(voxels);
// Add all voxels to the global occupied set
for (const key of voxels) {
occupied.add(key);
}
// Store the object's occupied voxels in the map
occupiedByUUID.set(object.uuid, objectOccupied);
// Process children recursively
if (object.children) {
for (const child of object.children) {
processObject(child);
}
}
};
// Process all objects in the scene's children array
if (sceneRoot.children) {
for (const object of sceneRoot.children) {
processObject(object);
}
}
return { occupied, occupiedByUUID };
}
/**
* Process a single connection and find its path
* @private
* @param {Object} connection - Connection object
* @param {Set<string>} occupied - Set of occupied voxel keys
* @param {Map<string, Set<string>>} occupiedByUUID - Map of occupied voxels by UUID
* @returns {Object|null} Path result object or null if connection is invalid
*/
processConnection(connection, occupied, occupiedByUUID) {
const fromObject = this.sceneManager.findObjectByUUID(connection.from);
const toObject = this.sceneManager.findObjectByUUID(connection.to);
if (!fromObject || !toObject) {
console.log(`[ERROR] Could not find objects for connection ${connection.from} - ${connection.to}`);
return null;
}
// Verify that both objects are meshes
if (fromObject.type !== 'Mesh' || toObject.type !== 'Mesh') {
console.log(`[ERROR] Connection endpoints must be meshes: ${fromObject.type} - ${toObject.type}`);
return null;
}
// Store the parent for each object to access later
fromObject.parent = this.sceneManager.findParentObject(fromObject);
toObject.parent = this.sceneManager.findParentObject(toObject);
// Get world positions for both objects
const worldPos1 = this.sceneManager.getWorldPosition(fromObject);
const worldPos2 = this.sceneManager.getWorldPosition(toObject);
// Create virtual segments for both connectors
const startSegment = this.connectorManager.createVirtualSegment(fromObject, worldPos1);
const endSegment = this.connectorManager.createVirtualSegment(toObject, worldPos2);
// Create a copy of occupied set for this path
const pathOccupied = new Set(occupied);
// Debug copy 1: Initial state after copying from global occupied
const debugOccupiedInitial = Array.from(pathOccupied)
.map(key => this.gridSystem.voxelToVec3(key));
// Get bounding boxes for the two objects
const fromBbox = this.sceneManager.getBoundingBox(fromObject);
const toBbox = this.sceneManager.getBoundingBox(toObject);
if (!fromBbox || !toBbox) return null;
// Clear voxels in bounding boxes only for objects that do NOT have virtual segments
if (!startSegment) {
const voxels = this.gridSystem.getVoxelsInBoundingBox(fromBbox);
for (const key of voxels) {
pathOccupied.delete(key);
}
}
if (!endSegment) {
const voxels = this.gridSystem.getVoxelsInBoundingBox(toBbox);
for (const key of voxels) {
pathOccupied.delete(key);
}
}
// Also clear the voxels for the virtual segments if they exist
if (startSegment) {
const direction = startSegment.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(worldPos1.x / this.gridSystem.gridSize + direction.x * i);
const y = Math.round(worldPos1.y / this.gridSystem.gridSize + direction.y * i);
const z = Math.round(worldPos1.z / this.gridSystem.gridSize + direction.z * i);
pathOccupied.delete(`${x},${y},${z}`);
}
}
if (endSegment) {
const direction = endSegment.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(worldPos2.x / this.gridSystem.gridSize + direction.x * i);
const y = Math.round(worldPos2.y / this.gridSystem.gridSize + direction.y * i);
const z = Math.round(worldPos2.z / this.gridSystem.gridSize + direction.z * i);
pathOccupied.delete(`${x},${y},${z}`);
}
}
// Process all other connectors in the scene
this.connectorManager.processConnectors(this.sceneManager.getRoot(), pathOccupied, fromObject.uuid, toObject.uuid);
// Debug copy 3: After clearing virtual segments and adding back previous paths
const debugOccupiedAfterSegments = Array.from(pathOccupied)
.map(key => this.gridSystem.voxelToVec3(key));
// Get start and end voxel keys
const startKey = this.gridSystem.voxelKey(worldPos1);
const endKey = this.gridSystem.voxelKey(worldPos2);
// Find a path with virtual segments
const pathKeys = this.findPathWithVirtualSegments(
startKey,
endKey,
pathOccupied,
startSegment,
endSegment
);
const occupiedVoxels = Array.from(pathOccupied)
.map(key => this.gridSystem.voxelToVec3(key));
if (pathKeys) {
const path = pathKeys.map(key => this.gridSystem.voxelToVec3(key));
// Create a set for this path's occupied voxels
const pathOccupied = new Set();
// Mark path points as occupied for next routes
for (const p of path) {
const key = this.gridSystem.voxelKey(p);
occupied.add(key);
pathOccupied.add(key);
}
// Store the path's occupied voxels in the map with a special key
const pathKey = `path_${connection.from}_${connection.to}`;
occupiedByUUID.set(pathKey, pathOccupied);
return {
from: connection.from,
to: connection.to,
path: path,
occupied: occupiedVoxels,
debug: {
initial: debugOccupiedInitial,
afterSegments: debugOccupiedAfterSegments
},
occupiedByUUID: occupiedByUUID // Include the map in the result
};
} else {
console.log(`[ERROR] No orthogonal path found for ${connection.from}-${connection.to}`);
return {
from: connection.from,
to: connection.to,
path: null,
start: worldPos1,
end: worldPos2,
occupied: occupiedVoxels,
debug: {
initial: debugOccupiedInitial,
afterSegments: debugOccupiedAfterSegments
},
occupiedByUUID: occupiedByUUID // Include the map in the result
};
}
}
/**
* Find paths for all connections
* @param {Array<Object>} connections - Array of connections to process
* @returns {Array<Object>} Array of path results
*/
findPaths(connections) {
console.log('[DEBUG] Starting findPaths()');
// Validate scene structure
if (!this.sceneManager.getRoot()) {
console.log('[ERROR] Invalid scene structure: missing scene root');
return [];
}
// Process all objects in the scene
const { occupied, occupiedByUUID } = this.markAllMeshesAsOccupiedVoxels(this.sceneManager.getRoot());
const paths = [];
// For each connection in the config, find a path
for (const connection of connections) {
const result = this.processConnection(connection, occupied, occupiedByUUID);
if (result) {
paths.push(result);
}
}
return paths;
}
}
/**
* Manages Steiner tree calculations and Minimum Spanning Tree (MST) operations
* @class SteinerTreeManager
*/
class SteinerTreeManager {
constructor() {}
/**
* Calculate distance between two points
* @param {Vector3} p1 - First point
* @param {Vector3} p2 - Second point
* @returns {number} Distance between points
*/
distance(p1, p2) {
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
const dz = p2.z - p1.z;
return Math.sqrt(dx * dx + dy * dy + dz * dz);
}
/**
* Find Minimum Spanning Tree using Prim's algorithm
* @param {Array<Vector3>} points - Array of points to connect
* @returns {Array<Array<number>>} Array of edges in the MST
*/
findMST(points) {
const n = points.length;
const mst = [];
const visited = new Set();
// Start with the first point
visited.add(0);
while (visited.size < n) {
let minDist = Infinity;
let minEdge = null;
// Find the minimum edge from visited to unvisited
for (const i of visited) {
for (let j = 0; j < n; j++) {
if (!visited.has(j)) {
const dist = this.distance(points[i], points[j]);
if (dist < minDist) {
minDist = dist;
minEdge = [i, j];
}
}
}
}
if (minEdge) {
mst.push(minEdge);
visited.add(minEdge[1]);
} else {
break; // Break if we can't find any more edges
}
}
return mst;
}
/**
* Find Steiner point using Fermat-Torricelli point approximation
* @param {Array<Vector3>} points - Array of points to connect
* @returns {Vector3|null} Steiner point or null if not possible
*/
findSteinerPoint(points) {
if (points.length <= 2) {
return null;
}
// Find MST first
const mst = this.findMST(points);
if (mst.length === 0) {
return null;
}
// Find the shortest edge in MST
let shortestEdge = null;
let minEdgeLength = Infinity;
mst.forEach(([i, j]) => {
const edgeLength = this.distance(points[i], points[j]);
if (edgeLength < minEdgeLength) {
minEdgeLength = edgeLength;
shortestEdge = [points[i], points[j]];
}
});
if (!shortestEdge) {
return null;
}
// Place Steiner point at the midpoint of the shortest edge
const steinerPoint = new Vector3(
(shortestEdge[0].x + shortestEdge[1].x) * 0.5,
(shortestEdge[0].y + shortestEdge[1].y) * 0.5,
(shortestEdge[0].z + shortestEdge[1].z) * 0.5
);
// Snap to 0.5 grid
const snapToGrid = (value) => Math.round(value * 2) / 2;
steinerPoint.x = snapToGrid(steinerPoint.x);
steinerPoint.y = snapToGrid(steinerPoint.y);
steinerPoint.z = snapToGrid(steinerPoint.z);
return steinerPoint;
}
}
/**
* A 3D pathfinding system that finds orthogonal paths between objects
* @class Pathfinder
*/
class Pathfinder {
/**
* Create a new Pathfinding instance
* @param {Object} [config] - Optional configuration object
* @param {Object} [config.grid] - Grid system configuration
* @param {number} [config.grid.size=0.5] - Size of each grid cell in world units
* @param {number} [config.grid.safetyMargin=0] - Safety margin around obstacles in world units
* @param {number} [config.grid.minSegmentLength=0.5] - Minimum length for straight pipe segments in world units
* @param {number} [config.grid.timeout=1000] - Timeout for A* pathfinding in milliseconds
*/
constructor(config = {}) {
this.config = config;
// Grid system settings with defaults
const gridConfig = config.grid || {};
this.gridSystem = new GridSystem(
gridConfig.size ?? 0.5,
gridConfig.safetyMargin ?? 0
);
this.MIN_SEGMENT_LENGTH = gridConfig.minSegmentLength ?? 0.5;
this.ASTAR_TIMEOUT = gridConfig.timeout ?? 1000;
// Initialize connector manager
this.connectorManager = new ConnectorManager(
{ grid: gridConfig }, // Only pass grid config
this.MIN_SEGMENT_LENGTH,
null, // sceneManager will be set in findPaths
this.gridSystem
);
// Initialize path manager
this.pathManager = new PathManager(
null, // sceneManager will be set in findPaths
this.gridSystem,
this.connectorManager,
this.MIN_SEGMENT_LENGTH,
this.ASTAR_TIMEOUT
);
// Initialize Steiner tree manager
this.steinerTreeManager = new SteinerTreeManager();
}
/**
* Find paths for all connections
* @param {Object} scene - The scene configuration
* @param {Object} scene.object - The scene object containing children
* @param {string} scene.object.uuid - Unique identifier for the scene object
* @param {string} scene.object.type - Type of the scene object (typically "Scene")
* @param {Array<Object>} scene.object.children - Array of scene objects
* @param {string} scene.object.children[].uuid - Unique identifier for the child object
* @param {string} scene.object.children[].type - Type of the child object (must be "Mesh")
* @param {Object} scene.object.children[].userData - User data for the child object
* @param {Object} scene.object.children[].userData.worldBoundingBox - Bounding box data
* @param {Array<number>} scene.object.children[].userData.worldBoundingBox.min - Minimum coordinates [x,y,z]
* @param {Array<number>} scene.object.children[].userData.worldBoundingBox.max - Maximum coordinates [x,y,z]
* @param {Array<number>} [scene.object.children[].userData.direction] - Optional direction vector [x,y,z]
* @param {Array<Object>} connections - Array of connections between objects
* @param {string} connections[].from - UUID of the source object
* @param {string} connections[].to - UUID of the target object
* @returns {Object} Result object containing paths, rewired connections, and gateways
* @property {Array<Object>} paths - Array of path results
* @property {string} paths[].from - Source object UUID
* @property {string} paths[].to - Target object UUID
* @property {Array<Vector3>|null} paths[].path - Array of path points or null if no path found
* @property {Array<Vector3>} [paths[].occupied] - Array of occupied points
* @property {Vector3} [paths[].start] - Start position (if no path found)
* @property {Vector3} [paths[].end] - End position (if no path found)
* @property {Array<Object>} rewiredConnections - Array of connections after gateway optimization
* @property {string} rewiredConnections[].from - Source object UUID
* @property {string} rewiredConnections[].to - Target object UUID
* @property {Array<Object>} gateways - Array of gateway information
* @property {string} gateways[].clusterId - ID of the cluster this gateway belongs to
* @property {number} gateways[].id - Unique identifier for the gateway
* @property {Vector3} gateways[].position - Position of the gateway in 3D space
*/
findPaths(scene, connections) {
// Create scene manager with the provided scene
const sceneManager = new SceneManager(scene);
// Update scene manager references
this.connectorManager.sceneManager = sceneManager;
this.pathManager.sceneManager = sceneManager;
// Cluster the connections
const clusters = this.connectorManager.clusterConnections(connections);
// Filter clusters to only include those with more than 2 objects
const filteredClusters = clusters.filter(cluster => cluster.objects.length > 2);
// Calculate Steiner points and MST for each cluster
filteredClusters.forEach((cluster, index) => {
const points = cluster.displacedPoints.map(p => p.displacedPoint);
cluster.mst = this.steinerTreeManager.findMST(points);
cluster.steinerPoint = this.steinerTreeManager.findSteinerPoint(points);
});
let gatewayCounter = 1;
const gatewayMap = new Map();
filteredClusters.forEach(cluster => {
if (cluster.steinerPoint) {
const gatewayId = gatewayCounter++;
gatewayMap.set(cluster.clusterId, gatewayId);
}
});
// Create virtual gateway objects in the scene
filteredClusters.forEach(cluster => {
if (cluster.steinerPoint) {
const gatewayId = gatewayMap.get(cluster.clusterId);
const gatewayObject = {
uuid: `GATEWAY-${gatewayId}`,
type: 'Mesh',
userData: {
worldBoundingBox: {
min: [
cluster.steinerP