@2112-lab/central-plant 0.1.7 → 0.1.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -601,12 +601,19 @@ class ConnectorManager {
601
601
  * @returns {Array<Object>} Array of clusters
602
602
  */
603
603
  clusterConnections(connections) {
604
+ // Filter out connections where either from or to UUID starts with "gateway" (case insensitive)
605
+ const filteredConnections = connections.filter(conn => {
606
+ const fromStartsWithGateway = conn.from.toLowerCase().startsWith('gateway');
607
+ const toStartsWithGateway = conn.to.toLowerCase().startsWith('gateway');
608
+ return !fromStartsWithGateway && !toStartsWithGateway;
609
+ });
610
+
604
611
  const clusters = new Map(); // Map of object UUID to its cluster
605
612
  const clusterMap = new Map(); // Map of cluster ID to set of object UUIDs
606
613
  let nextClusterId = 0;
607
614
 
608
615
  // First pass: create initial clusters for each object
609
- connections.forEach(conn => {
616
+ filteredConnections.forEach(conn => {
610
617
  const { from, to } = conn;
611
618
 
612
619
  // If neither object is in a cluster, create new cluster
@@ -651,13 +658,8 @@ class ConnectorManager {
651
658
  objects: Array.from(objects)
652
659
  }));
653
660
 
654
- // Filter out clusters containing objects with "GATEWAY" in their UUID
655
- const filteredClusters = clustersArray.filter(cluster =>
656
- !cluster.objects.some(uuid => uuid.includes('GATEWAY'))
657
- );
658
-
659
661
  // Enrich clusters with point and direction information
660
- filteredClusters.forEach(cluster => {
662
+ clustersArray.forEach(cluster => {
661
663
  cluster.objectPoints = cluster.objects.map(uuid => {
662
664
  const object = this.sceneManager.findObjectByUUID(uuid);
663
665
  if (object && object.userData.worldBoundingBox) {
@@ -689,7 +691,7 @@ class ConnectorManager {
689
691
  });
690
692
 
691
693
 
692
- return filteredClusters;
694
+ return clustersArray;
693
695
  }
694
696
  }
695
697
 
@@ -1090,11 +1092,14 @@ class PathManager {
1090
1092
  }
1091
1093
 
1092
1094
  /**
1093
- * Manages Steiner tree calculations and Minimum Spanning Tree (MST) operations
1094
- * @class SteinerTreeManager
1095
+ * Manages tree path calculations and Minimum Spanning Tree (MST) operations
1096
+ * @class TreePathManager
1095
1097
  */
1096
- class SteinerTreeManager {
1097
- constructor() {}
1098
+ class TreePathManager {
1099
+ constructor(gridSystem = null, sceneManager = null) {
1100
+ this.gridSystem = gridSystem;
1101
+ this.sceneManager = sceneManager;
1102
+ }
1098
1103
 
1099
1104
  /**
1100
1105
  * Calculate distance between two points
@@ -1149,13 +1154,62 @@ class SteinerTreeManager {
1149
1154
 
1150
1155
  return mst;
1151
1156
  }
1157
+
1158
+ /**
1159
+ * Find the nearest free voxel to a given position
1160
+ * @param {Vector3} position - Position to find free voxel near
1161
+ * @param {number} maxSearchRadius - Maximum search radius in voxel units
1162
+ * @returns {Vector3|null} Nearest free position or null if none found
1163
+ */
1164
+ findNearestFreeVoxel(position, maxSearchRadius = 5) {
1165
+ if (!this.gridSystem || !this.sceneManager) {
1166
+ return position; // Return original position if systems not available
1167
+ }
1168
+
1169
+ const originalVoxelKey = this.gridSystem.voxelKey(position);
1170
+
1171
+ // Check if original position is free
1172
+ if (!this.sceneManager.isVoxelOccupiedByMesh(originalVoxelKey, this.gridSystem.gridSize)) {
1173
+ return position;
1174
+ }
1175
+
1176
+ // Search in expanding rings around the position
1177
+ for (let radius = 1; radius <= maxSearchRadius; radius++) {
1178
+ const [x, y, z] = originalVoxelKey.split(',').map(Number);
1179
+
1180
+ // Generate all voxels at this radius
1181
+ const candidates = [];
1182
+
1183
+ // Generate all combinations of offsets at this radius
1184
+ for (let dx = -radius; dx <= radius; dx++) {
1185
+ for (let dy = -radius; dy <= radius; dy++) {
1186
+ for (let dz = -radius; dz <= radius; dz++) {
1187
+ // Only consider voxels exactly at this radius
1188
+ if (Math.abs(dx) + Math.abs(dy) + Math.abs(dz) === radius) {
1189
+ candidates.push([x + dx, y + dy, z + dz]);
1190
+ }
1191
+ }
1192
+ }
1193
+ }
1194
+
1195
+ // Check each candidate
1196
+ for (const [cx, cy, cz] of candidates) {
1197
+ const candidateKey = `${cx},${cy},${cz}`;
1198
+ if (!this.sceneManager.isVoxelOccupiedByMesh(candidateKey, this.gridSystem.gridSize)) {
1199
+ return this.gridSystem.voxelToVec3(candidateKey);
1200
+ }
1201
+ }
1202
+ }
1203
+
1204
+ return null;
1205
+ }
1152
1206
 
1153
1207
  /**
1154
- * Find Steiner point using Fermat-Torricelli point approximation
1208
+ * Find joint point
1155
1209
  * @param {Array<Vector3>} points - Array of points to connect
1156
- * @returns {Vector3|null} Steiner point or null if not possible
1210
+ * @returns {Vector3|null} Joint point or null if not possible
1157
1211
  */
1158
- findSteinerPoint(points) {
1212
+ findJointPoint(points) {
1159
1213
  if (points.length <= 2) {
1160
1214
  return null;
1161
1215
  }
@@ -1167,36 +1221,61 @@ class SteinerTreeManager {
1167
1221
  return null;
1168
1222
  }
1169
1223
 
1170
- // Find the shortest edge in MST
1171
- let shortestEdge = null;
1172
- let minEdgeLength = Infinity;
1224
+ // Find the best edge in MST
1225
+ let bestEdge = null;
1226
+ let maxScore = -1;
1173
1227
 
1174
- mst.forEach(([i, j]) => {
1175
- const edgeLength = this.distance(points[i], points[j]);
1176
- if (edgeLength < minEdgeLength) {
1177
- minEdgeLength = edgeLength;
1178
- shortestEdge = [points[i], points[j]];
1228
+ mst.forEach(([i, j], edgeIndex) => {
1229
+ const pointA = points[i];
1230
+ const pointB = points[j];
1231
+
1232
+ // Calculate edge components
1233
+ const dx = Math.abs(pointB.x - pointA.x);
1234
+ const dy = Math.abs(pointB.y - pointA.y);
1235
+ const dz = Math.abs(pointB.z - pointA.z);
1236
+
1237
+ // Calculate total length
1238
+ const totalLength = dx + dy + dz;
1239
+
1240
+ if (totalLength > 0) {
1241
+ // Calculate score
1242
+ // Higher score = more orthogonal (closer to being axis-aligned) relative to the total length of the edge
1243
+ const maxComponent = Math.max(dx, dy, dz);
1244
+ const score = (maxComponent / totalLength) / totalLength;
1245
+
1246
+ // Check if this edge has a better orthogonality score
1247
+ if (score > maxScore) {
1248
+ maxScore = score;
1249
+ bestEdge = [pointA, pointB];
1250
+ }
1179
1251
  }
1180
1252
  });
1181
1253
 
1182
- if (!shortestEdge) {
1254
+ if (!bestEdge) {
1183
1255
  return null;
1184
1256
  }
1185
1257
 
1186
- // Place Steiner point at the midpoint of the shortest edge
1187
- const steinerPoint = new Vector3(
1188
- (shortestEdge[0].x + shortestEdge[1].x) * 0.5,
1189
- (shortestEdge[0].y + shortestEdge[1].y) * 0.5,
1190
- (shortestEdge[0].z + shortestEdge[1].z) * 0.5
1258
+ // Place joint point at the midpoint of the most orthogonal edge
1259
+ const jointPoint = new Vector3(
1260
+ (bestEdge[0].x + bestEdge[1].x) * 0.5,
1261
+ (bestEdge[0].y + bestEdge[1].y) * 0.5,
1262
+ (bestEdge[0].z + bestEdge[1].z) * 0.5
1191
1263
  );
1192
1264
 
1193
1265
  // Snap to 0.5 grid
1194
1266
  const snapToGrid = (value) => Math.round(value * 2) / 2;
1195
- steinerPoint.x = snapToGrid(steinerPoint.x);
1196
- steinerPoint.y = snapToGrid(steinerPoint.y);
1197
- steinerPoint.z = snapToGrid(steinerPoint.z);
1267
+ jointPoint.x = snapToGrid(jointPoint.x);
1268
+ jointPoint.y = snapToGrid(jointPoint.y);
1269
+ jointPoint.z = snapToGrid(jointPoint.z);
1198
1270
 
1199
- return steinerPoint;
1271
+ // Check if the joint point is in an occupied voxel and move it if necessary
1272
+ const freeJointPoint = this.findNearestFreeVoxel(jointPoint);
1273
+
1274
+ if (freeJointPoint) {
1275
+ return freeJointPoint;
1276
+ } else {
1277
+ return jointPoint; // Return original if no free position found
1278
+ }
1200
1279
  }
1201
1280
  }
1202
1281
 
@@ -1243,8 +1322,8 @@ class Pathfinder {
1243
1322
  this.ASTAR_TIMEOUT
1244
1323
  );
1245
1324
 
1246
- // Initialize Steiner tree manager
1247
- this.steinerTreeManager = new SteinerTreeManager();
1325
+ // Initialize tree path manager
1326
+ this.treePathManager = new TreePathManager(this.gridSystem, null);
1248
1327
  }
1249
1328
 
1250
1329
  /**
@@ -1279,6 +1358,13 @@ class Pathfinder {
1279
1358
  * @property {string} gateways[].clusterId - ID of the cluster this gateway belongs to
1280
1359
  * @property {number} gateways[].id - Unique identifier for the gateway
1281
1360
  * @property {Vector3} gateways[].position - Position of the gateway in 3D space
1361
+ * @property {Object} gateways[].connections - Connection mapping information
1362
+ * @property {Array<Object>} gateways[].connections.removed - Array of original connections that were removed
1363
+ * @property {string} gateways[].connections.removed[].from - Source object UUID
1364
+ * @property {string} gateways[].connections.removed[].to - Target object UUID
1365
+ * @property {Array<Object>} gateways[].connections.added - Array of new connections that were added
1366
+ * @property {string} gateways[].connections.added[].from - Source object UUID
1367
+ * @property {string} gateways[].connections.added[].to - Target object UUID
1282
1368
  */
1283
1369
  findPaths(scene, connections) {
1284
1370
  // Create scene manager with the provided scene
@@ -1287,6 +1373,7 @@ class Pathfinder {
1287
1373
  // Update scene manager references
1288
1374
  this.connectorManager.sceneManager = sceneManager;
1289
1375
  this.pathManager.sceneManager = sceneManager;
1376
+ this.treePathManager.sceneManager = sceneManager;
1290
1377
 
1291
1378
  // Cluster the connections
1292
1379
  const clusters = this.connectorManager.clusterConnections(connections);
@@ -1294,41 +1381,41 @@ class Pathfinder {
1294
1381
  // Filter clusters to only include those with more than 2 objects
1295
1382
  const filteredClusters = clusters.filter(cluster => cluster.objects.length > 2);
1296
1383
 
1297
- // Calculate Steiner points and MST for each cluster
1384
+ // Calculate joint points for each cluster
1298
1385
  filteredClusters.forEach((cluster, index) => {
1299
1386
  const points = cluster.displacedPoints.map(p => p.displacedPoint);
1300
- cluster.mst = this.steinerTreeManager.findMST(points);
1301
- cluster.steinerPoint = this.steinerTreeManager.findSteinerPoint(points);
1387
+ cluster.jointPoint = this.treePathManager.findJointPoint(points);
1302
1388
  });
1303
1389
 
1304
- let gatewayCounter = 1;
1305
1390
  const gatewayMap = new Map();
1306
1391
 
1307
1392
  filteredClusters.forEach(cluster => {
1308
- if (cluster.steinerPoint) {
1309
- const gatewayId = gatewayCounter++;
1393
+ if (cluster.jointPoint) {
1394
+ // Generate a shorter unique ID (8 characters)
1395
+ const uuid = crypto.randomUUID().replace(/-/g, '').substring(0, 8);
1396
+ const gatewayId = `Gateway-${uuid}`;
1310
1397
  gatewayMap.set(cluster.clusterId, gatewayId);
1311
1398
  }
1312
1399
  });
1313
1400
 
1314
1401
  // Create virtual gateway objects in the scene
1315
1402
  filteredClusters.forEach(cluster => {
1316
- if (cluster.steinerPoint) {
1403
+ if (cluster.jointPoint) {
1317
1404
  const gatewayId = gatewayMap.get(cluster.clusterId);
1318
1405
  const gatewayObject = {
1319
- uuid: `GATEWAY-${gatewayId}`,
1406
+ uuid: gatewayId,
1320
1407
  type: 'Mesh',
1321
1408
  userData: {
1322
1409
  worldBoundingBox: {
1323
1410
  min: [
1324
- cluster.steinerPoint.x - 0.25,
1325
- cluster.steinerPoint.y - 0.25,
1326
- cluster.steinerPoint.z - 0.25
1411
+ cluster.jointPoint.x - 0.25,
1412
+ cluster.jointPoint.y - 0.25,
1413
+ cluster.jointPoint.z - 0.25
1327
1414
  ],
1328
1415
  max: [
1329
- cluster.steinerPoint.x + 0.25,
1330
- cluster.steinerPoint.y + 0.25,
1331
- cluster.steinerPoint.z + 0.25
1416
+ cluster.jointPoint.x + 0.25,
1417
+ cluster.jointPoint.y + 0.25,
1418
+ cluster.jointPoint.z + 0.25
1332
1419
  ]
1333
1420
  }
1334
1421
  }
@@ -1340,6 +1427,7 @@ class Pathfinder {
1340
1427
  // Rewire connections through gateways
1341
1428
  const rewiredConnections = [];
1342
1429
  const connectionSet = new Set(); // Track unique connections
1430
+ const gatewayConnectionMappings = new Map(); // Track which original connections are replaced by which gateway connections
1343
1431
 
1344
1432
  // Find direct connections (not part of clusters with >2 objects)
1345
1433
  const directConnections = connections.filter(conn => {
@@ -1367,44 +1455,63 @@ class Pathfinder {
1367
1455
  )?.clusterId;
1368
1456
 
1369
1457
  if (fromCluster !== undefined && toCluster !== undefined) {
1370
- const fromGateway = `GATEWAY-${gatewayMap.get(fromCluster)}`;
1371
- const toGateway = `GATEWAY-${gatewayMap.get(toCluster)}`;
1372
-
1373
- if (fromGateway && toGateway) {
1458
+ const fromGateway = gatewayMap.get(fromCluster);
1459
+ const toGateway = gatewayMap.get(toCluster);
1460
+ if (fromGateway !== undefined && toGateway !== undefined) {
1461
+ const fromGatewayId = fromGateway;
1462
+ const toGatewayId = toGateway;
1463
+
1464
+ // Track the original connection that will be replaced
1465
+ const originalConnection = { from: conn.from, to: conn.to };
1466
+ const addedConnections = [];
1467
+
1374
1468
  // If both objects are in the same cluster, connect through their gateway
1375
1469
  if (fromCluster === toCluster) {
1376
- const conn1 = { from: conn.from, to: fromGateway };
1377
- const conn2 = { from: fromGateway, to: conn.to };
1470
+ const conn1 = { from: conn.from, to: fromGatewayId };
1471
+ const conn2 = { from: fromGatewayId, to: conn.to };
1378
1472
 
1379
1473
  // Only add if not already present
1380
1474
  if (!connectionSet.has(JSON.stringify(conn1))) {
1381
1475
  gatewayConnections.push(conn1);
1382
1476
  connectionSet.add(JSON.stringify(conn1));
1477
+ addedConnections.push(conn1);
1383
1478
  }
1384
1479
  if (!connectionSet.has(JSON.stringify(conn2))) {
1385
1480
  gatewayConnections.push(conn2);
1386
1481
  connectionSet.add(JSON.stringify(conn2));
1482
+ addedConnections.push(conn2);
1387
1483
  }
1388
1484
  } else {
1389
1485
  // If objects are in different clusters, connect through both gateways
1390
- const conn1 = { from: conn.from, to: fromGateway };
1391
- const conn2 = { from: fromGateway, to: toGateway };
1392
- const conn3 = { from: toGateway, to: conn.to };
1486
+ const conn1 = { from: conn.from, to: fromGatewayId };
1487
+ const conn2 = { from: fromGatewayId, to: toGatewayId };
1488
+ const conn3 = { from: toGatewayId, to: conn.to };
1393
1489
 
1394
1490
  // Only add if not already present
1395
1491
  if (!connectionSet.has(JSON.stringify(conn1))) {
1396
1492
  gatewayConnections.push(conn1);
1397
1493
  connectionSet.add(JSON.stringify(conn1));
1494
+ addedConnections.push(conn1);
1398
1495
  }
1399
1496
  if (!connectionSet.has(JSON.stringify(conn2))) {
1400
1497
  gatewayConnections.push(conn2);
1401
1498
  connectionSet.add(JSON.stringify(conn2));
1499
+ addedConnections.push(conn2);
1402
1500
  }
1403
1501
  if (!connectionSet.has(JSON.stringify(conn3))) {
1404
1502
  gatewayConnections.push(conn3);
1405
1503
  connectionSet.add(JSON.stringify(conn3));
1504
+ addedConnections.push(conn3);
1406
1505
  }
1407
1506
  }
1507
+
1508
+ // Store the mapping for this connection
1509
+ if (addedConnections.length > 0) {
1510
+ gatewayConnectionMappings.set(JSON.stringify(originalConnection), {
1511
+ removed: [originalConnection],
1512
+ added: addedConnections
1513
+ });
1514
+ }
1408
1515
  }
1409
1516
  }
1410
1517
  });
@@ -1418,8 +1525,8 @@ class Pathfinder {
1418
1525
 
1419
1526
  if (!objA1 || !objA2 || !objB1 || !objB2) return 0;
1420
1527
 
1421
- const distA = this.steinerTreeManager.distance(sceneManager.getWorldPosition(objA1), sceneManager.getWorldPosition(objA2));
1422
- const distB = this.steinerTreeManager.distance(sceneManager.getWorldPosition(objB1), sceneManager.getWorldPosition(objB2));
1528
+ const distA = this.treePathManager.distance(sceneManager.getWorldPosition(objA1), sceneManager.getWorldPosition(objA2));
1529
+ const distB = this.treePathManager.distance(sceneManager.getWorldPosition(objB1), sceneManager.getWorldPosition(objB2));
1423
1530
 
1424
1531
  return distA - distB;
1425
1532
  });
@@ -1429,13 +1536,69 @@ class Pathfinder {
1429
1536
 
1430
1537
  const paths = this.pathManager.findPaths(rewiredConnections || []);
1431
1538
 
1539
+ // Aggregate connection mappings by gateway
1540
+ const gatewayConnectionsMap = new Map();
1541
+
1542
+ // Initialize connection mappings for each gateway
1543
+ Array.from(gatewayMap.entries()).forEach(([clusterId, gatewayId]) => {
1544
+ gatewayConnectionsMap.set(gatewayId, {
1545
+ removed: [],
1546
+ added: []
1547
+ });
1548
+ });
1549
+
1550
+ // Populate connection mappings
1551
+ gatewayConnectionMappings.forEach((mapping, connectionKey) => {
1552
+ const originalConnection = JSON.parse(connectionKey);
1553
+ const fromCluster = clusters.find(cluster =>
1554
+ cluster.objects.includes(originalConnection.from)
1555
+ )?.clusterId;
1556
+ const toCluster = clusters.find(cluster =>
1557
+ cluster.objects.includes(originalConnection.to)
1558
+ )?.clusterId;
1559
+
1560
+ if (fromCluster !== undefined && toCluster !== undefined) {
1561
+ const fromGateway = gatewayMap.get(fromCluster);
1562
+ const toGateway = gatewayMap.get(toCluster);
1563
+
1564
+ if (fromGateway !== undefined && toGateway !== undefined) {
1565
+ // Add to both gateways if they're different
1566
+ if (fromGateway === toGateway) {
1567
+ // Same gateway
1568
+ const gatewayConnections = gatewayConnectionsMap.get(fromGateway);
1569
+ gatewayConnections.removed.push(originalConnection);
1570
+ gatewayConnections.added.push(...mapping.added);
1571
+ } else {
1572
+ // Different gateways - split the connections
1573
+ const fromGatewayConnections = gatewayConnectionsMap.get(fromGateway);
1574
+ const toGatewayConnections = gatewayConnectionsMap.get(toGateway);
1575
+
1576
+ // Add the original connection to both gateways' removed list
1577
+ fromGatewayConnections.removed.push(originalConnection);
1578
+ toGatewayConnections.removed.push(originalConnection);
1579
+
1580
+ // Split added connections by gateway
1581
+ mapping.added.forEach(conn => {
1582
+ if (conn.from === originalConnection.from || conn.to === fromGateway) {
1583
+ fromGatewayConnections.added.push(conn);
1584
+ }
1585
+ if (conn.from === toGateway || conn.to === originalConnection.to) {
1586
+ toGatewayConnections.added.push(conn);
1587
+ }
1588
+ });
1589
+ }
1590
+ }
1591
+ }
1592
+ });
1593
+
1432
1594
  return {
1433
1595
  paths,
1434
1596
  rewiredConnections,
1435
1597
  gateways: Array.from(gatewayMap.entries()).map(([clusterId, gatewayId]) => ({
1436
1598
  clusterId,
1437
1599
  id: gatewayId,
1438
- position: clusters.find(c => c.clusterId === clusterId)?.steinerPoint
1600
+ position: clusters.find(c => c.clusterId === clusterId)?.jointPoint,
1601
+ connections: gatewayConnectionsMap.get(gatewayId) || { removed: [], added: [] }
1439
1602
  }))
1440
1603
  };
1441
1604
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@2112-lab/central-plant",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Utility modules for the Central Plant Application",
5
5
  "main": "dist/bundle/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -29,7 +29,7 @@
29
29
  "author": "CentralPlant Team",
30
30
  "license": "MIT",
31
31
  "dependencies": {
32
- "@2112-lab/pathfinder": "^1.0.15",
32
+ "@2112-lab/pathfinder": "^1.0.27",
33
33
  "three": "^0.177.0"
34
34
  },
35
35
  "devDependencies": {