diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5d037c14..49a47fc7 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -34,6 +34,7 @@ repos: types-requests==2.31.0, numpy==2.0.1, pytest==8.3.1, + websockets>=10.4, types-setuptools>=71.1.0.20240818 ] args: [--config=pyproject.toml] diff --git a/README.md b/README.md index a9d09ab6..8f3cec61 100644 --- a/README.md +++ b/README.md @@ -64,24 +64,59 @@ gantt title diffCheck - general overview excludes weekends - section Publication - Abstract edition :active, absed, 2024-03-01, 2024-03-15 - Submission abstract ICSA :milestone, icsaabs, 2024-03-15, 0d - Paper edition :paperd, 2024-10-01, 2024-10-30 - Submission paper ICSA :milestone, icsapap, 2024-10-30, 0d - - section Code development - Backend development :backenddev, after icsaabs, 6w - Rhino/Grasshopper integration :rhghinteg, after backenddev, 6w - Documentation & Interface :docuint, after fabar, 3w + section Workshop + Workshop dryrun :milestone, crit, dryrun, 2025-09-15, 1d + Workshop in Boston :workshop, 2025-11-16, 2d + + section Component development + Pose estimation :CD1, 2025-05-15, 1w + Communication w/ hardware :CD2, after CD1, 3w + Pose comparison :CD3, after CD1, 3w + General PC manipulation :CD4, after CD1, 6w + Data analysis component :CD5, after CD3, 3w + + section Workshop preparation + Workshop scenario :doc1, 2025-08-01, 1w + New compilation documentation :doc2, after mac, 2w + New components documentation :doc2, 2025-08-01, 4w + Development of special pipeline for data:doc3, after doc1, 3w + + section Cross-platform + adaptation of CMake for mac compilation :mac, 2025-07-01, 3w section Prototype testing - Fabrication of AR Prototype :crit, fabar, 2024-07-01, 2024-08-30 - Fabrication of CNC Prototype :crit, fabcnc, 2024-07-01, 2024-08-30 - Fabrication of Robot Prototype :crit, fabrob, 2024-07-01, 2024-08-30 - Data collection and evaluation :dataeval, after fabrob, 4w + Fabrication of iterative prototype :fab, 2025-08-01, 2w ``` + + ## How to contribute If you want to contribute to the project, please refer to the [contribution guidelines]([./CONTRIBUTING.md](https://diffcheckorg.github.io/diffCheck/contribute.html)). + +## Logic +The logic of the workflow is currently as follows: + +```mermaid +stateDiagram-v2 + state "[breps to assemble]" as s1 + state "scan of latest element placed" as s2 + state "get pose of i-th brep" as s3 + state "get pose of i-1-th brep" as s4 + state "compute pose of i-1-th element from scan" as s5 + state "compute pose difference" as s6 + state "compute pose correction" as s7 + state "assemble i-th-element" as s8 + state "i += 1" as s9 + [*]-->s2 + s1-->s3 + s1-->s4 + s2-->s5 + s5-->s6 + s4-->s6 + s6-->s7 + s3-->s7 + s7-->s8 + s8-->s9 + s9-->[*] +``` diff --git a/deps/eigen b/deps/eigen index 11fd34cc..81044ec1 160000 --- a/deps/eigen +++ b/deps/eigen @@ -1 +1 @@ -Subproject commit 11fd34cc1c398f2c2311339ed3b008b1114544eb +Subproject commit 81044ec13df7608d0d9d86aff2ef9805fc69bed1 diff --git a/deps/pybind11 b/deps/pybind11 index 708ce4d9..03d8f487 160000 --- a/deps/pybind11 +++ b/deps/pybind11 @@ -1 +1 @@ -Subproject commit 708ce4d9c7bf55075608eb3cfcb5fa0dc43e070f +Subproject commit 03d8f48750ba4486a2c9aeff82e9702109db5cb3 diff --git a/doc/_static/example_files/subtractive_gh_v1.gh b/doc/_static/example_files/subtractive_gh_v1.gh index bdc5feb1..83d94854 100644 Binary files a/doc/_static/example_files/subtractive_gh_v1.gh and b/doc/_static/example_files/subtractive_gh_v1.gh differ diff --git a/environment.yml b/environment.yml index a24851e4..193c4dcf 100644 Binary files a/environment.yml and b/environment.yml differ diff --git a/pyproject.toml b/pyproject.toml index 1b7616fe..032c09f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,8 @@ module = [ "GH_IO.*", "clr.*", "diffcheck_bindings", - "diffCheck.diffcheck_bindings" + "diffCheck.diffcheck_bindings", + "ghpythonlib.*" ] ignore_missing_imports = true diff --git a/src/diffCheck.hh b/src/diffCheck.hh index 733801aa..31dec076 100644 --- a/src/diffCheck.hh +++ b/src/diffCheck.hh @@ -6,6 +6,9 @@ #include #include +#include + +#include // diffCheck includes #include "diffCheck/log.hh" diff --git a/src/diffCheck/IOManager.cc b/src/diffCheck/IOManager.cc index 2a857950..2da591a8 100644 --- a/src/diffCheck/IOManager.cc +++ b/src/diffCheck/IOManager.cc @@ -68,4 +68,11 @@ namespace diffCheck::io std::filesystem::path pathCloud = pathTestData / "test_pc_for_SOR_101pts_with_1_outlier.ply"; return pathCloud.string(); } + + std::string GetTwoConnectedPlanesPlyPath() + { + std::filesystem::path pathTestData = GetTestDataDir(); + std::filesystem::path pathCloud = pathTestData / "two_connected_planes_with_normals.ply"; + return pathCloud.string(); + } } // namespace diffCheck::io \ No newline at end of file diff --git a/src/diffCheck/IOManager.hh b/src/diffCheck/IOManager.hh index e22c029d..646ce511 100644 --- a/src/diffCheck/IOManager.hh +++ b/src/diffCheck/IOManager.hh @@ -42,4 +42,6 @@ namespace diffCheck::io std::string GetRoofQuarterPlyPath(); /// @brief Get the path to the plane point cloud with one outlier std::string GetPlanePCWithOneOutliers(); + /// @brief Get the path to the two connected planes ply test file + std::string GetTwoConnectedPlanesPlyPath(); } // namespace diffCheck::io \ No newline at end of file diff --git a/src/diffCheck/geometry/DFPointCloud.cc b/src/diffCheck/geometry/DFPointCloud.cc index d00fac65..c371c06c 100644 --- a/src/diffCheck/geometry/DFPointCloud.cc +++ b/src/diffCheck/geometry/DFPointCloud.cc @@ -216,6 +216,114 @@ namespace diffCheck::geometry this->Normals.push_back(normal); } + std::vector DFPointCloud::GetPrincipalAxes(int nComponents) + { + std::vector principalAxes; + + if (! this->HasNormals()) + { + DIFFCHECK_WARN("The point cloud has no normals. Normals will be estimated with knn = 20."); + this->EstimateNormals(true, 20); + } + + // Convert normals to Eigen matrix + Eigen::Matrix normalMatrix(3, this->Normals.size()); + for (size_t i = 0; i < this->Normals.size(); ++i) + { + normalMatrix.col(i) = this->Normals[i].cast(); + } + + cilantro::KMeans kmeans(normalMatrix); + kmeans.cluster(nComponents); + + const cilantro::VectorSet3d& centroids = kmeans.getClusterCentroids(); + const std::vector& assignments = kmeans.getPointToClusterIndexMap(); + std::vector clusterSizes(nComponents, 0); + for (size_t i = 0; i < assignments.size(); ++i) + { + clusterSizes[assignments[i]]++; + } + // Sort clusters by size + std::vector> sortedClustersBySize(nComponents); + for (size_t i = 0; i < nComponents; ++i) + { + sortedClustersBySize[i] = {clusterSizes[i], centroids.col(i)}; + } + std::sort(sortedClustersBySize.begin(), sortedClustersBySize.end(), [](const auto& a, const auto& b) + { + return a.first > b.first; + }); + + for(size_t i = 0; i < nComponents; ++i) + { + if(principalAxes.size() == 0) + { + principalAxes.push_back(sortedClustersBySize[i].second); + } + else + { + bool isAlreadyPresent = false; + for (const auto& axis : principalAxes) + { + double dotProduct = std::abs(axis.dot(sortedClustersBySize[i].second)); + if (std::abs(dotProduct) > 0.7) // Threshold to consider as similar direction + { + isAlreadyPresent = true; + break; + } + } + if (!isAlreadyPresent) + { + principalAxes.push_back(sortedClustersBySize[i].second); + } + } + } + if (principalAxes.size() < 2) // Fallback to OBB if k-means fails to provide enough distinct axes + { + open3d::geometry::OrientedBoundingBox obb = this->Cvt2O3DPointCloud()->GetOrientedBoundingBox(); + principalAxes = {obb.R_.col(0), obb.R_.col(1), obb.R_.col(2)}; + } + return principalAxes; + } + + void DFPointCloud::Crop(const Eigen::Vector3d &minBound, const Eigen::Vector3d &maxBound) + { + auto O3DPointCloud = this->Cvt2O3DPointCloud(); + auto O3DPointCloudCropped = O3DPointCloud->Crop(open3d::geometry::AxisAlignedBoundingBox(minBound, maxBound)); + this->Points.clear(); + for (auto &point : O3DPointCloudCropped->points_) + this->Points.push_back(point); + this->Colors.clear(); + for (auto &color : O3DPointCloudCropped->colors_) + this->Colors.push_back(color); + this->Normals.clear(); + for (auto &normal : O3DPointCloudCropped->normals_) + this->Normals.push_back(normal); + } + + void DFPointCloud::Crop(const std::vector &corners) + { + if (corners.size() != 8) + throw std::invalid_argument("The corners vector must contain exactly 8 points."); + open3d::geometry::OrientedBoundingBox obb = open3d::geometry::OrientedBoundingBox::CreateFromPoints(corners); + auto O3DPointCloud = this->Cvt2O3DPointCloud(); + auto O3DPointCloudCropped = O3DPointCloud->Crop(obb); + this->Points.clear(); + for (auto &point : O3DPointCloudCropped->points_) + this->Points.push_back(point); + this->Colors.clear(); + for (auto &color : O3DPointCloudCropped->colors_) + this->Colors.push_back(color); + this->Normals.clear(); + for (auto &normal : O3DPointCloudCropped->normals_) + this->Normals.push_back(normal); + } + + DFPointCloud DFPointCloud::Duplicate() const + { + return DFPointCloud(this->Points, this->Colors, this->Normals); + } + void DFPointCloud::UniformDownsample(int everyKPoints) { auto O3DPointCloud = this->Cvt2O3DPointCloud(); @@ -258,6 +366,86 @@ namespace diffCheck::geometry return bboxPts; } + void DFPointCloud::SubtractPoints(const DFPointCloud &pointCloud, double distanceThreshold) + { + if (this->Points.size() == 0 || pointCloud.Points.size() == 0) + throw std::invalid_argument("One of the point clouds is empty."); + + auto O3DSourcePointCloud = this->Cvt2O3DPointCloud(); + auto O3DTargetPointCloud = std::make_shared(pointCloud)->Cvt2O3DPointCloud(); + auto O3DResultPointCloud = std::make_shared(); + + open3d::geometry::KDTreeFlann threeDTree; + threeDTree.SetGeometry(*O3DTargetPointCloud); + std::vector indices; + std::vector distances; + for (const auto &point : O3DSourcePointCloud->points_) + { + threeDTree.SearchRadius(point, distanceThreshold, indices, distances); + if (indices.empty()) + { + O3DResultPointCloud->points_.push_back(point); + if (O3DSourcePointCloud->HasColors()) + { + O3DResultPointCloud->colors_.push_back(O3DSourcePointCloud->colors_[&point - &O3DSourcePointCloud->points_[0]]); + } + if (O3DSourcePointCloud->HasNormals()) + { + O3DResultPointCloud->normals_.push_back(O3DSourcePointCloud->normals_[&point - &O3DSourcePointCloud->points_[0]]); + } + } + } + this->Points.clear(); + for (auto &point : O3DResultPointCloud->points_) + this->Points.push_back(point); + if (O3DResultPointCloud->HasColors()) + { + this->Colors.clear(); + for (auto &color : O3DResultPointCloud->colors_){this->Colors.push_back(color);}; + } + if (O3DResultPointCloud->HasNormals()) + { + this->Normals.clear(); + for (auto &normal : O3DResultPointCloud->normals_){this->Normals.push_back(normal);}; + } + } + + diffCheck::geometry::DFPointCloud DFPointCloud::Intersect(const DFPointCloud &pointCloud, double distanceThreshold) + { + if (this->Points.size() == 0 || pointCloud.Points.size() == 0) + throw std::invalid_argument("One of the point clouds is empty."); + + auto O3DSourcePointCloud = this->Cvt2O3DPointCloud(); + auto O3DTargetPointCloud = std::make_shared(pointCloud)->Cvt2O3DPointCloud(); + auto O3DResultPointCloud = std::make_shared(); + + open3d::geometry::KDTreeFlann threeDTree; + threeDTree.SetGeometry(*O3DTargetPointCloud); + std::vector indices; + std::vector distances; + for (const auto &point : O3DSourcePointCloud->points_) + { + threeDTree.SearchRadius(point, distanceThreshold, indices, distances); + if (!indices.empty()) + { + O3DResultPointCloud->points_.push_back(point); + if (O3DSourcePointCloud->HasColors()) + { + O3DResultPointCloud->colors_.push_back(O3DSourcePointCloud->colors_[&point - &O3DSourcePointCloud->points_[0]]); + } + if (O3DSourcePointCloud->HasNormals()) + { + O3DResultPointCloud->normals_.push_back(O3DSourcePointCloud->normals_[&point - &O3DSourcePointCloud->points_[0]]); + } + } + } + diffCheck::geometry::DFPointCloud result; + result.Points = O3DResultPointCloud->points_; + result.Colors = O3DResultPointCloud->colors_; + result.Normals = O3DResultPointCloud->normals_; + return result; + } + void DFPointCloud::ApplyTransformation(const diffCheck::transformation::DFTransformation &transformation) { auto O3DPointCloud = this->Cvt2O3DPointCloud(); diff --git a/src/diffCheck/geometry/DFPointCloud.hh b/src/diffCheck/geometry/DFPointCloud.hh index b3f0a3be..86658cc9 100644 --- a/src/diffCheck/geometry/DFPointCloud.hh +++ b/src/diffCheck/geometry/DFPointCloud.hh @@ -8,6 +8,8 @@ #include #include +#include + namespace diffCheck::geometry { @@ -89,6 +91,35 @@ namespace diffCheck::geometry */ void RemoveStatisticalOutliers(int nbNeighbors, double stdRatio); + /** + * @brief Get the nCompoments principal axes of the normals of the point cloud + * It is used to compute the pose of "boxy" point clouds. It relies on KMeans clustering to find the main axes of the point cloud. + * @param nComponents the number of components to compute (default 6, each of 3 main axes in both directions) + * @return std::vector the principal axes of the point cloud ordered by number of normals + */ + std::vector GetPrincipalAxes(int nComponents = 6); + + /** + * @brief Crop the point cloud to a bounding box defined by the min and max bounds + * + * @param minBound the minimum bound of the bounding box as an Eigen::Vector3d + * @param maxBound the maximum bound of the bounding box as an Eigen::Vector3d + */ + void Crop(const Eigen::Vector3d &minBound, const Eigen::Vector3d &maxBound); + + /** + * @brief Crop the point cloud to a bounding box defined by the 8 corners of the box + * @param corners the 8 corners of the bounding box as a vector of Eigen::Vector3d + */ + void Crop(const std::vector &corners); + + /** + * @brief Get the duplicate of the point cloud. This is mainly used in the python bindings + * + * @return DFPointCloud a copy of the point cloud + */ + diffCheck::geometry::DFPointCloud Duplicate() const; + public: ///< Downsamplers /** * @brief Downsample the point cloud with voxel grid @@ -136,6 +167,24 @@ namespace diffCheck::geometry * /// */ std::vector GetTightBoundingBox(); + + public: ///< Point cloud subtraction and intersection + /** + * @brief Subtract the points, colors and normals from another point cloud when they are too close to the points of another point cloud. + * + * @param pointCloud the other point cloud to subtract from this one + * @param distanceThreshold the distance threshold to consider a point as too close. Default is 0.01. + */ + void SubtractPoints(const DFPointCloud &pointCloud, double distanceThreshold = 0.01); + + /** + * @brief Intersect the points, colors and normals from another point cloud when they are close enough to the points of another point cloud. Is the point cloud interpretation of a boolean intersection. + * + * @param pointCloud the other point cloud to intersect with this one + * @param distanceThreshold the distance threshold to consider a point as too close. Default is 0.01. + * @return diffCheck::geometry::DFPointCloud the intersected point cloud + */ + diffCheck::geometry::DFPointCloud Intersect(const DFPointCloud &pointCloud, double distanceThreshold = 0.01); public: ///< Transformers /** diff --git a/src/diffCheckBindings.cc b/src/diffCheckBindings.cc index 434f5da2..048a3370 100644 --- a/src/diffCheckBindings.cc +++ b/src/diffCheckBindings.cc @@ -41,6 +41,12 @@ PYBIND11_MODULE(diffcheck_bindings, m) { .def("downsample_by_size", &diffCheck::geometry::DFPointCloud::DownsampleBySize, py::arg("target_size")) + .def("subtract_points", &diffCheck::geometry::DFPointCloud::SubtractPoints, + py::arg("point_cloud"), py::arg("distance_threshold")) + + .def("intersect", &diffCheck::geometry::DFPointCloud::Intersect, + py::arg("point_cloud"), py::arg("distance_threshold")) + .def("apply_transformation", &diffCheck::geometry::DFPointCloud::ApplyTransformation, py::arg("transformation")) @@ -55,6 +61,20 @@ PYBIND11_MODULE(diffcheck_bindings, m) { .def("remove_statistical_outliers", &diffCheck::geometry::DFPointCloud::RemoveStatisticalOutliers, py::arg("nb_neighbors"), py::arg("std_ratio")) + .def("get_principal_axes", &diffCheck::geometry::DFPointCloud::GetPrincipalAxes, + py::arg("n_components") = 6) + .def("crop", + (void (diffCheck::geometry::DFPointCloud::*)(const Eigen::Vector3d&, const Eigen::Vector3d&)) + &diffCheck::geometry::DFPointCloud::Crop, + py::arg("min_bound"), py::arg("max_bound")) + + .def("crop", + (void (diffCheck::geometry::DFPointCloud::*)(const std::vector&)) + &diffCheck::geometry::DFPointCloud::Crop, + py::arg("corners")) + + .def("duplicate", &diffCheck::geometry::DFPointCloud::Duplicate) + .def("load_from_PLY", &diffCheck::geometry::DFPointCloud::LoadFromPLY) .def("save_to_PLY", &diffCheck::geometry::DFPointCloud::SaveToPLY) diff --git a/src/gh/components/DF_cloud_difference/code.py b/src/gh/components/DF_cloud_difference/code.py new file mode 100644 index 00000000..0d54f9e1 --- /dev/null +++ b/src/gh/components/DF_cloud_difference/code.py @@ -0,0 +1,27 @@ +from diffCheck import df_cvt_bindings as df_cvt + +import Rhino +from Grasshopper.Kernel import GH_RuntimeMessageLevel as RML + +from ghpythonlib.componentbase import executingcomponent as component + + +class DFCloudDifference(component): + def __init__(self): + super(DFCloudDifference, self).__init__() + + def RunScript(self, + i_cloud_A: Rhino.Geometry.PointCloud, + i_cloud_B: Rhino.Geometry.PointCloud, + i_distance_threshold: float): + df_cloud = df_cvt.cvt_rhcloud_2_dfcloud(i_cloud_A) + df_cloud_substract = df_cvt.cvt_rhcloud_2_dfcloud(i_cloud_B) + if i_distance_threshold is None: + ghenv.Component.AddRuntimeMessage(RML.Warning, "Distance threshold not defined. 0.01 used as default value.")# noqa: F821 + i_distance_threshold = 0.01 + if i_distance_threshold <= 0: + ghenv.Component.AddRuntimeMessage(RML.Warning, "Distance threshold must be greater than 0. Please provide a valid distance threshold.")# noqa: F821 + return None + df_cloud.subtract_points(df_cloud_substract, i_distance_threshold) + rh_cloud = df_cvt.cvt_dfcloud_2_rhcloud(df_cloud) + return [rh_cloud] diff --git a/src/gh/components/DF_cloud_difference/icon.png b/src/gh/components/DF_cloud_difference/icon.png new file mode 100644 index 00000000..cba0ffbf Binary files /dev/null and b/src/gh/components/DF_cloud_difference/icon.png differ diff --git a/src/gh/components/DF_cloud_difference/metadata.json b/src/gh/components/DF_cloud_difference/metadata.json new file mode 100644 index 00000000..ec7dfd40 --- /dev/null +++ b/src/gh/components/DF_cloud_difference/metadata.json @@ -0,0 +1,64 @@ +{ + "name": "DFCloudDifference", + "nickname": "Difference", + "category": "diffCheck", + "subcategory": "Cloud", + "description": "Subtracts points from a point cloud based on a distance threshold.", + "exposure": 4, + "instanceGuid": "9ef299aa-76dc-4417-9b95-2a374e2b36af", + "ghpython": { + "hideOutput": true, + "hideInput": true, + "isAdvancedMode": true, + "marshalOutGuids": true, + "iconDisplay": 2, + "inputParameters": [ + { + "name": "i_cloud_A", + "nickname": "i_cloud_A", + "description": "The point cloud to subtract from.", + "optional": false, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "pointcloud" + }, + { + "name": "i_cloud_B", + "nickname": "i_cloud_B", + "description": "The point cloud to subtract with.", + "optional": false, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "pointcloud" + }, + { + "name": "i_distance_threshold", + "nickname": "i_distance_threshold", + "description": "The distance threshold to consider a point as too close.", + "optional": true, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "float" + } + ], + "outputParameters": [ + { + "name": "o_cloud_in", + "nickname": "o_cloud", + "description": "The resulting cloud after subtraction.", + "optional": false, + "sourceCount": 0, + "graft": false + } + ] + } +} \ No newline at end of file diff --git a/src/gh/components/DF_cloud_intersection/code.py b/src/gh/components/DF_cloud_intersection/code.py new file mode 100644 index 00000000..8ebe1b4e --- /dev/null +++ b/src/gh/components/DF_cloud_intersection/code.py @@ -0,0 +1,27 @@ +from diffCheck import df_cvt_bindings as df_cvt + +import Rhino +from Grasshopper.Kernel import GH_RuntimeMessageLevel as RML + +from ghpythonlib.componentbase import executingcomponent as component + + +class DFCloudIntersection(component): + def __init__(self): + super(DFCloudIntersection, self).__init__() + + def RunScript(self, + i_cloud_A: Rhino.Geometry.PointCloud, + i_cloud_B: Rhino.Geometry.PointCloud, + i_distance_threshold: float): + df_cloud = df_cvt.cvt_rhcloud_2_dfcloud(i_cloud_A) + df_cloud_intersect = df_cvt.cvt_rhcloud_2_dfcloud(i_cloud_B) + if i_distance_threshold is None: + ghenv.Component.AddRuntimeMessage(RML.Warning, "Distance threshold not defined. 0.01 used as default value.")# noqa: F821 + i_distance_threshold = 0.01 + if i_distance_threshold <= 0: + ghenv.Component.AddRuntimeMessage(RML.Warning, "Distance threshold must be greater than 0. Please provide a valid distance threshold.")# noqa: F821 + return None + df_intersection = df_cloud.intersect(df_cloud_intersect, i_distance_threshold) + rh_cloud = df_cvt.cvt_dfcloud_2_rhcloud(df_intersection) + return [rh_cloud] diff --git a/src/gh/components/DF_cloud_intersection/icon.png b/src/gh/components/DF_cloud_intersection/icon.png new file mode 100644 index 00000000..6eeb70e5 Binary files /dev/null and b/src/gh/components/DF_cloud_intersection/icon.png differ diff --git a/src/gh/components/DF_cloud_intersection/metadata.json b/src/gh/components/DF_cloud_intersection/metadata.json new file mode 100644 index 00000000..e12fd3ff --- /dev/null +++ b/src/gh/components/DF_cloud_intersection/metadata.json @@ -0,0 +1,64 @@ +{ + "name": "DFCloudIntersection", + "nickname": "Intersection", + "category": "diffCheck", + "subcategory": "Cloud", + "description": "Intersects points from two point clouds based on a distance threshold.", + "exposure": 4, + "instanceGuid": "b1a87021-dc4d-4844-86e0-8dcf55965ac6", + "ghpython": { + "hideOutput": true, + "hideInput": true, + "isAdvancedMode": true, + "marshalOutGuids": true, + "iconDisplay": 2, + "inputParameters": [ + { + "name": "i_cloud_A", + "nickname": "i_cloud_A", + "description": "The point cloud to intersect from.", + "optional": false, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "pointcloud" + }, + { + "name": "i_cloud_B", + "nickname": "i_cloud_B", + "description": "The point cloud to intersect with.", + "optional": false, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "pointcloud" + }, + { + "name": "i_distance_threshold", + "nickname": "i_distance_threshold", + "description": "The distance threshold to consider a point as close enough.", + "optional": true, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "float" + } + ], + "outputParameters": [ + { + "name": "o_cloud", + "nickname": "o_cloud", + "description": "The resulting cloud after intersection.", + "optional": false, + "sourceCount": 0, + "graft": false + } + ] + } +} \ No newline at end of file diff --git a/src/gh/components/DF_cloud_split/code.py b/src/gh/components/DF_cloud_split/code.py new file mode 100644 index 00000000..263136cb --- /dev/null +++ b/src/gh/components/DF_cloud_split/code.py @@ -0,0 +1,42 @@ +"""Crops a point cloud by giving the bounding box or a brep.""" +from diffCheck import df_cvt_bindings as df_cvt + +import numpy as np + +import Rhino + +from ghpythonlib.componentbase import executingcomponent as component + +TOL = Rhino.RhinoDoc.ActiveDoc.ModelAbsoluteTolerance + +class DFCloudSplit(component): + def __init__(self): + super(DFCloudSplit, self).__init__() + + def RunScript(self, + i_cloud: Rhino.Geometry.PointCloud, + i_boundary: Rhino.Geometry.Brep): + + if i_boundary.IsBox(): + vertices = i_boundary.Vertices + bb_as_array = [np.asarray([vertice.Location.X, vertice.Location.Y, vertice.Location.Z]) for vertice in vertices] + df_cloud = df_cvt.cvt_rhcloud_2_dfcloud(i_cloud) + df_cloud_copy = df_cloud.duplicate() + df_cloud.crop(bb_as_array) + df_cloud_copy.subtract_points(df_cloud, TOL) + o_pts_out = df_cvt.cvt_dfcloud_2_rhcloud(df_cloud_copy) + o_pts_in = df_cvt.cvt_dfcloud_2_rhcloud(df_cloud) + + else: + pts_in = [] + pts_out = [] + for pc_item in i_cloud: + point = Rhino.Geometry.Point3d(pc_item.X, pc_item.Y, pc_item.Z) + if i_boundary.IsPointInside(point, TOL, True): + pts_in.append(point) + else: + pts_out.append(point) + o_pts_in = Rhino.Geometry.PointCloud(pts_in) + o_pts_out = Rhino.Geometry.PointCloud(pts_out) + + return [o_pts_in, o_pts_out] diff --git a/src/gh/components/DF_cloud_split/icon.png b/src/gh/components/DF_cloud_split/icon.png new file mode 100644 index 00000000..2d21368a Binary files /dev/null and b/src/gh/components/DF_cloud_split/icon.png differ diff --git a/src/gh/components/DF_cloud_split/metadata.json b/src/gh/components/DF_cloud_split/metadata.json new file mode 100644 index 00000000..c4ca6852 --- /dev/null +++ b/src/gh/components/DF_cloud_split/metadata.json @@ -0,0 +1,60 @@ +{ + "name": "DFCloudSplit", + "nickname": "Split", + "category": "diffCheck", + "subcategory": "Cloud", + "description": "Splits a point cloud using a boundary volume.", + "exposure": 4, + "instanceGuid": "f0461287-b1aa-47ec-87c4-0f03924cea24", + "ghpython": { + "hideOutput": true, + "hideInput": true, + "isAdvancedMode": true, + "marshalOutGuids": true, + "iconDisplay": 2, + "inputParameters": [ + { + "name": "i_cloud", + "nickname": "i_cloud", + "description": "The point cloud to split.", + "optional": false, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "pointcloud" + }, + { + "name": "i_boundary", + "nickname": "i_boundary", + "description": "The brep boundary to split the point cloud with. If a box is provided, computation will be faster. If a generic brep is provided, it will be used but may be slower.", + "optional": false, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "brep" + } + ], + "outputParameters": [ + { + "name": "o_cloud_inside", + "nickname": "o_cloud_inside", + "description": "the points inside the splitting region.", + "optional": false, + "sourceCount": 0, + "graft": false + }, + { + "name": "o_cloud_outside", + "nickname": "o_cloud_outside", + "description": "the points outside the splitting region.", + "optional": false, + "sourceCount": 0, + "graft": false + } + ] + } +} \ No newline at end of file diff --git a/src/gh/components/DF_cloud_union/code.py b/src/gh/components/DF_cloud_union/code.py new file mode 100644 index 00000000..2c341023 --- /dev/null +++ b/src/gh/components/DF_cloud_union/code.py @@ -0,0 +1,28 @@ +"""Merges point clouds together.""" +import diffCheck +from diffCheck.diffcheck_bindings import dfb_geometry as df_geometry +import Rhino + +import System + +from Grasshopper.Kernel import GH_RuntimeMessageLevel as RML + +from ghpythonlib.componentbase import executingcomponent as component + +TOL = Rhino.RhinoDoc.ActiveDoc.ModelAbsoluteTolerance + + +class DFCloudUnion(component): + def RunScript(self, + i_clouds: System.Collections.Generic.List[Rhino.Geometry.PointCloud]): + if i_clouds is None or len(i_clouds) == 0: + ghenv.Component.AddRuntimeMessage(RML.Warning, "No point clouds provided. Please connect point clouds to the input.") # noqa: F821 + return None + + merged_cloud = df_geometry.DFPointCloud() + for cloud in i_clouds: + df_cloud = diffCheck.df_cvt_bindings.cvt_rhcloud_2_dfcloud(cloud) + merged_cloud.add_points(df_cloud) + + o_cloud = diffCheck.df_cvt_bindings.cvt_dfcloud_2_rhcloud(merged_cloud) + return [o_cloud] diff --git a/src/gh/components/DF_cloud_union/icon.png b/src/gh/components/DF_cloud_union/icon.png new file mode 100644 index 00000000..bda1f58c Binary files /dev/null and b/src/gh/components/DF_cloud_union/icon.png differ diff --git a/src/gh/components/DF_cloud_union/metadata.json b/src/gh/components/DF_cloud_union/metadata.json new file mode 100644 index 00000000..da4d63cc --- /dev/null +++ b/src/gh/components/DF_cloud_union/metadata.json @@ -0,0 +1,41 @@ +{ + "name": "DFCloudUnion", + "nickname": "DFCloudUnion", + "category": "diffCheck", + "subcategory": "Cloud", + "description": "This component merges a series of point clouds into a unique point cloud.", + "exposure": 4, + "instanceGuid": "1e5e3ce8-1eb8-4227-9456-016f3cedd235", + "ghpython": { + "hideOutput": true, + "hideInput": true, + "isAdvancedMode": true, + "marshalOutGuids": true, + "iconDisplay": 2, + "inputParameters": [ + { + "name": "i_clouds", + "nickname": "i_clouds", + "description": "The point clouds to merge.", + "optional": false, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "list", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "pointcloud", + "flatten": true + } + ], + "outputParameters": [ + { + "name": "o_cloud", + "nickname": "o_cloud", + "description": "The merged point clouds.", + "optional": false, + "sourceCount": 0, + "graft": false + } + ] + } +} \ No newline at end of file diff --git a/src/gh/components/DF_http_listener/code.py b/src/gh/components/DF_http_listener/code.py new file mode 100644 index 00000000..c35719ba --- /dev/null +++ b/src/gh/components/DF_http_listener/code.py @@ -0,0 +1,136 @@ +#! python3 + +from ghpythonlib.componentbase import executingcomponent as component +import os +import tempfile +import requests +import threading +import Rhino +import Rhino.Geometry as rg +import scriptcontext as sc +from diffCheck import df_gh_canvas_utils + + +class DFHTTPListener(component): + + def __init__(self): + try: + ghenv.Component.ExpireSolution(True) # noqa: F821 + ghenv.Component.Attributes.PerformLayout() # noqa: F821 + except NameError: + pass + + df_gh_canvas_utils.add_button(ghenv.Component, "Load", 0, x_offset=60) # noqa: F821 + df_gh_canvas_utils.add_panel(ghenv.Component, "Ply_url", "https://github.com/diffCheckOrg/diffCheck/raw/refs/heads/main/tests/test_data/cube_mesh.ply", 1, 60, 20) # noqa: F821 + + def RunScript(self, + i_load: bool, + i_ply_url: str): + + prefix = 'http' + + # initialize sticky variables + sc.sticky.setdefault(f'{prefix}_ply_url', None) # last url processed + sc.sticky.setdefault(f'{prefix}_imported_geom', None) # last geo imported from ply + sc.sticky.setdefault(f'{prefix}_status_message', "Waiting..") # status message on component + sc.sticky.setdefault(f'{prefix}_prev_load', False) # previous state of toggle + sc.sticky.setdefault(f'{prefix}_thread_running', False) # is a background thread running? + + def _import_job(url: str) -> None: + + """ + Downloads and imports a .ply file from a given URL in a background thread. + Background job: + - Downloads the .ply file from the URL + - Imports it into the active Rhino document + - Extracts the new geometry (point cloud or mesh) + - Cleans up the temporary file and document objects + - Updates sticky state and status message + - Signals to GH that it should re-solve + + :param url: A string representing a direct URL to a .ply file (e.g. from GitHub or local server). + The file must end with ".ply". + :returns: None + """ + + tmp = None + try: + if not url.lower().endswith('.ply'): + raise ValueError("URL must end in .ply") + + resp = requests.get(url, timeout=30) + resp.raise_for_status() + # save om temporary file + fn = os.path.basename(url) + tmp = os.path.join(tempfile.gettempdir(), fn) + with open(tmp, 'wb') as f: + f.write(resp.content) + + doc = Rhino.RhinoDoc.ActiveDoc + # recordd existing object IDs to detect new ones + before_ids = {o.Id for o in doc.Objects} + + # import PLY using Rhino's API + opts = Rhino.FileIO.FilePlyReadOptions() + ok = Rhino.FileIO.FilePly.Read(tmp, doc, opts) + if not ok: + raise RuntimeError("Rhino.FilePly.Read failed") + + after_ids = {o.Id for o in doc.Objects} + new_ids = after_ids - before_ids + # get new pcd or mesh from document + geom = None + for guid in new_ids: + g = doc.Objects.FindId(guid).Geometry + if isinstance(g, rg.PointCloud): + geom = g.Duplicate() + break + elif isinstance(g, rg.Mesh): + geom = g.DuplicateMesh() + break + # remove imported objects + for guid in new_ids: + doc.Objects.Delete(guid, True) + doc.Views.Redraw() + + # store new geometry + sc.sticky[f'{prefix}_imported_geom'] = geom + count = geom.Count if isinstance(geom, rg.PointCloud) else geom.Vertices.Count + if isinstance(geom, rg.PointCloud): + sc.sticky[f'{prefix}_status_message'] = f"Loaded pcd with {count} pts" + else: + sc.sticky[f'{prefix}_status_message'] = f"Loaded mesh wih {count} vertices" + ghenv.Component.Message = sc.sticky.get(f'{prefix}_status_message') # noqa: F821 + + except Exception as e: + sc.sticky[f'{prefix}_imported_geom'] = None + sc.sticky[f'{prefix}_status_message'] = f"Error: {e}" + finally: + try: + os.remove(tmp) + except Exception: + pass + # mark thread as finished + sc.sticky[f'{prefix}_thread_running'] = False + ghenv.Component.ExpireSolution(True) # noqa: F821 + + # check if the URL input has changed + if sc.sticky[f'{prefix}_ply_url'] != i_ply_url: + sc.sticky[f'{prefix}_ply_url'] = i_ply_url + sc.sticky[f'{prefix}_status_message'] = "URL changed. Press Load" + sc.sticky[f'{prefix}_thread_running'] = False + sc.sticky[f'{prefix}_prev_load'] = False + + # start importing if Load toggle is pressed and import thread is not already running + if i_load and not sc.sticky[f'{prefix}_prev_load'] and not sc.sticky[f'{prefix}_thread_running']: + sc.sticky[f'{prefix}_status_message'] = "Loading..." + sc.sticky[f'{prefix}_thread_running'] = True + threading.Thread(target=_import_job, args=(i_ply_url,), daemon=True).start() + + sc.sticky[f'{prefix}_prev_load'] = i_load + ghenv.Component.Message = sc.sticky.get(f'{prefix}_status_message', "") # noqa: F821 + + # output + o_geometry = sc.sticky.get(f'{prefix}_imported_geom') + + return [o_geometry] diff --git a/src/gh/components/DF_http_listener/icon.png b/src/gh/components/DF_http_listener/icon.png new file mode 100644 index 00000000..44df06fe Binary files /dev/null and b/src/gh/components/DF_http_listener/icon.png differ diff --git a/src/gh/components/DF_http_listener/metadata.json b/src/gh/components/DF_http_listener/metadata.json new file mode 100644 index 00000000..e029ea3f --- /dev/null +++ b/src/gh/components/DF_http_listener/metadata.json @@ -0,0 +1,52 @@ +{ + "name": "DFHTTPListener", + "nickname": "HTTPIn", + "category": "diffCheck", + "subcategory": "IO", + "description": "This component reads a ply file from the internet.", + "exposure": 4, + "instanceGuid": "ca4b5c94-6c85-4bc5-87f0-132cc34c4536", + "ghpython": { + "hideOutput": true, + "hideInput": true, + "isAdvancedMode": true, + "marshalOutGuids": true, + "iconDisplay": 2, + "inputParameters": [ + { + "name": "i_load", + "nickname": "i_load", + "description": "Button to import ply from url.", + "optional": true, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "bool" + }, + { + "name": "i_ply_url", + "nickname": "i_ply_url", + "description": "The url where to get the pointcloud", + "optional": true, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "str" + } + ], + "outputParameters": [ + { + "name": "o_geometry", + "nickname": "o_geo", + "description": "The mesh or pcd that was imported.", + "optional": false, + "sourceCount": 0, + "graft": false + } + ] + } +} \ No newline at end of file diff --git a/src/gh/components/DF_pose_estimation/code.py b/src/gh/components/DF_pose_estimation/code.py new file mode 100644 index 00000000..4ab24063 --- /dev/null +++ b/src/gh/components/DF_pose_estimation/code.py @@ -0,0 +1,68 @@ +#! python3 + +from diffCheck import df_cvt_bindings +from diffCheck import df_poses + +import Rhino +from Grasshopper.Kernel import GH_RuntimeMessageLevel as RML + +from ghpythonlib.componentbase import executingcomponent as component +import System + + +class DFPoseEstimation(component): + def RunScript(self, + i_clouds: System.Collections.Generic.List[Rhino.Geometry.PointCloud], + i_assembly, + i_save: bool, + i_reset: bool): + + # ensure assembly has enough beams + if len(i_assembly.beams) < len(i_clouds): + ghenv.Component.AddRuntimeMessage(RML.Warning, "Assembly has fewer beams than input clouds") # noqa: F821 + return None, None + + planes = [] + all_poses_in_time = df_poses.DFPosesAssembly() + if i_reset: + all_poses_in_time.reset() + return None, None + + all_poses_this_time = [] + for i, cloud in enumerate(i_clouds): + try: + df_cloud = df_cvt_bindings.cvt_rhcloud_2_dfcloud(cloud) + if df_cloud is None: + return None, None + if not df_cloud.has_normals(): + ghenv.Component.AddRuntimeMessage(RML.Error, f"Point cloud {i} has no normals. Please compute the normals.") # noqa: F821 + + df_points = df_cloud.get_axis_aligned_bounding_box() + df_point = (df_points[0] + df_points[1]) / 2 + rh_point = Rhino.Geometry.Point3d(df_point[0], df_point[1], df_point[2]) + + axes = df_cloud.get_principal_axes(3) + vectors = [] + for axe in axes: + vectors.append(Rhino.Geometry.Vector3d(axe[0], axe[1], axe[2])) + + new_xDirection, new_yDirection = df_poses.select_vectors(vectors, i_assembly.beams[i].plane.XAxis, i_assembly.beams[i].plane.YAxis) + + pose = df_poses.DFPose( + origin = [rh_point.X, rh_point.Y, rh_point.Z], + xDirection = [new_xDirection.X, new_xDirection.Y, new_xDirection.Z], + yDirection = [new_yDirection.X, new_yDirection.Y, new_yDirection.Z]) + all_poses_this_time.append(pose) + plane = Rhino.Geometry.Plane(origin = rh_point, xDirection=new_xDirection, yDirection=new_yDirection) + planes.append(plane) + except Exception as e: + # Any unexpected error on this cloud, skip it and keep going + ghenv.Component.AddRuntimeMessage(RML.Error, f"Cloud {i}: processing failed ({e}); skipping.") # noqa: F821 + planes.append(None) + all_poses_this_time.append(None) + continue + + if i_save: + all_poses_in_time.add_step(all_poses_this_time) + + return [planes, all_poses_in_time.to_gh_tree()] diff --git a/src/gh/components/DF_pose_estimation/icon.png b/src/gh/components/DF_pose_estimation/icon.png new file mode 100644 index 00000000..1240418f Binary files /dev/null and b/src/gh/components/DF_pose_estimation/icon.png differ diff --git a/src/gh/components/DF_pose_estimation/metadata.json b/src/gh/components/DF_pose_estimation/metadata.json new file mode 100644 index 00000000..60d1f363 --- /dev/null +++ b/src/gh/components/DF_pose_estimation/metadata.json @@ -0,0 +1,84 @@ +{ + "name": "DFPoseEstimation", + "nickname": "PoseEsimation", + "category": "diffCheck", + "subcategory": "PointCloud", + "description": "This compoment calculates the pose of a list of point clouds.", + "exposure": 4, + "instanceGuid": "a13c4414-f5df-46e6-beae-7054bb9c3e72", + "ghpython": { + "hideOutput": true, + "hideInput": true, + "isAdvancedMode": true, + "marshalOutGuids": true, + "iconDisplay": 2, + "inputParameters": [ + { + "name": "i_clouds", + "nickname": "i_clouds", + "description": "clouds whose pose is to be calculated", + "optional": false, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "list", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "pointcloud" + }, + { + "name": "i_assembly", + "nickname": "i_assembly", + "description": "The DFAssembly corresponding to the list of clouds.", + "optional": false, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "ghdoc" + }, + { + "name": "i_reset", + "nickname": "i_reset", + "description": "reset the history of the pose estimation", + "optional": true, + "allowTreeAccess": false, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "bool" + }, + { + "name": "i_save", + "nickname": "i_save", + "description": "save the poses computed at this iteration", + "optional": true, + "allowTreeAccess": false, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "bool" + } + ], + "outputParameters": [ + { + "name": "o_planes", + "nickname": "o_planes", + "description": "The resulting planes of the pose estimation in the last iteration.", + "optional": false, + "sourceCount": 0, + "graft": false + }, + { + "name": "o_history", + "nickname": "o_history", + "description": "The history of poses per elements.", + "optional": false, + "sourceCount": 0, + "graft": false + } + ] + } +} \ No newline at end of file diff --git a/src/gh/components/DF_tcp_listener/code.py b/src/gh/components/DF_tcp_listener/code.py new file mode 100644 index 00000000..8ff40dfc --- /dev/null +++ b/src/gh/components/DF_tcp_listener/code.py @@ -0,0 +1,157 @@ +#! python3 + +from ghpythonlib.componentbase import executingcomponent as component +import socket +import threading +import json +import time +import scriptcontext as sc +import Rhino.Geometry as rg +import System.Drawing as sd +from diffCheck import df_gh_canvas_utils + +class DFTCPListener(component): + def __init__(self): + try: + ghenv.Component.ExpireSolution(True) # noqa: F821 + ghenv.Component.Attributes.PerformLayout() # noqa: F821 + except NameError: + pass + + for idx, label in enumerate(("Start", "Stop", "Load")): + df_gh_canvas_utils.add_button( + ghenv.Component, label, idx, x_offset=60) # noqa: F821 + df_gh_canvas_utils.add_panel(ghenv.Component, "Host", "127.0.0.1", 3, 60, 20) # noqa: F821 + df_gh_canvas_utils.add_panel(ghenv.Component, "Port", "5000", 4, 60, 20) # noqa: F821 + + def RunScript(self, + i_start: bool, + i_stop: bool, + i_load: bool, + i_host: str, + i_port: int): + + prefix = 'tcp' + + # Sticky initialization + sc.sticky.setdefault(f'{prefix}_server_sock', None) + sc.sticky.setdefault(f'{prefix}_server_started', False) + sc.sticky.setdefault(f'{prefix}_cloud_buffer_raw', []) + sc.sticky.setdefault(f'{prefix}_latest_cloud', None) + sc.sticky.setdefault(f'{prefix}_status_message', 'Waiting..') + sc.sticky.setdefault(f'{prefix}_prev_start', False) + sc.sticky.setdefault(f'{prefix}_prev_stop', False) + sc.sticky.setdefault(f'{prefix}_prev_load', False) + + # Client handler + def handle_client(conn: socket.socket) -> None: + """ + Reads the incoming bytes from a single TCP client socket and stores valid data in a shared buffer. + + :param conn: A socket object returned by `accept()` representing a live client connection. + The client is expected to send newline-delimited JSON-encoded data, where each + message is a list of 6D values: [x, y, z, r, g, b]. + + :returns: None + """ + buf = b'' + with conn: + while sc.sticky.get(f'{prefix}_server_started', False): + try: + chunk = conn.recv(4096) + if not chunk: + break + buf += chunk + while b'\n' in buf: + line, buf = buf.split(b'\n', 1) + try: + raw = json.loads(line.decode()) + except Exception: + continue + if isinstance(raw, list) and all(isinstance(pt, list) and len(pt) == 6 for pt in raw): + sc.sticky[f'{prefix}_cloud_buffer_raw'] = raw + except Exception: + break + time.sleep(0.05) # sleep briefly to prevent CPU spin + + # thread to accept incoming connections + def server_loop(sock: socket.socket) -> None: + """ + Accepts a single client connection and starts a background thread to handle it. + + :param sock: A bound and listening TCP socket created by start_server(). + This socket will accept one incoming connection, then delegate it to handle_client(). + + :returns: None. This runs as a background thread and blocks on accept(). + """ + try: + conn, _ = sock.accept() + handle_client(conn) + except Exception: + pass + + # Start TCP server + def start_server() -> None: + """ + creates and binds a TCP socket on the given host/port, marks the server as started and then starts the accept_loop in a background thread + + :returns: None. + """ + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind((i_host, i_port)) + sock.listen(1) + sc.sticky[f'{prefix}_server_sock'] = sock + sc.sticky[f'{prefix}_server_started'] = True + sc.sticky[f'{prefix}_status_message'] = f'Listening on {i_host}:{i_port}' + # Only accept one connection to keep it long-lived + threading.Thread(target=server_loop, args=(sock,), daemon=True).start() + + def stop_server() -> None: + """ + Stops the running TCP server by closing the listening socket and resetting internal state. + + :returns: None. + """ + sock = sc.sticky.get(f'{prefix}_server_sock') + if sock: + try: + sock.close() + except Exception: + pass + sc.sticky[f'{prefix}_server_sock'] = None + sc.sticky[f'{prefix}_server_started'] = False + sc.sticky[f'{prefix}_cloud_buffer_raw'] = [] + sc.sticky[f'{prefix}_status_message'] = 'Stopped' + + # Start or stop server based on inputs + if i_start and not sc.sticky[f'{prefix}_prev_start']: + start_server() + if i_stop and not sc.sticky[f'{prefix}_prev_stop']: + stop_server() + + # Load buffered points into Rhino PointCloud + if i_load and not sc.sticky[f'{prefix}_prev_load']: + if not sc.sticky.get(f'{prefix}_server_started', False): + sc.sticky[f'{prefix}_status_message'] = "Start Server First!" + else: + raw = sc.sticky.get(f'{prefix}_cloud_buffer_raw', []) + if raw: + pc = rg.PointCloud() + for x, y, z, r, g, b in raw: + pc.Add(rg.Point3d(x, y, z), sd.Color.FromArgb(int(r), int(g), int(b))) + sc.sticky[f'{prefix}_latest_cloud'] = pc + sc.sticky[f'{prefix}_status_message'] = f'Loaded pcd with {pc.Count} pts' + else: + sc.sticky[f'{prefix}_status_message'] = 'No data buffered' + + # Update previous states + sc.sticky[f'{prefix}_prev_start'] = i_start + sc.sticky[f'{prefix}_prev_stop'] = i_stop + sc.sticky[f'{prefix}_prev_load'] = i_load + + # Update UI and output + ghenv.Component.Message = sc.sticky[f'{prefix}_status_message'] # noqa: F821 + + o_cloud = sc.sticky[f'{prefix}_latest_cloud'] + return [o_cloud] diff --git a/src/gh/components/DF_tcp_listener/icon.png b/src/gh/components/DF_tcp_listener/icon.png new file mode 100644 index 00000000..f8251581 Binary files /dev/null and b/src/gh/components/DF_tcp_listener/icon.png differ diff --git a/src/gh/components/DF_tcp_listener/metadata.json b/src/gh/components/DF_tcp_listener/metadata.json new file mode 100644 index 00000000..0b13cd90 --- /dev/null +++ b/src/gh/components/DF_tcp_listener/metadata.json @@ -0,0 +1,88 @@ +{ + "name": "DFTCPListener", + "nickname": "TCPIn", + "category": "diffCheck", + "subcategory": "IO", + "description": "This component get point cloud data from a tcp sender", + "exposure": 4, + "instanceGuid": "61a9cc27-864d-4892-bd39-5d97dbccbefb", + "ghpython": { + "hideOutput": true, + "hideInput": true, + "isAdvancedMode": true, + "marshalOutGuids": true, + "iconDisplay": 2, + "inputParameters": [ + { + "name": "i_start", + "nickname": "i_start", + "description": "Button to start the TCP server", + "optional": true, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "bool" + }, + { + "name": "i_stop", + "nickname": "i_stop", + "description": "Button to stop the server and release the port", + "optional": true, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "bool" + }, + { + "name": "i_load", + "nickname": "i_load", + "description": "Button to get the latest PCD from the buffer", + "optional": true, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "bool" + }, + { + "name": "i_host", + "nickname": "i_host", + "description": "The host to use for the connection", + "optional": true, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "str" + }, + { + "name": "i_port", + "nickname": "i_port", + "description": "The port to use for the connection", + "optional": true, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "int" + } + ], + "outputParameters": [ + { + "name": "o_cloud", + "nickname": "o_cloud", + "description": "The Rhino pcd that was received.", + "optional": false, + "sourceCount": 0, + "graft": false + } + ] + } +} \ No newline at end of file diff --git a/src/gh/components/DF_truncate_assembly/code.py b/src/gh/components/DF_truncate_assembly/code.py new file mode 100644 index 00000000..9312b74f --- /dev/null +++ b/src/gh/components/DF_truncate_assembly/code.py @@ -0,0 +1,15 @@ +from ghpythonlib.componentbase import executingcomponent as component + +import diffCheck +import diffCheck.df_geometries + +class DFTruncateAssembly(component): + def RunScript(self, + i_assembly, + i_truncate_index: int): + beams = i_assembly.beams[:i_truncate_index] + name = i_assembly.name + + o_assembly = diffCheck.df_geometries.DFAssembly(name=name, beams=beams) + ghenv.Component.Message = f"number of beams: {len(o_assembly.beams)}" # noqa: F821 + return o_assembly diff --git a/src/gh/components/DF_truncate_assembly/icon.png b/src/gh/components/DF_truncate_assembly/icon.png new file mode 100644 index 00000000..d15d8142 Binary files /dev/null and b/src/gh/components/DF_truncate_assembly/icon.png differ diff --git a/src/gh/components/DF_truncate_assembly/metadata.json b/src/gh/components/DF_truncate_assembly/metadata.json new file mode 100644 index 00000000..ec63631d --- /dev/null +++ b/src/gh/components/DF_truncate_assembly/metadata.json @@ -0,0 +1,52 @@ +{ + "name": "DFTruncateAssembly", + "nickname": "TruncateAssembly", + "category": "diffCheck", + "subcategory": "Structure", + "description": "This component truncates an assembly.", + "exposure": 4, + "instanceGuid": "cf8af97f-dd84-40b6-af44-bf6aca7b941b", + "ghpython": { + "hideOutput": true, + "hideInput": true, + "isAdvancedMode": true, + "marshalOutGuids": true, + "iconDisplay": 2, + "inputParameters": [ + { + "name": "i_assembly", + "nickname": "i_assembly", + "description": "The assembly to be truncated.", + "optional": false, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "ghdoc" + }, + { + "name": "i_truncate_index", + "nickname": "i_truncate_index", + "description": "The index at which to truncate the assembly.", + "optional": false, + "allowTreeAccess": false, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "int" + } + ], + "outputParameters": [ + { + "name": "o_assembly", + "nickname": "o_assembly", + "description": "The resulting assembly after truncation.", + "optional": false, + "sourceCount": 0, + "graft": false + } + ] + } +} \ No newline at end of file diff --git a/src/gh/components/DF_visualization_settings/code.py b/src/gh/components/DF_visualization_settings/code.py index 565f562c..61c2cb98 100644 --- a/src/gh/components/DF_visualization_settings/code.py +++ b/src/gh/components/DF_visualization_settings/code.py @@ -1,124 +1,11 @@ #! python3 -import System -import typing import Rhino from ghpythonlib.componentbase import executingcomponent as component -import Grasshopper as gh -from Grasshopper import Instances from Grasshopper.Kernel import GH_RuntimeMessageLevel as RML from diffCheck import df_visualization - - -def add_str_valuelist(self, - values_list: typing.List[str], - nickname: str, - indx: int, - X_param_coord: float, - Y_param_coord: float, - X_offset: int=87 - ) -> None: - """ - Adds a value list of string values to the component input - - :param values_list: a list of string values to add to the value list - :param nickname: the nickname of the value list - :param indx: the index of the input parameter - :param X_param_coord: the x coordinate of the input parameter - :param Y_param_coord: the y coordinate of the input parameter - :param X_offset: the offset of the value list from the input parameter - """ - param = ghenv.Component.Params.Input[indx] # noqa: F821 - if param.SourceCount == 0: - valuelist = gh.Kernel.Special.GH_ValueList() - valuelist.NickName = nickname - valuelist.Description = "Select the value to use with DFVizSettings" - selected = valuelist.FirstSelectedItem - valuelist.ListItems.Clear() - for v in values_list: - vli = gh.Kernel.Special.GH_ValueListItem(str(v),str('"' + v + '"')) - valuelist.ListItems.Add(vli) - if selected in values_list: - valuelist.SelectItem(values_list.index(selected)) - valuelist.CreateAttributes() - valuelist.Attributes.Pivot = System.Drawing.PointF( - X_param_coord - (valuelist.Attributes.Bounds.Width) - X_offset, - Y_param_coord - (valuelist.Attributes.Bounds.Height / 2 + 0.1) - ) - valuelist.Attributes.ExpireLayout() - gh.Instances.ActiveCanvas.Document.AddObject(valuelist, False) - ghenv.Component.Params.Input[indx].AddSource(valuelist) # noqa: F821 - -def add_slider(self, - nickname: str, - indx: int, - lower_bound: float, - upper_bound: float, - default_value: float, - X_param_coord: float, - Y_param_coord: float, - X_offset: int=100 - ) -> None: - """ - Adds a slider to the component input - - :param nickname: the nickname of the slider - :param indx: the index of the input parameter - :param X_param_coord: the x coordinate of the input parameter - :param Y_param_coord: the y coordinate of the input parameter - :param X_offset: the offset of the slider from the input parameter - """ - param = ghenv.Component.Params.Input[indx] # noqa: F821 - if param.SourceCount == 0: - slider = gh.Kernel.Special.GH_NumberSlider() - slider.NickName = nickname - slider.Description = "Set the value for the threshold" - slider.Slider.Minimum = System.Decimal(lower_bound) - slider.Slider.Maximum = System.Decimal(upper_bound) - slider.Slider.DecimalPlaces = 3 - slider.Slider.SmallChange = System.Decimal(0.001) - slider.Slider.LargeChange = System.Decimal(0.01) - slider.Slider.Value = System.Decimal(default_value) - slider.CreateAttributes() - slider.Attributes.Pivot = System.Drawing.PointF( - X_param_coord - (slider.Attributes.Bounds.Width) - X_offset, - Y_param_coord - (slider.Attributes.Bounds.Height / 2 - 0.1) - ) - slider.Attributes.ExpireLayout() - gh.Instances.ActiveCanvas.Document.AddObject(slider, False) - ghenv.Component.Params.Input[indx].AddSource(slider) # noqa: F821 - -def add_plane_object(self, - nickname: str, - indx: int, - X_param_coord: float, - Y_param_coord: float, - X_offset: int=75 - ) -> None: - """ - Adds a plane object to the component input - - :param nickname: the nickname of the plane object - :param indx: the index of the input parameter - :param X_param_coord: the x coordinate of the input parameter - :param Y_param_coord: the y coordinate of the input parameter - :param X_offset: the offset of the plane object from the input parameter - """ - param = ghenv.Component.Params.Input[indx] # noqa: F821 - if param.SourceCount == 0: - doc = Instances.ActiveCanvas.Document - if doc: - plane = gh.Kernel.Parameters.Param_Plane() - plane.NickName = nickname - plane.CreateAttributes() - plane.Attributes.Pivot = System.Drawing.PointF( - X_param_coord - (plane.Attributes.Bounds.Width) - X_offset, - Y_param_coord - ) - plane.Attributes.ExpireLayout() - doc.AddObject(plane, False) - ghenv.Component.Params.Input[indx].AddSource(plane) # noqa: F821 +from diffCheck import df_gh_canvas_utils class DFVisualizationSettings(component): @@ -129,43 +16,44 @@ def __init__(self): ghenv.Component.ExpireSolution(True) # noqa: F821 ghenv.Component.Attributes.PerformLayout() # noqa: F821 params = getattr(ghenv.Component.Params, "Input") # noqa: F821 + for j in range(len(params)): Y_cord = params[j].Attributes.InputGrip.Y X_cord = params[j].Attributes.Pivot.X input_indx = j if "i_value_type" == params[j].NickName: - add_str_valuelist( + df_gh_canvas_utils.add_str_valuelist( ghenv.Component, # noqa: F821 self.poss_value_types, "DF_value_t", input_indx, X_cord, Y_cord) if "i_palette" == params[j].NickName: - add_str_valuelist( + df_gh_canvas_utils.add_str_valuelist( ghenv.Component, # noqa: F821 self.poss_palettes, "DF_palette", input_indx, X_cord, Y_cord) if "i_legend_height" == params[j].NickName: - add_slider( + df_gh_canvas_utils.add_slider( ghenv.Component, # noqa: F821 "DF_legend_height", input_indx, 0.000, 20.000, 10.000, X_cord, Y_cord) if "i_legend_width" == params[j].NickName: - add_slider( + df_gh_canvas_utils.add_slider( ghenv.Component, # noqa: F821 "DF_legend_width", input_indx, 0.000, 2.000, 0.500, X_cord, Y_cord) if "i_legend_plane" == params[j].NickName: - add_plane_object( + df_gh_canvas_utils.add_plane_object( ghenv.Component, # noqa: F821 "DF_legend_plane", input_indx, X_cord, Y_cord) if "i_histogram_scale_factor" == params[j].NickName: - add_slider( + df_gh_canvas_utils.add_slider( ghenv.Component, # noqa: F821 "DF_histogram_scale_factor", input_indx, diff --git a/src/gh/components/DF_websocket_listener/code.py b/src/gh/components/DF_websocket_listener/code.py new file mode 100644 index 00000000..09b66696 --- /dev/null +++ b/src/gh/components/DF_websocket_listener/code.py @@ -0,0 +1,156 @@ +#! python3 + +from ghpythonlib.componentbase import executingcomponent as component +import threading +import asyncio +import json +import scriptcontext as sc +import Rhino.Geometry as rg +import System.Drawing as sd +from websockets.server import serve +from diffCheck import df_gh_canvas_utils + +class DFWSServerListener(component): + def __init__(self): + try: + ghenv.Component.ExpireSolution(True) # noqa: F821 + ghenv.Component.Attributes.PerformLayout() # noqa: F821 + except NameError: + pass + + for idx, label in enumerate(("Start", "Stop", "Load")): + df_gh_canvas_utils.add_button( + ghenv.Component, label, idx, x_offset=60) # noqa: F821 + df_gh_canvas_utils.add_panel(ghenv.Component, "Host", "127.0.0.1", 3, 60, 20) # noqa: F821 + df_gh_canvas_utils.add_panel(ghenv.Component, "Port", "9000", 4, 60, 20) # noqa: F821 + + def RunScript(self, + i_start: bool, + i_stop: bool, + i_load: bool, + i_host: str, + i_port: int): + + prefix = 'ws' + + # Persistent state across runs + sc.sticky.setdefault(f'{prefix}_server', None) + sc.sticky.setdefault(f'{prefix}_loop', None) + sc.sticky.setdefault(f'{prefix}_thread', None) + sc.sticky.setdefault(f'{prefix}_last_pcd', None) + sc.sticky.setdefault(f'{prefix}_loaded_pcd', None) + sc.sticky.setdefault(f'{prefix}_logs', []) + sc.sticky.setdefault(f'{prefix}_thread_started', False) + sc.sticky.setdefault(f'{prefix}_prev_start', False) + sc.sticky.setdefault(f'{prefix}_prev_stop', False) + sc.sticky.setdefault(f'{prefix}_prev_load', False) + + logs = sc.sticky[f'{prefix}_logs'] + + # STOP server + if i_stop and sc.sticky.pop(f'{prefix}_thread_started', False): + server = sc.sticky.pop(f'{prefix}_server', None) + loop = sc.sticky.pop(f'{prefix}_loop', None) + if server and loop: + try: + server.close() + asyncio.run_coroutine_threadsafe(server.wait_closed(), loop) + logs.append("WebSocket server close initiated") + except Exception as e: + logs.append(f"Error closing server: {e}") + sc.sticky[f'{prefix}_thread'] = None + logs.append("Cleared previous WebSocket server flag") + ghenv.Component.ExpireSolution(True) # noqa: F821 + + # START server + if i_start and not sc.sticky[f'{prefix}_thread_started']: + + async def echo(ws, path: str) -> None: + """ + Handles a single WebSocket client connection and reads messages containing point cloud data. + + :param ws: A WebSocket connection object from the 'websockets' server, representing a live client. + :param path: The URL path for the connection (unused here but required by the API). + + :returns: None. Updates sc.sticky['ws_last_pcd'] with the most recent valid list of points. + Each message is expected to be a JSON list of 6-element lists: + [x, y, z, r, g, b] for each point. + """ + logs.append("[GH] Client connected") + try: + async for msg in ws: + try: + pcd = json.loads(msg) + if isinstance(pcd, list) and all(isinstance(pt, (list, tuple)) and len(pt) == 6 for pt in pcd): + sc.sticky[f'{prefix}_last_pcd'] = pcd + logs.append(f"Received PCD with {len(pcd)} points") + else: + logs.append("Invalid PCD format") + except Exception as inner: + logs.append(f"PCD parse error: {inner}") + except Exception as outer: + logs.append(f"Handler crashed: {outer}") + + async def server_coro() -> None: + """ + Coroutine that starts the WebSocket server and waits for it to be closed. + + :returns: None. Stores the server object in sc.sticky['ws_server'] and the event loop + in sc.sticky['ws_loop']. Also logs progress to sc.sticky['ws_logs']. + """ + loop = asyncio.get_running_loop() + sc.sticky[f'{prefix}_loop'] = loop + + logs.append(f"server_coro starting on {i_host}:{i_port}") + server = await serve(echo, i_host, i_port) + sc.sticky[f'{prefix}_server'] = server + logs.append(f"Listening on ws://{i_host}:{i_port}") + await server.wait_closed() + logs.append("Server coroutine exited") + + def run_server() -> None: + """ + Blocking function that runs the WebSocket server coroutine in this thread. + + :returns: None. Used as the target for a background thread. Logs errors if server startup fails. + """ + try: + asyncio.run(server_coro()) + except Exception as ex: + logs.append(f"WebSocket server ERROR: {ex}") + + t = threading.Thread(target=run_server, daemon=True) + t.start() + sc.sticky[f'{prefix}_thread'] = t + sc.sticky[f'{prefix}_thread_started'] = True + ghenv.Component.ExpireSolution(True) # noqa: F821 + + # LOAD buffered PCD on i_load rising edge + if i_load and not sc.sticky[f'{prefix}_prev_load']: + if not sc.sticky.get(f'{prefix}_server'): + logs.append("Start Server First!") + else: + sc.sticky[f'{prefix}_loaded_pcd'] = sc.sticky.get(f'{prefix}_last_pcd') + cnt = len(sc.sticky[f'{prefix}_loaded_pcd']) if sc.sticky[f'{prefix}_loaded_pcd'] else 0 + logs.append(f"Loaded pcd with {cnt} pts") + ghenv.Component.ExpireSolution(True) # noqa: F821 + + # BUILD output PointCloud + raw = sc.sticky.get(f'{prefix}_loaded_pcd') + if isinstance(raw, list) and all(isinstance(pt, (list, tuple)) and len(pt) == 6 for pt in raw): + pc = rg.PointCloud() + for x, y, z, r, g, b in raw: + pt = rg.Point3d(x, y, z) + col = sd.Color.FromArgb(r, g, b) + pc.Add(pt, col) + o_cloud = pc + else: + o_cloud = None + + # UPDATE UI message & return outputs + ghenv.Component.Message = logs[-1] if logs else 'Waiting..' # noqa: F821 + sc.sticky[f'{prefix}_prev_start'] = i_start + sc.sticky[f'{prefix}_prev_stop'] = i_stop + sc.sticky[f'{prefix}_prev_load'] = i_load + + return [o_cloud] diff --git a/src/gh/components/DF_websocket_listener/icon.png b/src/gh/components/DF_websocket_listener/icon.png new file mode 100644 index 00000000..8a2268ef Binary files /dev/null and b/src/gh/components/DF_websocket_listener/icon.png differ diff --git a/src/gh/components/DF_websocket_listener/metadata.json b/src/gh/components/DF_websocket_listener/metadata.json new file mode 100644 index 00000000..ce4707e7 --- /dev/null +++ b/src/gh/components/DF_websocket_listener/metadata.json @@ -0,0 +1,88 @@ +{ + "name": "DFWSListener", + "nickname": "WSIn", + "category": "diffCheck", + "subcategory": "IO", + "description": "This component receives a pcd via websocket connection.", + "exposure": 4, + "instanceGuid": "4e87cc43-8f9f-4f8f-a63a-49f76229db3e", + "ghpython": { + "hideOutput": true, + "hideInput": true, + "isAdvancedMode": true, + "marshalOutGuids": true, + "iconDisplay": 2, + "inputParameters": [ + { + "name": "i_start", + "nickname": "i_start", + "description": "Button to start the TCP server", + "optional": true, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "bool" + }, + { + "name": "i_stop", + "nickname": "i_stop", + "description": "Stop the server and release the port", + "optional": true, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "bool" + }, + { + "name": "i_load", + "nickname": "i_load", + "description": "Button to get the latest PCD from the buffer", + "optional": true, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "bool" + }, + { + "name": "i_host", + "nickname": "i_host", + "description": "The host for the connection", + "optional": true, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "str" + }, + { + "name": "i_port", + "nickname": "i_port", + "description": "The port to use for the connection", + "optional": true, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "int" + } + ], + "outputParameters": [ + { + "name": "o_cloud", + "nickname": "o_cloud", + "description": "The pcd that was received.", + "optional": false, + "sourceCount": 0, + "graft": false + } + ] + } +} \ No newline at end of file diff --git a/src/gh/diffCheck/diffCheck.egg-info/PKG-INFO b/src/gh/diffCheck/diffCheck.egg-info/PKG-INFO index f88b43ec..78a74a86 100644 --- a/src/gh/diffCheck/diffCheck.egg-info/PKG-INFO +++ b/src/gh/diffCheck/diffCheck.egg-info/PKG-INFO @@ -1,4 +1,4 @@ -Metadata-Version: 2.4 +Metadata-Version: 2.1 Name: diffCheck Version: 1.3.0 Summary: DiffCheck is a package to check the differences between two timber structures @@ -11,6 +11,7 @@ Classifier: Programming Language :: Python :: 3.9 Description-Content-Type: text/markdown Requires-Dist: numpy Requires-Dist: pybind11>=2.5.0 +Requires-Dist: websockets>=10.4 Dynamic: author Dynamic: author-email Dynamic: classifier diff --git a/src/gh/diffCheck/diffCheck.egg-info/SOURCES.txt b/src/gh/diffCheck/diffCheck.egg-info/SOURCES.txt index 8887cb03..e64d5fc2 100644 --- a/src/gh/diffCheck/diffCheck.egg-info/SOURCES.txt +++ b/src/gh/diffCheck/diffCheck.egg-info/SOURCES.txt @@ -9,7 +9,7 @@ diffCheck/df_joint_detector.py diffCheck/df_transformations.py diffCheck/df_util.py diffCheck/df_visualization.py -diffCheck/diffcheck_bindings.cp312-win_amd64.pyd +diffCheck/diffcheck_bindings.cp39-win_amd64.pyd diffCheck.egg-info/PKG-INFO diffCheck.egg-info/SOURCES.txt diffCheck.egg-info/dependency_links.txt diff --git a/src/gh/diffCheck/diffCheck.egg-info/requires.txt b/src/gh/diffCheck/diffCheck.egg-info/requires.txt index b2195e0b..15579520 100644 --- a/src/gh/diffCheck/diffCheck.egg-info/requires.txt +++ b/src/gh/diffCheck/diffCheck.egg-info/requires.txt @@ -1,2 +1,3 @@ numpy pybind11>=2.5.0 +websockets>=10.4 diff --git a/src/gh/diffCheck/diffCheck/df_geometries.py b/src/gh/diffCheck/diffCheck/df_geometries.py index 821a0849..541efcd5 100644 --- a/src/gh/diffCheck/diffCheck/df_geometries.py +++ b/src/gh/diffCheck/diffCheck/df_geometries.py @@ -101,6 +101,7 @@ def __post_init__(self): self._center: DFVertex = None # the normal of the face self._normal: typing.List[float] = None + self._area: float = None def __getstate__(self): state = self.__dict__.copy() @@ -261,6 +262,12 @@ def normal(self): self._normal = [normal_rg.X, normal_rg.Y, normal_rg.Z] return self._normal + @property + def area(self): + if self._area is None: + self._area = self.to_brep_face().ToBrep().GetArea() + return self._area + @dataclass class DFJoint: """ @@ -375,6 +382,7 @@ def __post_init__(self): self._center: rg.Point3d = None self._axis: rg.Line = self.compute_axis() + self.plane: rg.Plane = self.compute_plane() self._length: float = self._axis.Length self.__uuid = uuid.uuid4().int @@ -506,6 +514,28 @@ def compute_axis(self, is_unitized: bool = True) -> rg.Line: return axis_ln + def compute_plane(self) -> rg.Plane: + """ + This function computes the plane of the beam based on its axis and the first joint's center. + The plane is oriented along the beam's axis. + + :return plane: The plane of the beam + """ + if not self.joints: + raise ValueError("The beam has no joints to compute a plane") + + #main axis as defined above + main_direction = self.compute_axis().Direction + + #secondary axis as normal to the largest face of the beam + largest_face = max(self.faces, key=lambda f: f.area) + secondary_axis = largest_face.normal + secondary_vector = rg.Vector3d(secondary_axis[0], secondary_axis[1], secondary_axis[2]) + first_vector = rg.Vector3d.CrossProduct(main_direction, secondary_vector) + origin = self.center + + return rg.Plane(origin, first_vector, secondary_vector) + def compute_joint_distances_to_midpoint(self) -> typing.List[float]: """ This function computes the distances from the center of the beam to each joint. diff --git a/src/gh/diffCheck/diffCheck/df_gh_canvas_utils.py b/src/gh/diffCheck/diffCheck/df_gh_canvas_utils.py new file mode 100644 index 00000000..9a46bcd3 --- /dev/null +++ b/src/gh/diffCheck/diffCheck/df_gh_canvas_utils.py @@ -0,0 +1,202 @@ +from Grasshopper import Instances +import Grasshopper as gh +import System.Drawing as sd +import System +import typing + + +def add_str_valuelist(comp, + values_list: typing.List[str], + nickname: str, + indx: int, + X_param_coord: float, + Y_param_coord: float, + X_offset: int = 87 + ) -> None: + """ + Adds a value list of string values to the component input + + :param values_list: a list of string values to add to the value list + :param nickname: the nickname of the value list + :param indx: the index of the input parameter + :param X_param_coord: the x coordinate of the input parameter + :param Y_param_coord: the y coordinate of the input parameter + :param X_offset: the offset of the value list from the input parameter + """ + inp = comp.Params.Input[indx] # noqa: F821 + if inp.SourceCount == 0: + valuelist = gh.Kernel.Special.GH_ValueList() + valuelist.NickName = nickname + valuelist.Description = "Select the value to use with DFVizSettings" + selected = valuelist.FirstSelectedItem + valuelist.ListItems.Clear() + for v in values_list: + vli = gh.Kernel.Special.GH_ValueListItem(str(v), str('"' + v + '"')) + valuelist.ListItems.Add(vli) + if selected in values_list: + valuelist.SelectItem(values_list.index(selected)) + valuelist.CreateAttributes() + valuelist.Attributes.Pivot = System.Drawing.PointF( + X_param_coord - (valuelist.Attributes.Bounds.Width) - X_offset, + Y_param_coord - (valuelist.Attributes.Bounds.Height / 2 + 0.1) + ) + valuelist.Attributes.ExpireLayout() + gh.Instances.ActiveCanvas.Document.AddObject(valuelist, False) + inp.AddSource(valuelist) # noqa: F821 + + +def add_slider(comp, + nickname: str, + indx: int, + lower_bound: float, + upper_bound: float, + default_value: float, + X_param_coord: float, + Y_param_coord: float, + X_offset: int = 100 + ) -> None: + """ + Adds a slider to the component input + + :param nickname: the nickname of the slider + :param indx: the index of the input parameter + :param X_param_coord: the x coordinate of the input parameter + :param Y_param_coord: the y coordinate of the input parameter + :param X_offset: the offset of the slider from the input parameter + """ + inp = comp.Params.Input[indx] # noqa: F821 + if inp.SourceCount == 0: + slider = gh.Kernel.Special.GH_NumberSlider() + slider.NickName = nickname + slider.Description = "Set the value for the threshold" + slider.Slider.Minimum = System.Decimal(lower_bound) + slider.Slider.Maximum = System.Decimal(upper_bound) + slider.Slider.DecimalPlaces = 3 + slider.Slider.SmallChange = System.Decimal(0.001) + slider.Slider.LargeChange = System.Decimal(0.01) + slider.Slider.Value = System.Decimal(default_value) + slider.CreateAttributes() + slider.Attributes.Pivot = System.Drawing.PointF( + X_param_coord - (slider.Attributes.Bounds.Width) - X_offset, + Y_param_coord - (slider.Attributes.Bounds.Height / 2 - 0.1) + ) + slider.Attributes.ExpireLayout() + gh.Instances.ActiveCanvas.Document.AddObject(slider, False) + inp.AddSource(slider) # noqa: F821 + + +def add_plane_object(comp, + nickname: str, + indx: int, + X_param_coord: float, + Y_param_coord: float, + X_offset: int = 75 + ) -> None: + """ + Adds a plane object to the component input + + :param nickname: the nickname of the plane object + :param indx: the index of the input parameter + :param X_param_coord: the x coordinate of the input parameter + :param Y_param_coord: the y coordinate of the input parameter + :param X_offset: the offset of the plane object from the input parameter + """ + inp = comp.Params.Input[indx] # noqa: F821 + if inp.SourceCount == 0: + doc = Instances.ActiveCanvas.Document + if doc: + plane = gh.Kernel.Parameters.Param_Plane() + plane.NickName = nickname + plane.CreateAttributes() + plane.Attributes.Pivot = System.Drawing.PointF( + X_param_coord - (plane.Attributes.Bounds.Width) - X_offset, + Y_param_coord + ) + plane.Attributes.ExpireLayout() + doc.AddObject(plane, False) + inp.AddSource(plane) # noqa: F821 + + +def add_button(comp, + nickname: str, + indx: int, + x_offset: int = 60 + ) -> None: + """ + Adds a one-shot Boolean button to the left of a component input. + + :param comp: The Grasshopper component to which the button will be added. + :param nickname: The display label of the button (e.g. "Start", "Load"). + :param indx: The index of the component input to wire the button into. + :param x_offset: Horizontal distance (in pixels) to place the button to the left of the input. + """ + + inp = comp.Params.Input[indx] + # only add if nothing already connected + if inp.SourceCount == 0: + # create the one-shot button + btn = gh.Kernel.Special.GH_ButtonObject() + btn.NickName = nickname + btn.Value = False # always starts False + # build its UI attributes so we can measure size & position + btn.CreateAttributes() + + # compute pivot: left of the input grip + grip = inp.Attributes.InputGrip + # X = input pivot X, Y = grip Y + pivot_x = grip.X - btn.Attributes.Bounds.Width - x_offset + pivot_y = grip.Y - btn.Attributes.Bounds.Height/2 + btn.Attributes.Pivot = sd.PointF(pivot_x, pivot_y) + btn.Attributes.ExpireLayout() + + # drop it onto the canvas (non-grouped) + Instances.ActiveCanvas.Document.AddObject(btn, False) + # wire it into the component + inp.AddSource(btn) + + +def add_panel(comp, + nickname: str, + text: str, + indx: int, + x_offset: int = 60, + panel_height: int = 20 + ) -> None: + """ + Adds a text panel to the left of a component input with a default string value. + + :param comp: The Grasshopper component to which the panel will be added. + :param nickname: The label shown at the top of the panel (e.g. "Host", "Port"). + :param text: The default string to display inside the panel. + :param indx: The index of the component input to connect the panel to. + :param x_offset: Horizontal distance (in pixels) to place the panel left of the input. + :param panel_height: Height of the panel in pixels (default is 20). + + :returns: None. The panel is created, positioned, and connected if no existing source is present. + """ + + inp = comp.Params.Input[indx] + if inp.SourceCount == 0: + panel = gh.Kernel.Special.GH_Panel() + # Set the panel's displayed text + panel.UserText = text + panel.NickName = nickname + panel.CreateAttributes() + + # adjust height while preserving width + bounds = panel.Attributes.Bounds + panel.Attributes.Bounds = System.Drawing.RectangleF( + bounds.X, + bounds.Y, + bounds.Width, + panel_height + ) + + # Position left of input grip + grip = inp.Attributes.InputGrip + px = grip.X - panel.Attributes.Bounds.Width - x_offset + py = grip.Y - panel.Attributes.Bounds.Height / 2 + panel.Attributes.Pivot = sd.PointF(px, py) + panel.Attributes.ExpireLayout() + Instances.ActiveCanvas.Document.AddObject(panel, False) + inp.AddSource(panel) diff --git a/src/gh/diffCheck/diffCheck/df_poses.py b/src/gh/diffCheck/diffCheck/df_poses.py new file mode 100644 index 00000000..adaeee16 --- /dev/null +++ b/src/gh/diffCheck/diffCheck/df_poses.py @@ -0,0 +1,152 @@ +from scriptcontext import sticky as rh_sticky_dict +import ghpythonlib.treehelpers as th +import Rhino + +import json +from dataclasses import dataclass, field + +# use a key and not all the sticky +_STICKY_KEY = "df_poses" + +def _get_store(): + # returns private sub-dict inside rhino sticky + return rh_sticky_dict.setdefault(_STICKY_KEY, {}) + +@dataclass +class DFPose: + """ + This class represents the pose of a single element at a given time in the assembly process. + """ + origin: list + xDirection: list + yDirection: list + + def to_rh_plane(self): + """ + Convert the pose to a Rhino Plane object. + """ + origin = Rhino.Geometry.Point3d(self.origin[0], self.origin[1], self.origin[2]) + xDirection = Rhino.Geometry.Vector3d(self.xDirection[0], self.xDirection[1], self.xDirection[2]) + yDirection = Rhino.Geometry.Vector3d(self.yDirection[0], self.yDirection[1], self.yDirection[2]) + return Rhino.Geometry.Plane(origin, xDirection, yDirection) + +@dataclass +class DFPosesBeam: + """ + This class contains the poses of a single beam, at different times in the assembly process. + It also contains the number of faces detected for this element, based on which the poses are calculated. + """ + poses_dictionary: dict + n_faces: int = 3 + + def add_pose(self, pose: DFPose, step_number: int): + """ + Add a pose to the dictionary of poses. + """ + self.poses_dictionary[f"pose_{step_number}"] = pose + + def set_n_faces(self, n_faces: int): + """ + Set the number of faces detected for this element. + """ + self.n_faces = n_faces + +@dataclass +class DFPosesAssembly: + n_step: int = 0 + poses_per_element_dictionary: dict = field(default_factory=_get_store) + + """ + This class contains the poses of the different elements of the assembly, at different times in the assembly process. + """ + def __post_init__(self): + """ + Initialize the poses_per_element_dictionary with empty DFPosesBeam objects. + """ + lengths = [] + for element in self.poses_per_element_dictionary: + lengths.append(len(self.poses_per_element_dictionary[element].poses_dictionary)) + self.n_step = max(lengths) if lengths else 0 + + def add_step(self, new_poses: list[DFPose]): + for i, pose in enumerate(new_poses): + if f"element_{i}" not in self.poses_per_element_dictionary: + self.poses_per_element_dictionary[f"element_{i}"] = DFPosesBeam({}, 4) + for j in range(self.n_step): + self.poses_per_element_dictionary[f"element_{i}"].add_pose(None, j) + self.poses_per_element_dictionary[f"element_{i}"].add_pose(pose, self.n_step) + self.n_step += 1 + + def get_last_poses(self): + """ + Get the last poses of each element. + """ + if self.n_step == 0: + return None + last_poses = [] + for i in range(len(self.poses_per_element_dictionary)): + last_poses.append(self.poses_per_element_dictionary[f"element_{i}"].poses_dictionary[f"pose_{self.n_step-1}"]) + return last_poses + + def reset(self): + """ + Reset the assembly poses to the initial state. + """ + self.n_step = 0 + # clear only namespace + rh_sticky_dict[_STICKY_KEY] = {} + # refresh the local reference to the (now empty) store + self.poses_per_element_dictionary = _get_store() + + def save(self, file_path: str): + """ + Save the assembly poses to a JSON file. + """ + with open(file_path, 'w') as f: + json.dump(self.poses_per_element_dictionary, f, default=lambda o: o.__dict__, indent=4) + + def to_gh_tree(self): + """ + Convert the assembly poses to a Grasshopper tree structure. + """ + list_of_poses = [] + for element, poses in self.poses_per_element_dictionary.items(): + list_of_pose_of_element = [] + for pose in poses.poses_dictionary.values(): + list_of_pose_of_element.append(pose.to_rh_plane() if pose is not None else None) + list_of_poses.append(list_of_pose_of_element) + return th.list_to_tree(list_of_poses) + + +def compute_dot_product(v1, v2): + """ + Compute the dot product of two vectors. + """ + return (v1.X * v2.X) + (v1.Y * v2.Y) + (v1.Z * v2.Z) + + +def select_vectors(vectors, previous_xDirection, previous_yDirection): + """ + Select the vectors that are aligned with the xDirection and yDirection. + """ + if previous_xDirection is not None and previous_yDirection is not None: + sorted_vectors_by_alignment = sorted(vectors, key=lambda v: compute_dot_product(v, previous_xDirection), reverse=True) + new_xDirection = sorted_vectors_by_alignment[0] + else: + new_xDirection = vectors[0] + + condidates_for_yDirection = [] + for v in vectors: + if compute_dot_product(v, new_xDirection) ** 2 < 0.5: + condidates_for_yDirection.append(v) + if previous_xDirection is not None and previous_yDirection is not None: + sorted_vectors_by_perpendicularity = sorted(condidates_for_yDirection, key=lambda v: compute_dot_product(v, previous_yDirection), reverse=True) + new_xDirection = sorted_vectors_by_alignment[0] + new_yDirection = sorted_vectors_by_perpendicularity[0] - compute_dot_product(sorted_vectors_by_perpendicularity[0], new_xDirection) * new_xDirection + new_yDirection.Unitize() + else: + + sorted_vectors = sorted(vectors[1:], key=lambda v: compute_dot_product(v, new_xDirection)**2) + new_yDirection = sorted_vectors[0] - compute_dot_product(sorted_vectors[0], new_xDirection) * new_xDirection + new_yDirection.Unitize() + return new_xDirection, new_yDirection diff --git a/src/gh/diffCheck/setup.py b/src/gh/diffCheck/setup.py index 181bbb0c..bbb48856 100644 --- a/src/gh/diffCheck/setup.py +++ b/src/gh/diffCheck/setup.py @@ -8,7 +8,8 @@ packages=find_packages(), install_requires=[ "numpy", - "pybind11>=2.5.0" + "pybind11>=2.5.0", + "websockets>=10.4" # other dependencies... ], description="DiffCheck is a package to check the differences between two timber structures", diff --git a/src/gh/examples/simple_tcp_sender.py b/src/gh/examples/simple_tcp_sender.py new file mode 100644 index 00000000..348d96aa --- /dev/null +++ b/src/gh/examples/simple_tcp_sender.py @@ -0,0 +1,23 @@ +import socket +import time +import random +import json + +host = '127.0.0.1' +port = 5000 + + +def random_colored_point(): + x, y, z = [round(random.uniform(-10, 10), 2) for _ in range(3)] + r, g, b = [random.randint(0, 255) for _ in range(3)] + return [x, y, z, r, g, b] + + +with socket.create_connection((host, port)) as s: + print("Connected to GH") + while True: + cloud = [random_colored_point() for _ in range(1000)] + msg = json.dumps(cloud) + "\n" + s.sendall(msg.encode()) + print("Sent cloud with", len(cloud), "colored points") + time.sleep(1) diff --git a/src/gh/examples/simple_ws_sender.py b/src/gh/examples/simple_ws_sender.py new file mode 100644 index 00000000..edf1cb40 --- /dev/null +++ b/src/gh/examples/simple_ws_sender.py @@ -0,0 +1,31 @@ +import asyncio +import websockets +import random +import json + + +def random_colored_point(): + x, y, z = [round(random.uniform(-10, 10), 2) for _ in range(3)] + r, g, b = [random.randint(0, 255) for _ in range(3)] + return [x, y, z, r, g, b] + + +async def send_pointcloud(host="127.0.0.1", port=9000): + uri = f"ws://{host}:{port}" + print(f"Connecting to {uri}…") + try: + async with websockets.connect(uri) as ws: + counter = 0 + while True: + counter += 1 + # generate and send 1 000 random points + pcd = [random_colored_point() for _ in range(1000)] + await ws.send(json.dumps(pcd)) + print(f"[{counter}] Sent PCD with {len(pcd)} points") + await asyncio.sleep(5) + + except Exception as e: + print(f"Connection error: {e}") + +if __name__ == "__main__": + asyncio.run(send_pointcloud(host="127.0.0.1", port=9000)) diff --git a/tests/unit_tests/DFPointCloudTest.cc b/tests/unit_tests/DFPointCloudTest.cc index d6d669d5..72b5a9db 100644 --- a/tests/unit_tests/DFPointCloudTest.cc +++ b/tests/unit_tests/DFPointCloudTest.cc @@ -219,4 +219,12 @@ TEST_F(DFPointCloudTestFixture, Transform) { //------------------------------------------------------------------------- // Others -//------------------------------------------------------------------------- \ No newline at end of file +//------------------------------------------------------------------------- + +TEST_F(DFPointCloudTestFixture, KMeansClusteringOfNormals) { + std::string path = diffCheck::io::GetTwoConnectedPlanesPlyPath(); + diffCheck::geometry::DFPointCloud dfPointCloud2Planes; + dfPointCloud2Planes.LoadFromPLY(path); + std::vector axes = dfPointCloud2Planes.GetPrincipalAxes(2); + EXPECT_TRUE((axes[0] - Eigen::Vector3d(0, 0, 1)).norm() < 1e-2 || (axes[1] - Eigen::Vector3d(0, 0, 1)).norm() < 1e-2); +} \ No newline at end of file