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..954e2115 160000 --- a/deps/eigen +++ b/deps/eigen @@ -1 +1 @@ -Subproject commit 11fd34cc1c398f2c2311339ed3b008b1114544eb +Subproject commit 954e21152e204b1960aca802eb9f16d054d70fd9 diff --git a/deps/pybind11 b/deps/pybind11 index 708ce4d9..23c59b6e 160000 --- a/deps/pybind11 +++ b/deps/pybind11 @@ -1 +1 @@ -Subproject commit 708ce4d9c7bf55075608eb3cfcb5fa0dc43e070f +Subproject commit 23c59b6e3d3535949bcb30b24de4fefdcded44d9 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..5324f3f6 100644 --- a/src/diffCheck/geometry/DFPointCloud.cc +++ b/src/diffCheck/geometry/DFPointCloud.cc @@ -216,6 +216,61 @@ namespace diffCheck::geometry this->Normals.push_back(normal); } + Eigen::Vector3d DFPointCloud::FitPlaneRANSAC( + double distanceThreshold, + int ransacN, + int numIterations) + { + if (this->Points.size() < ransacN) + { + DIFFCHECK_ERROR("Not enough points to fit a plane with RANSAC."); + return Eigen::Vector3d::Zero(); + } + + auto O3DPointCloud = this->Cvt2O3DPointCloud(); + std::tuple< Eigen::Vector4d, std::vector> planeModel = O3DPointCloud->SegmentPlane(distanceThreshold, ransacN, numIterations); + Eigen::Vector3d planeParameters = std::get<0>(planeModel).head<3>(); + return planeParameters; + } + + 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 +313,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..fb1367fa 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,40 @@ namespace diffCheck::geometry */ void RemoveStatisticalOutliers(int nbNeighbors, double stdRatio); + /** + * @brief Fit a plane to the point cloud using RANSAC + * + * @param distanceThreshold the distance threshold to consider a point as an inlier + * @param ransacN the number of points to sample for each RANSAC iteration + * @param numIterations the number of RANSAC iterations + * @return The Normal vector of the fitted plane as an Eigen::Vector3d + */ + Eigen::Vector3d FitPlaneRANSAC( + double distanceThreshold = 0.01, + int ransacN = 3, + int numIterations = 100); + + /** + * @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 +172,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/diffCheck/segmentation/DFSegmentation.cc b/src/diffCheck/segmentation/DFSegmentation.cc index 4bbec40d..d84ef254 100644 --- a/src/diffCheck/segmentation/DFSegmentation.cc +++ b/src/diffCheck/segmentation/DFSegmentation.cc @@ -330,7 +330,7 @@ namespace diffCheck::segmentation void DFSegmentation::CleanUnassociatedClusters( bool isCylinder, std::vector> &unassociatedClusters, - std::vector> &existingPointCloudSegments, + std::vector>> &existingPointCloudSegments, std::vector>> meshes, double angleThreshold, double associationThreshold) @@ -459,12 +459,12 @@ namespace diffCheck::segmentation DIFFCHECK_WARN("No mesh face found for the cluster. Skipping the cluster."); continue; } - if (goodMeshIndex >= existingPointCloudSegments.size()) + if (goodMeshIndex >= existingPointCloudSegments.size() || goodFaceIndex >= existingPointCloudSegments[goodMeshIndex].size()) { DIFFCHECK_WARN("No segment found for the face. Skipping the face."); continue; } - std::shared_ptr completed_segment = existingPointCloudSegments[goodMeshIndex]; + std::shared_ptr completed_segment = existingPointCloudSegments[goodMeshIndex][goodFaceIndex]; for (Eigen::Vector3d point : cluster->Points) { diff --git a/src/diffCheck/segmentation/DFSegmentation.hh b/src/diffCheck/segmentation/DFSegmentation.hh index f88b3e71..38a0a487 100644 --- a/src/diffCheck/segmentation/DFSegmentation.hh +++ b/src/diffCheck/segmentation/DFSegmentation.hh @@ -44,7 +44,7 @@ namespace diffCheck::segmentation /** @brief Iterated through clusters and finds the corresponding mesh face. It then associates the points of the cluster that are on the mesh face to the segment already associated with the mesh face. * @param isCylinder a boolean to indicate if the model is a cylinder. If true, the method will use the GetCenterAndAxis method of the mesh to find the center and axis of the mesh. based on that, we only want points that have normals more or less perpendicular to the cylinder axis. * @param unassociatedClusters the clusters from the normal-based segmentatinon that haven't been associated yet. - * @param existingPointCloudSegments the already associated segments + * @param existingPointCloudSegments the already associated segments per mesh face. * @param meshes the mesh faces for all the model. This is used to associate the clusters to the mesh faces. * * @param angleThreshold the threshold to consider the a cluster as potential candidate for association. the value passed is the minimum sine of the angles. A value of 0 requires perfect alignment (angle = 0), while a value of 0.1 allows an angle of 5.7 degrees. * @param associationThreshold the threshold to consider the points of a segment and a mesh face as associable. It is the ratio between the surface of the closest mesh triangle and the sum of the areas of the three triangles that form the rest of the pyramid described by the mesh triangle and the point we want to associate or not. The lower the number, the more strict the association will be and some poinnts on the mesh face might be wrongfully excluded. @@ -52,7 +52,7 @@ namespace diffCheck::segmentation static void DFSegmentation::CleanUnassociatedClusters( bool isCylinder, std::vector> &unassociatedClusters, - std::vector> &existingPointCloudSegments, + std::vector>> &existingPointCloudSegments, std::vector>> meshes, double angleThreshold = 0.1, double associationThreshold = 0.1); diff --git a/src/diffCheckBindings.cc b/src/diffCheckBindings.cc index 434f5da2..25dab20e 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,23 @@ PYBIND11_MODULE(diffcheck_bindings, m) { .def("remove_statistical_outliers", &diffCheck::geometry::DFPointCloud::RemoveStatisticalOutliers, py::arg("nb_neighbors"), py::arg("std_ratio")) + .def("fit_plane_ransac", &diffCheck::geometry::DFPointCloud::FitPlaneRANSAC, + py::arg("distance_threshold") = 0.01, + py::arg("ransac_n") = 3, + py::arg("num_iterations") = 100) + + .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_CAD_segmentator/code.py b/src/gh/components/DF_CAD_segmentator/code.py index f2ae9f9b..5a561c33 100644 --- a/src/gh/components/DF_CAD_segmentator/code.py +++ b/src/gh/components/DF_CAD_segmentator/code.py @@ -5,6 +5,7 @@ import Rhino from ghpythonlib.componentbase import executingcomponent as component from Grasshopper.Kernel import GH_RuntimeMessageLevel as RML +import ghpythonlib.treehelpers as th from diffCheck.diffcheck_bindings import dfb_segmentation @@ -19,7 +20,7 @@ def RunScript(self, i_clouds: System.Collections.Generic.IList[Rhino.Geometry.PointCloud], i_assembly, i_angle_threshold: float = 0.1, - i_association_threshold: float = 0.1) -> Rhino.Geometry.PointCloud: + i_association_threshold: float = 0.1): if i_clouds is None or i_assembly is None: self.AddRuntimeMessage(RML.Warning, "Please provide a cloud and an assembly to segment.") @@ -29,20 +30,18 @@ def RunScript(self, if i_association_threshold is None: i_association_threshold = 0.1 - o_clusters = [] + o_face_clusters = [] df_clusters = [] # we make a deepcopy of the input clouds df_clouds = [df_cvt_bindings.cvt_rhcloud_2_dfcloud(cloud.Duplicate()) for cloud in i_clouds] df_beams = i_assembly.beams - df_beams_meshes = [] - rh_beams_meshes = [] for df_b in df_beams: + o_face_clusters.append([]) + rh_b_mesh_faces = [df_b_f.to_mesh() for df_b_f in df_b.side_faces] df_b_mesh_faces = [df_cvt_bindings.cvt_rhmesh_2_dfmesh(rh_b_mesh_face) for rh_b_mesh_face in rh_b_mesh_faces] - df_beams_meshes.append(df_b_mesh_faces) - rh_beams_meshes.append(rh_b_mesh_faces) # different association depending on the type of beam df_asssociated_cluster_faces = dfb_segmentation.DFSegmentation.associate_clusters( @@ -53,27 +52,30 @@ def RunScript(self, association_threshold=i_association_threshold ) - df_asssociated_cluster = dfb_geometry.DFPointCloud() - for df_associated_face in df_asssociated_cluster_faces: - df_asssociated_cluster.add_points(df_associated_face) - dfb_segmentation.DFSegmentation.clean_unassociated_clusters( is_roundwood=df_b.is_roundwood, unassociated_clusters=df_clouds, - associated_clusters=[df_asssociated_cluster], + associated_clusters=[df_asssociated_cluster_faces], reference_mesh=[df_b_mesh_faces], angle_threshold=i_angle_threshold, association_threshold=i_association_threshold ) + o_face_clusters[-1] = [df_cvt_bindings.cvt_dfcloud_2_rhcloud(cluster) for cluster in df_asssociated_cluster_faces] + + df_asssociated_cluster = dfb_geometry.DFPointCloud() + for df_associated_face in df_asssociated_cluster_faces: + df_asssociated_cluster.add_points(df_associated_face) + df_clusters.append(df_asssociated_cluster) - o_clusters = [df_cvt_bindings.cvt_dfcloud_2_rhcloud(cluster) for cluster in df_clusters] + o_beam_clouds = [df_cvt_bindings.cvt_dfcloud_2_rhcloud(cluster) for cluster in df_clusters] - for o_cluster in o_clusters: - if not o_cluster.IsValid: - o_cluster = None + for i, o_beam_cloud in enumerate(o_beam_clouds): + if not o_beam_cloud.IsValid: + o_beam_clouds[i] = None ghenv.Component.AddRuntimeMessage(RML.Warning, "Some beams could not be segmented and were replaced by 'None'") # noqa: F821 + o_face_clouds = th.list_to_tree(o_face_clusters) - return o_clusters + return [o_beam_clouds, o_face_clouds] diff --git a/src/gh/components/DF_CAD_segmentator/metadata.json b/src/gh/components/DF_CAD_segmentator/metadata.json index 415dc571..087ade1c 100644 --- a/src/gh/components/DF_CAD_segmentator/metadata.json +++ b/src/gh/components/DF_CAD_segmentator/metadata.json @@ -64,9 +64,17 @@ ], "outputParameters": [ { - "name": "o_clusters", - "nickname": "o_clusters", - "description": "The clouds associated to each beam.", + "name": "o_beam_clouds", + "nickname": "o_beam_clouds", + "description": "The merged clouds associated to each beam.", + "optional": false, + "sourceCount": 0, + "graft": false + }, + { + "name": "o_face_clouds", + "nickname": "o_face_clouds", + "description": "The datatree of clouds associated to each face.", "optional": false, "sourceCount": 0, "graft": false 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_comparison/code.py b/src/gh/components/DF_pose_comparison/code.py new file mode 100644 index 00000000..267afd9d --- /dev/null +++ b/src/gh/components/DF_pose_comparison/code.py @@ -0,0 +1,73 @@ +"""Compares CAD poses with measured poses to compute errors.""" +#! python3 + +import Rhino +import Grasshopper +from ghpythonlib.componentbase import executingcomponent as component +import ghpythonlib.treehelpers as th + +import diffCheck.df_geometries +import numpy + +def compute_comparison(measured_pose, cad_pose): + cad_origin = cad_pose.Origin + measured_origin = measured_pose.Origin + distance = cad_origin.DistanceTo(measured_origin) + + # Compare the orientations using the formula: $$ \theta = \arccos\left(\frac{\text{trace}(R_{\text{pred}}^T R_{\text{meas}}) - 1}{2}\right) $$ + transform_o_to_cad = Rhino.Geometry.Transform.PlaneToPlane(Rhino.Geometry.Plane.WorldXY, cad_pose) + transform_o_to_measured = Rhino.Geometry.Transform.PlaneToPlane(Rhino.Geometry.Plane.WorldXY, measured_pose) + np_transform_o_to_cad = numpy.array(transform_o_to_cad.ToDoubleArray(rowDominant=True)).reshape((4, 4)) + np_transform_o_to_measured = numpy.array(transform_o_to_measured.ToDoubleArray(rowDominant=True)).reshape((4, 4)) + + R_cad = np_transform_o_to_cad[:3, :3] + R_measured = np_transform_o_to_measured[:3, :3] + R_rel = numpy.dot(R_cad.T, R_measured) + theta = numpy.arccos(numpy.clip((numpy.trace(R_rel) - 1) / 2, -1.0, 1.0)) + + # Compute the transformation matrix between the CAD pose and the measured pose + transform_cad_to_measured = Rhino.Geometry.Transform.PlaneToPlane(cad_pose, measured_pose) + return distance, theta, transform_cad_to_measured + +class DFPoseComparison(component): + def RunScript(self, i_assembly: diffCheck.df_geometries.DFAssembly, i_measured_planes: Grasshopper.DataTree[object]): + + CAD_poses = [beam.plane for beam in i_assembly.beams] + + o_distances = [] + o_angles = [] + o_transforms_cad_to_measured = [] + # Compare the origins + # measure the distance between the origins of the CAD pose and the measured pose and output this in the component + + bc = i_measured_planes.BranchCount + if bc > 1: + poses_per_beam = i_measured_planes.Branches + for beam_id, poses in enumerate(poses_per_beam): + o_distances.append([]) + o_angles.append([]) + o_transforms_cad_to_measured.append([]) + for pose in poses: + if not pose or not pose.IsValid: + o_distances[beam_id].append(None) + o_angles[beam_id].append(None) + o_transforms_cad_to_measured[beam_id].append(None) + else: + dist, angle, transform_cad_to_measured = compute_comparison(pose, CAD_poses[beam_id]) + o_distances[beam_id].append(dist) + o_angles[beam_id].append(angle) + o_transforms_cad_to_measured[beam_id].append(transform_cad_to_measured) + else: + i_measured_planes.Flatten() + measured_plane_list = th.tree_to_list(i_measured_planes) + print(measured_plane_list) + for i, plane in enumerate(measured_plane_list): + dist, angle, transform_cad_to_measured = compute_comparison(plane, CAD_poses[i]) + o_distances.append(dist) + o_angles.append(angle) + o_transforms_cad_to_measured.append(transform_cad_to_measured) + + if bc == 1: + return o_distances, o_angles, o_transforms_cad_to_measured + else: + return th.list_to_tree(o_distances), th.list_to_tree(o_angles), th.list_to_tree(o_transforms_cad_to_measured) diff --git a/src/gh/components/DF_pose_comparison/icon.png b/src/gh/components/DF_pose_comparison/icon.png new file mode 100644 index 00000000..31191432 Binary files /dev/null and b/src/gh/components/DF_pose_comparison/icon.png differ diff --git a/src/gh/components/DF_pose_comparison/metadata.json b/src/gh/components/DF_pose_comparison/metadata.json new file mode 100644 index 00000000..c9ad3a8e --- /dev/null +++ b/src/gh/components/DF_pose_comparison/metadata.json @@ -0,0 +1,67 @@ +{ + "name": "DFPoseComparison", + "nickname": "DFPoseComparison", + "category": "diffCheck", + "subcategory": "Analysis", + "description": "Compares CAD poses with measured poses to compute errors.", + "exposure": 4, + "instanceGuid": "13d76641-6f4f-4e78-a7dd-e64e176ffb2a", + "ghpython": { + "hideOutput": true, + "hideInput": true, + "isAdvancedMode": true, + "marshalOutGuids": true, + "iconDisplay": 2, + "inputParameters": [ + { + "name": "i_assembly", + "nickname": "i_assembly", + "description": "The DFAssembly of the structure.", + "optional": false, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "ghdoc" + }, + { + "name": "i_measured_planes", + "nickname": "i_measured_planes", + "description": "The measured planes (aka poses) to compare against the CAD planes.", + "optional": false, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "tree", + "wireDisplay": "default", + "sourceCount": 0 + } + ], + "outputParameters": [ + { + "name": "o_distances", + "nickname": "o_distances", + "description": "The distances between the CAD pose origins and measured pose origins.", + "optional": false, + "sourceCount": 0, + "graft": false + }, + { + "name": "o_angles", + "nickname": "o_angles", + "description": "The angles between the CAD pose orientations and measured pose orientations.", + "optional": false, + "sourceCount": 0, + "graft": false + }, + { + "name": "o_transforms_cad_to_measured", + "nickname": "o_transforms_cad_to_measured", + "description": "The transformation matrices from CAD poses to measured poses.", + "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..0534a9e8 --- /dev/null +++ b/src/gh/components/DF_pose_estimation/code.py @@ -0,0 +1,74 @@ +"""This compoment calculates the pose of a data tree of point clouds.""" +#! python3 + +from diffCheck import df_cvt_bindings +from diffCheck import df_poses +from diffCheck.diffcheck_bindings import dfb_geometry + +import Rhino +from Grasshopper.Kernel import GH_RuntimeMessageLevel as RML +import Grasshopper +import ghpythonlib.treehelpers as th + +from ghpythonlib.componentbase import executingcomponent as component + + +class DFPoseEstimation(component): + def RunScript(self, + i_face_clouds: Grasshopper.DataTree[Rhino.Geometry.PointCloud], + i_assembly, + i_reset: bool, + i_save: bool): + + clusters_per_beam = th.tree_to_list(i_face_clouds) + # ensure assembly has enough beams + if len(i_assembly.beams) < len(clusters_per_beam): + 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, face_clouds in enumerate(clusters_per_beam): + try: + df_cloud = dfb_geometry.DFPointCloud() + + rh_face_normals = [] + for face_cloud in face_clouds: + df_face_cloud = df_cvt_bindings.cvt_rhcloud_2_dfcloud(face_cloud) + df_cloud.add_points(df_face_cloud) + plane_normal = df_face_cloud.fit_plane_ransac() + if all(plane_normal) == 0: + ghenv.Component.AddRuntimeMessage(RML.Warning, f"There was a missing face in the cloud of beam {i}: the face was skipped in the pose estimation of that beam") # noqa: F821 + continue + rh_face_normals.append(Rhino.Geometry.Vector3d(plane_normal[0], plane_normal[1], plane_normal[2])) + + df_bb_points = df_cloud.get_axis_aligned_bounding_box() + df_bb_centroid = (df_bb_points[0] + df_bb_points[1]) / 2 + rh_bb_centroid = Rhino.Geometry.Point3d(df_bb_centroid[0], df_bb_centroid[1], df_bb_centroid[2]) + + new_xDirection, new_yDirection = df_poses.select_vectors(rh_face_normals, i_assembly.beams[i].plane.XAxis, i_assembly.beams[i].plane.YAxis) + + pose = df_poses.DFPose( + origin = [rh_bb_centroid.X, rh_bb_centroid.Y, rh_bb_centroid.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_bb_centroid, 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..f7c780ae --- /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_face_clouds", + "nickname": "i_face_clouds", + "description": "datatree of beam 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_measured_planes", + "nickname": "o_measured_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..73085aa1 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,21 @@ def compute_axis(self, is_unitized: bool = True) -> rg.Line: return axis_ln + def compute_plane(self) -> rg.Plane: + """ + This is an utility function that computes the plane of the beam. + The plane is calculated using the beam's axis and the world Z axis. + + :return plane: The plane of the beam + """ + beam_direction = self.axis.Direction + df_faces = [face for face in self.faces] + sorted_df_faces = sorted(df_faces, key=lambda face: Rhino.Geometry.AreaMassProperties.Compute(face._rh_brepface).Area if face._rh_brepface else 0, reverse=True) + largest_side_face_normal = sorted_df_faces[0].normal + rh_largest_side_face_normal = rg.Vector3d(largest_side_face_normal[0], largest_side_face_normal[1], largest_side_face_normal[2]) + + return rg.Plane(self.center, rg.Vector3d.CrossProduct(beam_direction, rh_largest_side_face_normal), rh_largest_side_face_normal) + def compute_joint_distances_to_midpoint(self) -> typing.List[float]: """ This function computes the distances from the center of the beam to each joint. @@ -666,6 +689,11 @@ def axis(self): self._axis = self.compute_axis() return self._axis + @property + def plane(self): + self._plane = self.compute_plane() + return self._plane + @property def length(self): self._length = self._axis.Length 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 29a99c05..f862e56a 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/test_save.ply b/test_save.ply deleted file mode 100644 index 7147788f..00000000 --- a/test_save.ply +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7e20354a8b0681cc343a87c7a98ff9e5e9400fb558f88f2e21c7fb1fc7e8bb24 -size 376593 diff --git a/tests/unit_tests/DFPointCloudTest.cc b/tests/unit_tests/DFPointCloudTest.cc index d6d669d5..d53a7d1e 100644 --- a/tests/unit_tests/DFPointCloudTest.cc +++ b/tests/unit_tests/DFPointCloudTest.cc @@ -219,4 +219,14 @@ TEST_F(DFPointCloudTestFixture, Transform) { //------------------------------------------------------------------------- // Others -//------------------------------------------------------------------------- \ No newline at end of file +//------------------------------------------------------------------------- + +TEST_F(DFPointCloudTestFixture, FitPlaneRANSAC) { + std::shared_ptr dfPointCloudPlane = std::make_shared(); + dfPointCloudPlane->LoadFromPLY(diffCheck::io::GetPlanePCWithOneOutliers()); + Eigen::Vector3d planeNormal = dfPointCloudPlane->FitPlaneRANSAC(0.01, 3, 100); + // plane model should be close to (0, 0, 1, d) + EXPECT_NEAR(planeNormal[0], 0.0, 1e-2); + EXPECT_NEAR(planeNormal[1], 0.0, 1e-2); + EXPECT_NEAR(std::abs(planeNormal[2]), 1.0, 1e-2); +}