diff --git a/assets/icon_pool/icon_export_ply.xcf b/assets/icon_pool/icon_export_ply.xcf new file mode 100644 index 00000000..4b91e29b Binary files /dev/null and b/assets/icon_pool/icon_export_ply.xcf differ diff --git a/assets/icon_pool/icon_export_serialization.xcf b/assets/icon_pool/icon_export_serialization.xcf new file mode 100644 index 00000000..8537fe78 Binary files /dev/null and b/assets/icon_pool/icon_export_serialization.xcf differ diff --git a/assets/icon_pool/icon_export_serialization_v2.xcf b/assets/icon_pool/icon_export_serialization_v2.xcf new file mode 100644 index 00000000..a9482bd3 Binary files /dev/null and b/assets/icon_pool/icon_export_serialization_v2.xcf differ diff --git a/assets/icon_pool/icon_import_serialization_v1.xcf b/assets/icon_pool/icon_import_serialization_v1.xcf new file mode 100644 index 00000000..486ce23f Binary files /dev/null and b/assets/icon_pool/icon_import_serialization_v1.xcf differ diff --git a/assets/icon_pool/icon_inspect_results.xcf b/assets/icon_pool/icon_inspect_results.xcf new file mode 100644 index 00000000..79d23128 Binary files /dev/null and b/assets/icon_pool/icon_inspect_results.xcf differ diff --git a/assets/icon_pool/icon_results.xcf b/assets/icon_pool/icon_results.xcf new file mode 100644 index 00000000..57524822 Binary files /dev/null and b/assets/icon_pool/icon_results.xcf differ diff --git a/assets/icon_pool/normal_v1.png b/assets/icon_pool/normal_v1.png new file mode 100644 index 00000000..dacb5192 Binary files /dev/null and b/assets/icon_pool/normal_v1.png differ diff --git a/deps/eigen b/deps/eigen index 7ad7c1d5..8b4efc8e 160000 --- a/deps/eigen +++ b/deps/eigen @@ -1 +1 @@ -Subproject commit 7ad7c1d5c59ab0bf87f83003283f0cc8357789bd +Subproject commit 8b4efc8ed8a65415e248d54fbc9afdd964c94f64 diff --git a/deps/pybind11 b/deps/pybind11 index 7e418f49..0ed20f26 160000 --- a/deps/pybind11 +++ b/deps/pybind11 @@ -1 +1 @@ -Subproject commit 7e418f49243bb7d13fa92cf2634af1eeac386465 +Subproject commit 0ed20f26acee626ac989568ecc6347e159ddbb47 diff --git a/doc/gh_DFExportCloudToFile.rst b/doc/gh_DFExportCloudToFile.rst new file mode 100644 index 00000000..164331a4 --- /dev/null +++ b/doc/gh_DFExportCloudToFile.rst @@ -0,0 +1,8 @@ +.. image:: ../src/gh/components/DF_export_cloud_to_file/icon.png + :align: left + :width: 40px + +``DFExportCloudToFile`` component +================================= + +.. ghcomponent_to_rst:: ../src/gh/components/DF_export_cloud_to_file \ No newline at end of file diff --git a/doc/gh_DFExportResults.rst b/doc/gh_DFExportResults.rst new file mode 100644 index 00000000..bf5fb73e --- /dev/null +++ b/doc/gh_DFExportResults.rst @@ -0,0 +1,8 @@ +.. image:: ../src/gh/components/DF_export_results/icon.png + :align: left + :width: 40px + +``DFExportResults`` component +============================= + +.. ghcomponent_to_rst:: ../src/gh/components/DF_export_results \ No newline at end of file diff --git a/doc/gh_DFImportResults.rst b/doc/gh_DFImportResults.rst new file mode 100644 index 00000000..affbc98a --- /dev/null +++ b/doc/gh_DFImportResults.rst @@ -0,0 +1,8 @@ +.. image:: ../src/gh/components/DF_import_results/icon.png + :align: left + :width: 40px + +``DFImportResults`` component +============================= + +.. ghcomponent_to_rst:: ../src/gh/components/DF_import_results \ No newline at end of file diff --git a/doc/gh_DFInspectResults.rst b/doc/gh_DFInspectResults.rst new file mode 100644 index 00000000..5d5e243c --- /dev/null +++ b/doc/gh_DFInspectResults.rst @@ -0,0 +1,8 @@ +.. image:: ../src/gh/components/DF_inspect_results/icon.png + :align: left + :width: 40px + +``DFInspectResults`` component +============================== + +.. ghcomponent_to_rst:: ../src/gh/components/DF_inspect_results \ No newline at end of file diff --git a/doc/gh_components.rst b/doc/gh_components.rst index 08828e02..e668b9d8 100644 --- a/doc/gh_components.rst +++ b/doc/gh_components.rst @@ -81,6 +81,16 @@ DF has a Grasshopper_ plugin with a set of components that allows the user to in - .. image:: ../src/gh/components/DF_cloud_voxel_downsample/icon.png - `gh_DFCloudVoxelDownsample `_ + * - .. image:: ../src/gh/components/DF_export_cloud_to_file/icon.png + - `DFExportCloudToFile `_ + - .. image:: ../src/gh/components/DF_export_results/icon.png + - `DFExportResults `_ + + * - .. image:: ../src/gh/components/DF_import_results/icon.png + - `DFImportResults `_ + - .. image:: ../src/gh/components/DF_inspect_results/icon.png + - `DFInspectResults `_ + .. toctree:: :maxdepth: 1 @@ -113,4 +123,8 @@ DF has a Grasshopper_ plugin with a set of components that allows the user to in gh_DFColorizeCloud gh_DFBrepToCloud gh_DFRemoveStatisticalOutliers - gh_DFMergeAssemblies \ No newline at end of file + gh_DFMergeAssemblies + gh_DFExportCloudToFile + gh_DFExportResults + gh_DFImportResults + gh_DFInspectResults \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 0fb1af09..1b7616fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,10 +29,13 @@ module = "pefile" ignore_missing_imports = true [[tool.mypy.overrides]] -module = "diffCheck" -ignore_undefined_attributes = true - +module = "df_geometries" +disable_error_code = "annotation-unchecked" +check_untyped_defs = false +[[tool.mypy.overrides]] +module = "src.gh.diffCheck.diffCheck.df_geometries" +disable_error_code = "annotation-unchecked" [tool.ruff] exclude = [ diff --git a/src/diffCheck/IOManager.cc b/src/diffCheck/IOManager.cc index 8abc2970..2a857950 100644 --- a/src/diffCheck/IOManager.cc +++ b/src/diffCheck/IOManager.cc @@ -28,6 +28,15 @@ namespace diffCheck::io return mesh; } + void WritePLYPointCloud(const std::shared_ptr &pointCloud, const std::string &filename) + { + auto open3dPointCloud = pointCloud->Cvt2O3DPointCloud(); + open3d::io::WritePointCloudToPLY( + filename, + *open3dPointCloud, + open3d::io::WritePointCloudOption()); + } + std::string GetTestDataDir() { // for github action conviniency diff --git a/src/diffCheck/IOManager.hh b/src/diffCheck/IOManager.hh index 95a745cf..e22c029d 100644 --- a/src/diffCheck/IOManager.hh +++ b/src/diffCheck/IOManager.hh @@ -25,6 +25,13 @@ namespace diffCheck::io */ std::shared_ptr ReadPLYMeshFromFile(const std::string &filename); + /** + * @brief Write a point cloud to a file as a PLY + * + * @param pointCloud the point cloud to write + * @param filename the path to the file with the extension + */ + void WritePLYPointCloud(const std::shared_ptr &pointCloud, const std::string &filename); ////////////////////////////////////////////////////////////////////////// // IO for test suite and tests data diff --git a/src/diffCheck/geometry/DFPointCloud.cc b/src/diffCheck/geometry/DFPointCloud.cc index 5a4bd742..d00fac65 100644 --- a/src/diffCheck/geometry/DFPointCloud.cc +++ b/src/diffCheck/geometry/DFPointCloud.cc @@ -277,6 +277,12 @@ namespace diffCheck::geometry this->Normals = cloud->Normals; } + void DFPointCloud::SaveToPLY(const std::string &path) + { + auto cloud_ptr = std::make_shared(this->Points, this->Colors, this->Normals); + diffCheck::io::WritePLYPointCloud(cloud_ptr, path); + } + std::vector DFPointCloud::ComputeDistance(std::shared_ptr target) { std::vector errors; diff --git a/src/diffCheck/geometry/DFPointCloud.hh b/src/diffCheck/geometry/DFPointCloud.hh index 62f512d4..b3f0a3be 100644 --- a/src/diffCheck/geometry/DFPointCloud.hh +++ b/src/diffCheck/geometry/DFPointCloud.hh @@ -153,6 +153,13 @@ namespace diffCheck::geometry */ void LoadFromPLY(const std::string &path); + /** + * @brief Save a point cloud to a file as a PLY + * + * @param filename the path to the file with the extension + */ + void SaveToPLY(const std::string &path); + public: ///< Distance calculations /** * @brief Compute the distance between two point clouds. diff --git a/src/diffCheckBindings.cc b/src/diffCheckBindings.cc index 606e31d7..434f5da2 100644 --- a/src/diffCheckBindings.cc +++ b/src/diffCheckBindings.cc @@ -56,6 +56,8 @@ PYBIND11_MODULE(diffcheck_bindings, m) { py::arg("nb_neighbors"), py::arg("std_ratio")) .def("load_from_PLY", &diffCheck::geometry::DFPointCloud::LoadFromPLY) + .def("save_to_PLY", &diffCheck::geometry::DFPointCloud::SaveToPLY) + .def("add_points", &diffCheck::geometry::DFPointCloud::AddPoints) .def("get_tight_bounding_box", &diffCheck::geometry::DFPointCloud::GetTightBoundingBox) diff --git a/src/gh/components/DF_csv_exporter/code.py b/src/gh/components/DF_csv_exporter/code.py index 0f1e4ca0..877944dd 100644 --- a/src/gh/components/DF_csv_exporter/code.py +++ b/src/gh/components/DF_csv_exporter/code.py @@ -1,11 +1,45 @@ #! python3 +import System +import csv +import os +import typing from ghpythonlib.componentbase import executingcomponent as component +import Grasshopper as gh -from diffCheck.df_error_estimation import DFInvalidData -import csv -import os +from diffCheck.df_error_estimation import DFInvalidData, DFVizResults + + +def add_bool_toggle(self, + nickname: str, + indx: int, + X_param_coord: float, + Y_param_coord: float, + X_offset: int=87 + ) -> None: + """ + Adds a boolean toggle to the component input + + :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: + toggle = gh.Kernel.Special.GH_BooleanToggle() + toggle.NickName = nickname + toggle.Description = "Toggle the value to use with DFVizSettings" + toggle.CreateAttributes() + toggle.Attributes.Pivot = System.Drawing.PointF( + X_param_coord - (toggle.Attributes.Bounds.Width) - X_offset, + Y_param_coord - (toggle.Attributes.Bounds.Height / 2 + 0.1) + ) + toggle.Attributes.ExpireLayout() + gh.Instances.ActiveCanvas.Document.AddObject(toggle, False) + ghenv.Component.Params.Input[indx].AddSource(toggle) # noqa: F821 class DFCsvExporter(component): @@ -14,7 +48,28 @@ def __init__(self): self.prefix = "" self.counter = 0 - def _get_id(self, idx, i_result): + 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 + 1 + X_cord = params[j].Attributes.Pivot.X + 10 + input_indx = j + if "i_export_seperate_files" == params[j].NickName: + add_bool_toggle( + ghenv.Component, # noqa: F821 + "export_asfiles", + input_indx, X_cord, Y_cord) + if "i_export_distances" == params[j].NickName: + add_bool_toggle( + ghenv.Component, # noqa: F821 + "export_dist", + input_indx, X_cord, Y_cord) + + def _get_id(self, + idx: int, + i_result: DFVizResults + ) -> str: """ Get the ID of the element """ counter = 0 @@ -34,15 +89,18 @@ def _get_id(self, idx, i_result): return f"{idx_b}--{idx_j}--{idx_f}" counter += 1 - def _write_csv(self, file_path, rows): - """ Write the CSV file """ - with open(file_path, mode='w', newline='') as file: - writer = csv.writer(file) - writer.writerow([f"{self.prefix} id", "distances", "min_deviation", "max_deviation", "std_deviation", "rmse", "mean"]) - writer.writerows(rows) + def _prepare_row(self, + idx: int, + i_result: DFVizResults + ) -> typing.Dict: + """ + Convert the results contained in the DFVizResults object to a dict to be written in the CSV file - def _prepare_row(self, idx, i_result): - """ Prepare a row for the CSV file """ + :param idx: Index of the element + :param i_result: DFVizResults object containing all the values + + :return: Dict of values containng as keys the header and as items the values to be written in the CSV file + """ if i_result.sanity_check[idx].value != DFInvalidData.VALID.value: invalid_type = i_result.sanity_check[idx].name return [self._get_id(idx, i_result), invalid_type, invalid_type, invalid_type, invalid_type, invalid_type, invalid_type] @@ -53,16 +111,57 @@ def _prepare_row(self, idx, i_result): std_dev = round(i_result.distances_sd_deviation[idx], 4) rmse = round(i_result.distances_rmse[idx], 4) mean = round(i_result.distances_mean[idx], 4) - distances_str = ";".join(map(str, distances)) - return [self._get_id(idx, i_result), distances_str, min_dev, max_dev, std_dev, rmse, mean] + + row: typing.Dict = { + f"{self.prefix} id": self._get_id(idx, i_result), + "distances": distances, + "min_deviation": min_dev, + "max_deviation": max_dev, + "std_deviation": std_dev, + "rmse": rmse, + "mean": mean + } + return row + + def _write_csv(self, + csv_path: str, + rows: typing.List[typing.Dict], + is_writing_only_distances: bool = False + ) -> None: + """ + Write the CSV file + + :param csv_path: Path of the CSV file + :param rows: Dict of values to be written in the CSV file + :param is_writing_only_distances: Flag to check if to write ONLY distances or the whole analysis + + :return: None + """ + with open(csv_path, mode='w', newline='') as file: + writer = csv.writer(file, quoting=csv.QUOTE_MINIMAL) + if is_writing_only_distances: + writer.writerow(list(rows[0].keys())[:2]) # header + element_id = [row[f"{self.prefix} id"] for row in rows] + dist_rows = [row["distances"] for row in rows] + for idx, dist_row in enumerate(dist_rows): + for dist in dist_row: + writer.writerow([element_id[idx], dist]) + else: + rows = [{k: v for k, v in row.items() if k != "distances"} for row in rows] # no distances + writer.writerow(list(rows[0].keys())) # header + writer.writerows([list(row.values()) for row in rows]) def RunScript(self, i_dump: bool, i_export_dir: str, i_file_name: str, i_export_seperate_files: bool, + i_export_distances: bool, i_result): + csv_analysis_path: str = None + csv_distances_path: str = None + if i_dump: os.makedirs(i_export_dir, exist_ok=True) @@ -75,10 +174,17 @@ def RunScript(self, if i_export_seperate_files: for idx in range(len(i_result.source)): - element_id = self._get_id( idx, i_result) - file_path = os.path.join(i_export_dir, f"{i_file_name}_{self.prefix}_{element_id}.csv") - self._write_csv(file_path, [self._prepare_row(idx, i_result)]) + element_id = self._get_id(idx, i_result) + csv_analysis_path = os.path.join(i_export_dir, f"{i_file_name}_{self.prefix}_{element_id}.csv") + rows = [self._prepare_row(idx, i_result)] + self._write_csv(csv_analysis_path, rows) + if i_export_distances: + csv_distances_path = os.path.join(i_export_dir, f"{i_file_name}_{self.prefix}_{element_id}_distances.csv") + self._write_csv(csv_distances_path, rows, is_writing_only_distances=True) else: - file_path = os.path.join(i_export_dir, f"{i_file_name}.csv") - rows = [self._prepare_row(idx, i_result) for idx in range(len(i_result.source))] - self._write_csv(file_path, rows) + csv_analysis_path = os.path.join(i_export_dir, f"{i_file_name}.csv") + merged_rows = [self._prepare_row(idx, i_result) for idx in range(len(i_result.source))] + self._write_csv(csv_analysis_path, merged_rows) + if i_export_distances: + csv_distances_path = os.path.join(i_export_dir, f"{i_file_name}_distances.csv") + self._write_csv(csv_distances_path, merged_rows, is_writing_only_distances=True) diff --git a/src/gh/components/DF_csv_exporter/metadata.json b/src/gh/components/DF_csv_exporter/metadata.json index 149de386..d1cfbc99 100644 --- a/src/gh/components/DF_csv_exporter/metadata.json +++ b/src/gh/components/DF_csv_exporter/metadata.json @@ -61,6 +61,18 @@ "sourceCount": 0, "typeHintID": "bool" }, + { + "name": "i_export_distances", + "nickname": "i_export_distances", + "description": "Whether to export the calculated error distances for each point of the analysed point cloud.", + "optional": true, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "bool" + }, { "name": "i_result", "nickname": "i_result", diff --git a/src/gh/components/DF_export_cloud_to_file/code.py b/src/gh/components/DF_export_cloud_to_file/code.py new file mode 100644 index 00000000..a81ab7ef --- /dev/null +++ b/src/gh/components/DF_export_cloud_to_file/code.py @@ -0,0 +1,74 @@ +#! python3 + +import System + +import Rhino # noqa: F401 +import Rhino.Geometry as rg +from ghpythonlib.componentbase import executingcomponent as component + +from Grasshopper.Kernel import GH_RuntimeMessageLevel as RML +import Grasshopper as gh + +from diffCheck import diffcheck_bindings +from diffCheck import df_cvt_bindings + + +def add_button(self, + nickname: str, + indx: int, + X_param_coord: float, + Y_param_coord: float, + X_offset: int=45 + ) -> None: + """ + Adds a button to the component input + + :param nickname: the nickname of the button + :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 button from the input parameter + """ + param = ghenv.Component.Params.Input[indx] # noqa: F821 + if param.SourceCount == 0: + button = gh.Kernel.Special.GH_ButtonObject() + button.NickName = "" + button.Description = "" + button.CreateAttributes() + button.Attributes.Pivot = System.Drawing.PointF( + X_param_coord - (button.Attributes.Bounds.Width) - X_offset, + Y_param_coord - (button.Attributes.Bounds.Height / 2 - 0.1) + ) + button.Attributes.ExpireLayout() + gh.Instances.ActiveCanvas.Document.AddObject(button, False) + ghenv.Component.Params.Input[indx].AddSource(button) # noqa: F821 + +class DFExportCloudToFile(component): + def __init__(self): + super(DFExportCloudToFile, self).__init__() + 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)): + X_cord = params[j].Attributes.Pivot.X + Y_cord = params[j].Attributes.InputGrip.Y + if params[j].Name == "i_dump": + add_button(self, "", j, X_cord, Y_cord) + + def RunScript(self, + i_dump: bool, + i_file_path: str, + i_cloud: rg.PointCloud) -> None: + if i_dump is None or i_file_path is None or i_cloud is None: + return None + + # check that the i_file_path is a valid path and it has the .ply extension + if not i_file_path.endswith(".ply"): + ghenv.Component.AddRuntimeMessage(RML.Warning, "Attention: the format should be .ply") # noqa: F821 + return None + + if i_dump: + df_cloud: diffcheck_bindings.dfb_geometry.DFPointCloud = df_cvt_bindings.cvt_rhcloud_2_dfcloud(i_cloud) + df_cloud.save_to_PLY(i_file_path) + + return None diff --git a/src/gh/components/DF_export_cloud_to_file/icon.png b/src/gh/components/DF_export_cloud_to_file/icon.png new file mode 100644 index 00000000..091afe79 Binary files /dev/null and b/src/gh/components/DF_export_cloud_to_file/icon.png differ diff --git a/src/gh/components/DF_export_cloud_to_file/metadata.json b/src/gh/components/DF_export_cloud_to_file/metadata.json new file mode 100644 index 00000000..a349bc89 --- /dev/null +++ b/src/gh/components/DF_export_cloud_to_file/metadata.json @@ -0,0 +1,55 @@ +{ + "name": "DFExportCloudToFile", + "nickname": "DFExportCloudToFile", + "category": "diffCheck", + "subcategory": "Cloud", + "description": "Export a point cloud to a file of PLY format.", + "exposure": 4, + "instanceGuid": "aa2c0229-90b9-48be-890b-aded2bfc2b24", + "ghpython": { + "hideOutput": true, + "hideInput": true, + "isAdvancedMode": true, + "marshalOutGuids": true, + "iconDisplay": 2, + "inputParameters": [ + { + "name": "i_dump", + "nickname": "i_dump", + "description": "Button to save the point cloud.", + "optional": true, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "bool" + }, + { + "name": "i_file_path", + "nickname": "i_file_path", + "description": "The path of the file with extension .ply to save.", + "optional": false, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "str" + }, + { + "name": "i_cloud", + "nickname": "i_cloud", + "description": "The cloud to save.", + "optional": false, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "pointcloud" + } + ], + "outputParameters": [] + } +} \ No newline at end of file diff --git a/src/gh/components/DF_export_results/code.py b/src/gh/components/DF_export_results/code.py new file mode 100644 index 00000000..a051167f --- /dev/null +++ b/src/gh/components/DF_export_results/code.py @@ -0,0 +1,58 @@ +#! python3 + +import System + +from ghpythonlib.componentbase import executingcomponent as component +import Grasshopper as gh + + +def add_button(self, + nickname: str, + indx: int, + X_param_coord: float, + Y_param_coord: float, + X_offset: int=45 + ) -> None: + """ + Adds a button to the component input + + :param nickname: the nickname of the button + :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 button from the input parameter + """ + param = ghenv.Component.Params.Input[indx] # noqa: F821 + if param.SourceCount == 0: + button = gh.Kernel.Special.GH_ButtonObject() + button.NickName = "" + button.Description = "" + button.CreateAttributes() + button.Attributes.Pivot = System.Drawing.PointF( + X_param_coord - (button.Attributes.Bounds.Width) - X_offset, + Y_param_coord - (button.Attributes.Bounds.Height / 2 - 0.1) + ) + button.Attributes.ExpireLayout() + gh.Instances.ActiveCanvas.Document.AddObject(button, False) + ghenv.Component.Params.Input[indx].AddSource(button) # noqa: F821 + +class DFExportResults(component): + def __init__(self): + super(DFExportResults, self).__init__() + 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)): + X_cord = params[j].Attributes.Pivot.X + Y_cord = params[j].Attributes.InputGrip.Y + if params[j].Name == "i_dump": + add_button(self, "", j, X_cord, Y_cord) + + def RunScript(self, i_dump: bool, i_export_dir: str, i_results): + if i_dump is None or i_export_dir is None or i_results is None: + return None + + if i_dump: + i_results.dump_serialization(i_export_dir) + + return None diff --git a/src/gh/components/DF_export_results/icon.png b/src/gh/components/DF_export_results/icon.png new file mode 100644 index 00000000..43163e2e Binary files /dev/null and b/src/gh/components/DF_export_results/icon.png differ diff --git a/src/gh/components/DF_export_results/metadata.json b/src/gh/components/DF_export_results/metadata.json new file mode 100644 index 00000000..15978295 --- /dev/null +++ b/src/gh/components/DF_export_results/metadata.json @@ -0,0 +1,55 @@ +{ + "name": "DFExportResults", + "nickname": "DFExportResults", + "category": "diffCheck", + "subcategory": "Results", + "description": "It saves the computed DF results in a file .diffCheck to be exported later. This is done by pickling/serializing the DFVizResults object.", + "exposure": 4, + "instanceGuid": "fbc0c1be-1485-4167-98f4-9c6106852a90", + "ghpython": { + "hideOutput": true, + "hideInput": true, + "isAdvancedMode": true, + "marshalOutGuids": true, + "iconDisplay": 2, + "inputParameters": [ + { + "name": "i_dump", + "nickname": "i_dump", + "description": "Save it!", + "optional": false, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "bool" + }, + { + "name": "i_export_dir", + "nickname": "i_export_dir", + "description": "The directory where the .diffCheck file will be saved.", + "optional": true, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "str" + }, + { + "name": "i_results", + "nickname": "i_results", + "description": "The DFVizResults object to be saved.", + "optional": false, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "ghdoc" + } + ], + "outputParameters": [] + } +} \ No newline at end of file diff --git a/src/gh/components/DF_import_results/code.py b/src/gh/components/DF_import_results/code.py new file mode 100644 index 00000000..2ded2a2c --- /dev/null +++ b/src/gh/components/DF_import_results/code.py @@ -0,0 +1,15 @@ +#! python3 + +from ghpythonlib.componentbase import executingcomponent as component + +from diffCheck.df_error_estimation import DFVizResults + + +class DFImportResults(component): + def RunScript(self, i_import_path: str): + if i_import_path is None: + return None + + o_results = DFVizResults.load_serialization(i_import_path) + + return o_results diff --git a/src/gh/components/DF_import_results/icon.png b/src/gh/components/DF_import_results/icon.png new file mode 100644 index 00000000..e6a315a9 Binary files /dev/null and b/src/gh/components/DF_import_results/icon.png differ diff --git a/src/gh/components/DF_import_results/metadata.json b/src/gh/components/DF_import_results/metadata.json new file mode 100644 index 00000000..4af6ad8f --- /dev/null +++ b/src/gh/components/DF_import_results/metadata.json @@ -0,0 +1,40 @@ +{ + "name": "DFImportResults", + "nickname": "DFImportResults", + "category": "diffCheck", + "subcategory": "Results", + "description": "It loads the DF result object from a file .diffCheck.", + "exposure": 4, + "instanceGuid": "c2b67d2c-a53b-47c4-bfbf-772f60136049", + "ghpython": { + "hideOutput": true, + "hideInput": true, + "isAdvancedMode": true, + "marshalOutGuids": true, + "iconDisplay": 2, + "inputParameters": [ + { + "name": "i_import_path", + "nickname": "i_import_path", + "description": "The path to the file of the .diffCheck file.", + "optional": true, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "str" + } + ], + "outputParameters": [ + { + "name": "o_results", + "nickname": "o_results", + "description": "The loaded DF result object.", + "optional": false, + "sourceCount": 0, + "graft": false + } + ] + } +} \ No newline at end of file diff --git a/src/gh/components/DF_inspect_results/code.py b/src/gh/components/DF_inspect_results/code.py new file mode 100644 index 00000000..c5053b64 --- /dev/null +++ b/src/gh/components/DF_inspect_results/code.py @@ -0,0 +1,19 @@ +#! python3 + +from ghpythonlib.componentbase import executingcomponent as component + + +class DFInspectResults(component): + def RunScript(self, i_results): + if i_results is None: + return None + + return i_results.assembly, \ + i_results.source, \ + i_results.target, \ + i_results.distances_mean, \ + i_results.distances_rmse, \ + i_results.distances_max_deviation, \ + i_results.distances_min_deviation, \ + i_results.distances_sd_deviation, \ + i_results.distances diff --git a/src/gh/components/DF_inspect_results/icon.png b/src/gh/components/DF_inspect_results/icon.png new file mode 100644 index 00000000..4249f635 Binary files /dev/null and b/src/gh/components/DF_inspect_results/icon.png differ diff --git a/src/gh/components/DF_inspect_results/metadata.json b/src/gh/components/DF_inspect_results/metadata.json new file mode 100644 index 00000000..7c5c08fb --- /dev/null +++ b/src/gh/components/DF_inspect_results/metadata.json @@ -0,0 +1,104 @@ +{ + "name": "DFInspectResults", + "nickname": "DFInspectResults", + "category": "diffCheck", + "subcategory": "Results", + "description": "Access the components of the DFVizResults object.", + "exposure": 4, + "instanceGuid": "efc619ea-7f00-4681-ad79-18219c0471af", + "ghpython": { + "hideOutput": true, + "hideInput": true, + "isAdvancedMode": true, + "marshalOutGuids": true, + "iconDisplay": 2, + "inputParameters": [ + { + "name": "i_results", + "nickname": "i_results", + "description": "The path to the file of the .diffCheck file.", + "optional": true, + "allowTreeAccess": true, + "showTypeHints": true, + "scriptParamAccess": "item", + "wireDisplay": "default", + "sourceCount": 0, + "typeHintID": "ghdoc" + } + ], + "outputParameters": [ + { + "name": "o_assembly", + "nickname": "o_assembly", + "description": "The DFAssembly object.", + "optional": false, + "sourceCount": 0, + "graft": false + }, + { + "name": "o_source", + "nickname": "o_source", + "description": "The point cloud used for calculating the results.", + "optional": false, + "sourceCount": 0, + "graft": false + }, + { + "name": "o_target", + "nickname": "o_target", + "description": "The target mesh of the structure.", + "optional": false, + "sourceCount": 0, + "graft": false + }, + { + "name": "o_distances_mean", + "nickname": "o_distances_mean", + "description": "-", + "optional": false, + "sourceCount": 0, + "graft": false + }, + { + "name": "o_distances_rmse", + "nickname": "o_distances_rmse", + "description": "-", + "optional": false, + "sourceCount": 0, + "graft": false + }, + { + "name": "o_distances_max_deviation", + "nickname": "o_distances_max_deviation", + "description": "-", + "optional": false, + "sourceCount": 0, + "graft": false + }, + { + "name": "o_distances_min_deviation", + "nickname": "o_distances_min_deviation", + "description": "-", + "optional": false, + "sourceCount": 0, + "graft": false + }, + { + "name": "o_distances_sd_deviation", + "nickname": "o_distances_sd_deviation", + "description": "-", + "optional": false, + "sourceCount": 0, + "graft": false + }, + { + "name": "o_distances", + "nickname": "o_distances", + "description": "-", + "optional": false, + "sourceCount": 0, + "graft": false + } + ] + } +} \ No newline at end of file diff --git a/src/gh/components/DF_load_cloud_from_file/code.py b/src/gh/components/DF_load_cloud_from_file/code.py index ba240697..eb42ca36 100644 --- a/src/gh/components/DF_load_cloud_from_file/code.py +++ b/src/gh/components/DF_load_cloud_from_file/code.py @@ -11,7 +11,11 @@ class DFLoadCloudFromFile(component): def RunScript(self, i_path: str, i_scalef: float) -> rg.PointCloud: - # import and convert to Rhino Cloud + if i_path is None: + return None + if i_scalef is None: + i_scalef = 1.0 + df_cloud = diffcheck_bindings.dfb_geometry.DFPointCloud() df_cloud.load_from_PLY(i_path) rh_cloud = df_cvt_bindings.cvt_dfcloud_2_rhcloud(df_cloud) diff --git a/src/gh/diffCheck/diffCheck/df_cvt_bindings.py b/src/gh/diffCheck/diffCheck/df_cvt_bindings.py index c6016b37..59e34c27 100644 --- a/src/gh/diffCheck/diffCheck/df_cvt_bindings.py +++ b/src/gh/diffCheck/diffCheck/df_cvt_bindings.py @@ -260,3 +260,31 @@ def cvt_ndarray_2_rh_transform(matrix) -> rg.Transform: transfo.M32 = matrix[3, 2] transfo.M33 = matrix[3, 3] return transfo + + +def cvt_dfcloud_2_dict(df_cloud: diffcheck_bindings.dfb_geometry.DFPointCloud) -> dict: + """ + Convert a diffCheck cloud to a dictionary mainly for pickling and serialization. + + :param df_cloud: diffCheck cloud + :return cloud_dict: the cloud dictionary + """ + cloud_dict = { + "points": df_cloud.points, + "normals": df_cloud.normals, + "colors": df_cloud.colors + } + return cloud_dict + +def cvt_dict_2_dfcloud(cloud_dict: dict) -> diffcheck_bindings.dfb_geometry.DFPointCloud: + """ + Convert a dictionary to a diffCheck cloud mainly for pickling and deserialization. + + :param cloud_dict: the cloud dictionary + :return df_cloud: diffCheck cloud + """ + df_cloud = diffcheck_bindings.dfb_geometry.DFPointCloud() + df_cloud.points = cloud_dict["points"] + df_cloud.normals = cloud_dict["normals"] + df_cloud.colors = cloud_dict["colors"] + return df_cloud diff --git a/src/gh/diffCheck/diffCheck/df_error_estimation.py b/src/gh/diffCheck/diffCheck/df_error_estimation.py index 7a9776f1..4607e1e6 100644 --- a/src/gh/diffCheck/diffCheck/df_error_estimation.py +++ b/src/gh/diffCheck/diffCheck/df_error_estimation.py @@ -5,17 +5,29 @@ import typing from enum import Enum +from datetime import datetime +import os + +import json import numpy as np import Rhino import Rhino.Geometry as rg +from Rhino.FileIO import SerializationOptions from diffCheck import diffcheck_bindings # type: ignore from diffCheck import df_cvt_bindings from diffCheck.df_geometries import DFAssembly +class NumpyEncoder(json.JSONEncoder): + """ Special json encoder for numpy ndarray types. """ + def default(self, obj): + if isinstance(obj, np.ndarray): + return obj.tolist() + return super().default(obj) + class DFInvalidData(Enum): """ Enum to define the type of invalid data for joint or assembly analysis @@ -31,11 +43,16 @@ class DFVizResults: """ This class compiles the resluts of the error estimation into one object """ + __serial_file_extenion: str = ".diffCheck" def __init__(self, assembly): - self.source = [] - self.target = [] + self.assembly: DFAssembly = assembly + self.source: typing.List[diffcheck_bindings.dfb_geometry.DFPointCloud] = [] + self.target: typing.List[Rhino.Geometry.Mesh] = [] + + self.sanity_check: typing.List[DFInvalidData] = [] + self._is_source_cloud = True # if False it's a mesh self.distances_mean = [] self.distances_rmse = [] @@ -43,11 +60,81 @@ def __init__(self, assembly): self.distances_min_deviation = [] self.distances_sd_deviation = [] self.distances = [] - self.assembly = assembly - self.sanity_check = [] + def __repr__(self): + return f"DFVizResults of({self.assembly})" + + def __getstate__(self): + state = self.__dict__.copy() + if "assembly" in state and state["assembly"] is not None: + state["assembly"] = self.assembly.__getstate__() + if "source" in state and state["source"] is not None: + state["source"] = [df_cvt_bindings.cvt_dfcloud_2_dict(pcd) for pcd in state["source"]] + if "target" in state and state["target"] is not None: + state["target"] = [mesh.ToJSON(SerializationOptions()) for mesh in state["target"]] + if "sanity_check" in state and state["sanity_check"] is not None: + state["sanity_check"] = [s.value if isinstance(s, DFInvalidData) else s for s in self.sanity_check] + return state + + def __setstate__(self, state: typing.Dict): + if "assembly" in state and state["assembly"] is not None: + assembly = DFAssembly.__new__(DFAssembly) + assembly.__setstate__(state["assembly"]) + state["assembly"] = assembly + if "source" in state and state["source"] is not None: + source = [] + for pcd_dict in state["source"]: + pcd = diffcheck_bindings.dfb_geometry.DFPointCloud() + pcd = df_cvt_bindings.cvt_dict_2_dfcloud(pcd_dict) + source.append(pcd) + state["source"] = source + if "target" in state and state["target"] is not None: + target = [] + for mesh_json in state["target"]: + mesh = rg.Mesh() + mesh = mesh.FromJSON(mesh_json) + target.append(mesh) + state["target"] = target + if "sanity_check" in state and state["sanity_check"] is not None: + state["sanity_check"] = [DFInvalidData(s) for s in state["sanity_check"]] + self.__dict__.update(state) + + def dump_serialization(self, dir: str) -> str: + """ Dump the results into a JSON file for serialization """ + if not os.path.exists(os.path.dirname(dir)): + try: + os.makedirs(os.path.dirname(dir)) + except OSError as exc: + raise exc + + timestamp: str = datetime.now().strftime("%Y%m%d_%H%M%S") + assembly_name: str = self.assembly.name + serial_file_path = os.path.join(dir, f"{assembly_name}_{timestamp}{self.__serial_file_extenion}") + + try: + with open(serial_file_path, "w") as f: + json.dump(self.__getstate__(), f, cls=NumpyEncoder) + except Exception as e: + raise e + + return serial_file_path + + @staticmethod + def load_serialization(file_path: str) -> 'DFVizResults': + """ Load the results from a JSON file """ + if not os.path.exists(file_path): + raise FileNotFoundError(f"File {file_path} not found") + if not file_path.endswith(DFVizResults.__serial_file_extenion): + raise ValueError(f"File {file_path} is not a valid diffCheck file") + try: + with open(file_path, "r") as f: + state = json.load(f) + obj = DFVizResults.__new__(DFVizResults) + obj.__setstate__(state) + except Exception as e: + raise e + return obj - self._is_source_cloud = True # if False it's a mesh def add(self, source, target, distances, sanity_check: DFInvalidData = DFInvalidData.VALID): diff --git a/src/gh/diffCheck/diffCheck/df_geometries.py b/src/gh/diffCheck/diffCheck/df_geometries.py index f87701fd..44e502a6 100644 --- a/src/gh/diffCheck/diffCheck/df_geometries.py +++ b/src/gh/diffCheck/diffCheck/df_geometries.py @@ -9,6 +9,7 @@ import Rhino import Rhino.Geometry as rg +from Rhino.FileIO import SerializationOptions from Grasshopper.Kernel import GH_RuntimeMessageLevel as RML @@ -35,6 +36,12 @@ def __post_init__(self): self.__uuid = uuid.uuid4().int + def __getstate__(self): + return self.__dict__ + + def __setstate__(self, state: typing.Dict): + self.__dict__.update(state) + def __repr__(self): return f"Vertex: X={self.x}, Y={self.y}, Z={self.z}" @@ -83,17 +90,40 @@ class DFFace: joint_id: Optional[int] = None def __post_init__(self): - self.all_loops = self.all_loops - - self.joint_id = self.joint_id + self.all_loops: typing.List[typing.List[DFVertex]] = self.all_loops + self.joint_id: Optional[int] = self.joint_id self.__is_joint = False self.__uuid = uuid.uuid4().int - # if df_face is created from a rhino brep face, we store the rhino brep face - self._rh_brepface = None - + self._rh_brepface: rg.BrepFace = None self.is_roundwood = False + def __getstate__(self): + state = self.__dict__.copy() + if "all_loops" in state and state["all_loops"] is not None: + state["all_loops"] = [[vertex.__getstate__() for vertex in loop] for loop in state["all_loops"]] + # note: rg.BrepFaces cannot be serialized, so we need to convert it to a Surface >> JSON >> brep >> brepface (and vice versa) + if "_rh_brepface" in state and state["_rh_brepface"] is not None: + state["_rh_brepface"] = self.to_brep_face().DuplicateFace(True).ToJSON(SerializationOptions()) + return state + + def __setstate__(self, state: typing.Dict): + if "all_loops" in state and state["all_loops"] is not None: + all_loops = [] + for loop_state in state["all_loops"]: + loop = [DFVertex.__new__(DFVertex) for _ in loop_state] + for vertex, vertex_state in zip(loop, loop_state): + vertex.__setstate__(vertex_state) + all_loops.append(loop) + state["all_loops"] = all_loops + # note: rg.BrepFaces cannot be serialized, so we need to convert it to a Surface >> JSON >> brep >> brepface (and vice versa) + if "_rh_brepface" in state and state["_rh_brepface"] is not None: + state["_rh_brepface"] = rg.Surface.FromJSON(state["_rh_brepface"]).Faces[0] + self.__dict__.update(state) + if self._rh_brepface is not None: + self.from_brep_face(self._rh_brepface, self.joint_id) + + def __repr__(self): return f"Face id: {(self.id)}, IsJoint: {self.is_joint} Loops: {len(self.all_loops)}" @@ -227,6 +257,22 @@ def __post_init__(self): # this is an automatic identifier self.__uuid = uuid.uuid4().int + def __getstate__(self): + state = self.__dict__.copy() + if "faces" in state and state["faces"] is not None: + state["faces"] = [face.__getstate__() for face in self.faces] + return state + + def __setstate__(self, state: typing.Dict): + if "faces" in state and state["faces"] is not None: + faces = [] + for face_state in state["faces"]: + face = DFFace.__new__(DFFace) + face.__setstate__(face_state) + faces.append(face) + state["faces"] = faces + self.__dict__.update(state) + def __repr__(self): return f"Joint id: {self.id}, Faces: {len(self.faces)}" @@ -277,28 +323,86 @@ class DFBeam: faces: typing.List[DFFace] def __post_init__(self): - self.name = self.name or "Unnamed Beam" - self.faces = self.faces or [] - self.is_roundwood = False + self.name: str = self.name or "Unnamed Beam" + self.faces: typing.List[DFFace] = self.faces or [] + self.is_roundwood: bool = False - self._joint_faces = [] - self._side_faces = [] - self._vertices = [] + self._joint_faces: typing.List[DFFace] = [] + self._side_faces: typing.List[DFFace] = [] + self._vertices: typing.List[DFVertex] = [] - self._joints = [] + self._joints: typing.List[DFJoint] = [] # this should be used like a hash identifier self.__uuid = uuid.uuid4().int # this index is assigned only when the an beam is added to an assembly - self._index_assembly = None + self._index_assembly: int = None - self._center = None + self._center: rg.Point3d = None self.__id = uuid.uuid4().int + def __getstate__(self): + state = self.__dict__.copy() + if "faces" in state and state["faces"] is not None: + state["faces"] = [face.__getstate__() for face in self.faces] + if "_joint_faces" in state and state["_joint_faces"] is not None: + state["_joint_faces"] = [face.__getstate__() for face in state["_joint_faces"]] + if "_side_faces" in state and state["_side_faces"] is not None: + state["_side_faces"] = [face.__getstate__() for face in state["_side_faces"]] + if "_vertices" in state and state["_vertices"] is not None: + state["_vertices"] = [vertex.__getstate__() for vertex in state["_vertices"]] + if "_joints" in state and state["_joints"] is not None: + state["_joints"] = [joint.__getstate__() for joint in state["_joints"]] + if "_center" in state and state["_center"] is not None: + state["_center"] = self._center.ToJSON(SerializationOptions()) + return state + + def __setstate__(self, state: typing.Dict): + if "faces" in state and state["faces"] is not None: + faces = [] + for face_state in state["faces"]: + face = DFFace.__new__(DFFace) + face.__setstate__(face_state) + faces.append(face) + state["faces"] = faces + if "_joint_faces" in state and state["_joint_faces"] is not None: + joint_faces = [] + for face_state in state["_joint_faces"]: + face = DFFace.__new__(DFFace) + face.__setstate__(face_state) + joint_faces.append(face) + state["_joint_faces"] = joint_faces + if "_side_faces" in state and state["_side_faces"] is not None: + side_faces = [] + for face_state in state["_side_faces"]: + face = DFFace.__new__(DFFace) + face.__setstate__(face_state) + side_faces.append(face) + state["_side_faces"] = side_faces + if "_vertices" in state and state["_vertices"] is not None: + vertices = [] + for vertex_state in state["_vertices"]: + vertex = DFVertex.__new__(DFVertex) + vertex.__setstate__(vertex_state) + vertices.append(vertex) + state["_vertices"] = vertices + if "_joints" in state and state["_joints"] is not None: + joints = [] + for joint_state in state["_joints"]: + joint = DFJoint.__new__(DFJoint) + joint.__setstate__(joint_state) + joints.append(joint) + state["_joints"] = joints + if "_center" in state and state["_center"] is not None: + state["_center"] = rg.Point3d.FromJSON(state["_center"]) + self.__dict__.update(state) + + def __repr__(self): + return f"Beam: {self.name}, Faces: {len(self.faces)}" + def deepcopy(self): return DFBeam(self.name, [face.deepcopy() for face in self.faces]) - @classmethod def from_brep_face(cls, brep, is_roundwood=False): """ @@ -346,9 +450,6 @@ def to_mesh(self, max_edge_length): mesh.Compact() return mesh - def __repr__(self): - return f"Beam: {self.name}, Faces: {len(self.faces)}" - @property def uuid(self): return self.__uuid @@ -408,27 +509,78 @@ class DFAssembly: name: str def __post_init__(self): - self.beams = self.beams + self.beams: typing.List[DFBeam] = self.beams for idx, beam in enumerate(self.beams): beam._index_assembly = idx - self.__uuid = uuid.uuid4().int + self.__uuid: int = uuid.uuid4().int - self.name = self.name or "Unnamed Assembly" + self.name: str = self.name or "Unnamed Assembly" self._all_jointfaces: typing.List[DFFace] = [] self._all_sidefaces: typing.List[DFFace] = [] self._all_vertices: typing.List[DFVertex] = [] self._all_joints: typing.List[DFJoint] = [] - for beam in self.beams: - if beam.is_roundwood: - self.contains_cylinders = True - break - else: - self.contains_cylinders = False - - self._mass_center = None + self.contains_cylinders: bool = any(beam.is_roundwood for beam in self.beams) + + self._mass_center: rg.Point3d = None + + def __getstate__(self): + state = self.__dict__.copy() + if "beams" in state and state["beams"] is not None: + state["beams"] = [beam.__getstate__() for beam in self.beams] + if "_mass_center" in state and state["_mass_center"] is not None: + state["_mass_center"] = self._mass_center.ToJSON(SerializationOptions()) + if "_all_jointfaces" in state and state["_all_jointfaces"] is not None: + state["_all_jointfaces"] = [face.__getstate__() for face in state["_all_jointfaces"]] + if "_all_sidefaces" in state and state["_all_sidefaces"] is not None: + state["_all_sidefaces"] = [face.__getstate__() for face in state["_all_sidefaces"]] + if "_all_vertices" in state and state["_all_vertices"] is not None: + state["_all_vertices"] = [vertex.__getstate__() for vertex in state["_all_vertices"]] + if "_all_joints" in state and state["_all_joints"] is not None: + state["_all_joints"] = [joint.__getstate__() for joint in state["_all_joints"]] + return state + + def __setstate__(self, state: typing.Dict): + if "beams" in state and state["beams"] is not None: + beams = [] + for beam_state in state["beams"]: + beam = DFBeam.__new__(DFBeam) + beam.__setstate__(beam_state) + beams.append(beam) + state["beams"] = beams + if "_mass_center" in state and state["_mass_center"] is not None: + state["_mass_center"] = rg.Point3d.FromJSON(state["_mass_center"]) + if "_all_jointfaces" in state and state["_all_jointfaces"] is not None: + joint_faces = [] + for face_state in state["_all_jointfaces"]: + face = DFFace.__new__(DFFace) + face.__setstate__(face_state) + joint_faces.append(face) + state["_all_jointfaces"] = joint_faces + if "_all_sidefaces" in state and state["_all_sidefaces"] is not None: + side_faces = [] + for face_state in state["_all_sidefaces"]: + face = DFFace.__new__(DFFace) + face.__setstate__(face_state) + side_faces.append(face) + state["_all_sidefaces"] = side_faces + if "_all_vertices" in state and state["_all_vertices"] is not None: + vertices = [] + for vertex_state in state["_all_vertices"]: + vertex = DFVertex.__new__(DFVertex) + vertex.__setstate__(vertex_state) + vertices.append(vertex) + state["_all_vertices"] = vertices + if "_all_joints" in state and state["_all_joints"] is not None: + joints = [] + for joint_state in state["_all_joints"]: + joint = DFJoint.__new__(DFJoint) + joint.__setstate__(joint_state) + joints.append(joint) + state["_all_joints"] = joints + self.__dict__.update(state) def __repr__(self): return f"Assembly: {self.name}, Beams: {len(self.beams)}" diff --git a/tests/integration_tests/pybinds_tests/test_pybind_units.py b/tests/integration_tests/pybinds_tests/test_pybind_units.py index 5397f4ec..6caed2d9 100644 --- a/tests/integration_tests/pybinds_tests/test_pybind_units.py +++ b/tests/integration_tests/pybinds_tests/test_pybind_units.py @@ -90,6 +90,15 @@ def test_DFPointCloud_load_from_PLY(): assert pc.normals.__len__() == 7379, "DFPointCloud should have 7379 normals" assert pc.colors.__len__() == 7379, "DFPointCloud should have 7379 colors" +def test_DFPointCloud_save_to_PLY(): + pc = dfb.dfb_geometry.DFPointCloud() + pc.load_from_PLY(get_ply_cloud_roof_quarter_path()) + + temp_ply_path = os.path.join(os.path.dirname(__file__), "temp_ply.ply") + pc.save_to_PLY(temp_ply_path) + assert os.path.exists(temp_ply_path), "The PLY file should be saved to the specified path" + os.remove(temp_ply_path) + @pytest.fixture def create_DFPointCloudSampleRoof(): df_pcd = dfb.dfb_geometry.DFPointCloud() diff --git a/tests/unit_tests/DFPointCloudTest.cc b/tests/unit_tests/DFPointCloudTest.cc index 6cd19791..d6d669d5 100644 --- a/tests/unit_tests/DFPointCloudTest.cc +++ b/tests/unit_tests/DFPointCloudTest.cc @@ -59,6 +59,16 @@ TEST_F(DFPointCloudTestFixture, LoadFromPLY) { EXPECT_EQ(dfPointCloud.GetNumNormals(), 7379); } +TEST_F(DFPointCloudTestFixture, SaveToPLY) { + std::string path = "./test_save.ply"; + dfPointCloud.SaveToPLY(path); + diffCheck::geometry::DFPointCloud dfPointCloud2; + dfPointCloud2.LoadFromPLY(path); + EXPECT_EQ(dfPointCloud.GetNumPoints(), dfPointCloud2.GetNumPoints()); + EXPECT_EQ(dfPointCloud.GetNumColors(), dfPointCloud2.GetNumColors()); + EXPECT_EQ(dfPointCloud.GetNumNormals(), dfPointCloud2.GetNumNormals()); +} + //------------------------------------------------------------------------- // properties //------------------------------------------------------------------------- diff --git a/unnamed.gh b/unnamed.gh deleted file mode 100644 index fdfe0ca6..00000000 Binary files a/unnamed.gh and /dev/null differ