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/geometry/DFPointCloud.cc b/src/diffCheck/geometry/DFPointCloud.cc index 0277b802..eb767dd0 100644 --- a/src/diffCheck/geometry/DFPointCloud.cc +++ b/src/diffCheck/geometry/DFPointCloud.cc @@ -256,7 +256,32 @@ namespace diffCheck::geometry for(size_t i = 0; i < nComponents; ++i) { - principalAxes.push_back(sortedClustersBySize[i].second); + if(principalAxes.size() == 0) + { + principalAxes.push_back(sortedClustersBySize[i].second); + } + else + { + bool isAlreadyPresent = false; + for (const auto& axis : principalAxes) + { + double dotProduct = std::abs(axis.dot(sortedClustersBySize[i].second)); + if (std::abs(dotProduct) > 0.7) // Threshold to consider as similar direction + { + isAlreadyPresent = true; + break; + } + } + if (!isAlreadyPresent) + { + principalAxes.push_back(sortedClustersBySize[i].second); + } + } + } + if (principalAxes.size() < 2) // Fallback to OBB if k-means fails to provide enough distinct axes + { + open3d::geometry::OrientedBoundingBox obb = this->Cvt2O3DPointCloud()->GetOrientedBoundingBox(); + principalAxes = {obb.R_.col(0), obb.R_.col(1), obb.R_.col(2)}; } return principalAxes; } diff --git a/src/gh/components/DF_main_pc_axes/code.py b/src/gh/components/DF_main_pc_axes/code.py deleted file mode 100644 index ce398c2c..00000000 --- a/src/gh/components/DF_main_pc_axes/code.py +++ /dev/null @@ -1,65 +0,0 @@ -#! python3 - -from diffCheck import diffcheck_bindings -from diffCheck import df_cvt_bindings -from diffCheck import df_poses - -import Rhino -from Grasshopper.Kernel import GH_RuntimeMessageLevel as RML - -from ghpythonlib.componentbase import executingcomponent as component - -import System - -class DFMainPCAxes(component): - def RunScript(self, - i_clouds: System.Collections.Generic.List[Rhino.Geometry.PointCloud], - i_reset: bool): - - planes = [] - all_poses_in_time = df_poses.DFPosesAssembly() - if i_reset: - all_poses_in_time.reset() - return None, None - - previous_poses = all_poses_in_time.get_last_poses() - all_poses_this_time = [] - for i, cloud in enumerate(i_clouds): - df_cloud = df_cvt_bindings.cvt_rhcloud_2_dfcloud(cloud) - if df_cloud is None: - return None, None - if not df_cloud.has_normals(): - ghenv.Component.AddRuntimeMessage(RML.Error, f"Point cloud {i} has no normals. Please compute the normals.") # noqa: F821 - - df_points = df_cloud.get_axis_aligned_bounding_box() - df_point = (df_points[0] + df_points[1]) / 2 - rh_point = Rhino.Geometry.Point3d(df_point[0], df_point[1], df_point[2]) - vectors = [] - # Get the main axes of the point cloud - previous_pose = previous_poses[i] if previous_poses else None - if previous_pose: - rh_previous_xDirection = Rhino.Geometry.Vector3d(previous_pose.xDirection[0], previous_pose.xDirection[1], previous_pose.xDirection[2]) - rh_previous_yDirection = Rhino.Geometry.Vector3d(previous_pose.yDirection[0], previous_pose.yDirection[1], previous_pose.yDirection[2]) - n_faces = all_poses_in_time.poses_per_element_dictionary[f"element_{i}"].n_faces - else: - rh_previous_xDirection = None - rh_previous_yDirection = None - n_faces = len(diffcheck_bindings.dfb_segmentation.DFSegmentation.segment_by_normal(df_cloud, 12, int(len(df_cloud.points)/20), True, int(len(df_cloud.points)/200), 1)) - - axes = df_cloud.get_principal_axes(n_faces) - for axe in axes: - vectors.append(Rhino.Geometry.Vector3d(axe[0], axe[1], axe[2])) - - new_xDirection, new_yDirection = df_poses.select_vectors(vectors, rh_previous_xDirection, rh_previous_yDirection) - - pose = df_poses.DFPose( - origin = [rh_point.X, rh_point.Y, rh_point.Z], - xDirection = [new_xDirection.X, new_xDirection.Y, new_xDirection.Z], - yDirection = [new_yDirection.X, new_yDirection.Y, new_yDirection.Z]) - all_poses_this_time.append(pose) - plane = Rhino.Geometry.Plane(origin = rh_point, xDirection=new_xDirection, yDirection=new_yDirection) - planes.append(plane) - - all_poses_in_time.add_step(all_poses_this_time) - - return [planes, all_poses_in_time] diff --git a/src/gh/components/DF_pose_estimation/code.py b/src/gh/components/DF_pose_estimation/code.py new file mode 100644 index 00000000..4ab24063 --- /dev/null +++ b/src/gh/components/DF_pose_estimation/code.py @@ -0,0 +1,68 @@ +#! python3 + +from diffCheck import df_cvt_bindings +from diffCheck import df_poses + +import Rhino +from Grasshopper.Kernel import GH_RuntimeMessageLevel as RML + +from ghpythonlib.componentbase import executingcomponent as component +import System + + +class DFPoseEstimation(component): + def RunScript(self, + i_clouds: System.Collections.Generic.List[Rhino.Geometry.PointCloud], + i_assembly, + i_save: bool, + i_reset: bool): + + # ensure assembly has enough beams + if len(i_assembly.beams) < len(i_clouds): + ghenv.Component.AddRuntimeMessage(RML.Warning, "Assembly has fewer beams than input clouds") # noqa: F821 + return None, None + + planes = [] + all_poses_in_time = df_poses.DFPosesAssembly() + if i_reset: + all_poses_in_time.reset() + return None, None + + all_poses_this_time = [] + for i, cloud in enumerate(i_clouds): + try: + df_cloud = df_cvt_bindings.cvt_rhcloud_2_dfcloud(cloud) + if df_cloud is None: + return None, None + if not df_cloud.has_normals(): + ghenv.Component.AddRuntimeMessage(RML.Error, f"Point cloud {i} has no normals. Please compute the normals.") # noqa: F821 + + df_points = df_cloud.get_axis_aligned_bounding_box() + df_point = (df_points[0] + df_points[1]) / 2 + rh_point = Rhino.Geometry.Point3d(df_point[0], df_point[1], df_point[2]) + + axes = df_cloud.get_principal_axes(3) + vectors = [] + for axe in axes: + vectors.append(Rhino.Geometry.Vector3d(axe[0], axe[1], axe[2])) + + new_xDirection, new_yDirection = df_poses.select_vectors(vectors, i_assembly.beams[i].plane.XAxis, i_assembly.beams[i].plane.YAxis) + + pose = df_poses.DFPose( + origin = [rh_point.X, rh_point.Y, rh_point.Z], + xDirection = [new_xDirection.X, new_xDirection.Y, new_xDirection.Z], + yDirection = [new_yDirection.X, new_yDirection.Y, new_yDirection.Z]) + all_poses_this_time.append(pose) + plane = Rhino.Geometry.Plane(origin = rh_point, xDirection=new_xDirection, yDirection=new_yDirection) + planes.append(plane) + except Exception as e: + # Any unexpected error on this cloud, skip it and keep going + ghenv.Component.AddRuntimeMessage(RML.Error, f"Cloud {i}: processing failed ({e}); skipping.") # noqa: F821 + planes.append(None) + all_poses_this_time.append(None) + continue + + if i_save: + all_poses_in_time.add_step(all_poses_this_time) + + return [planes, all_poses_in_time.to_gh_tree()] diff --git a/src/gh/components/DF_main_pc_axes/icon.png b/src/gh/components/DF_pose_estimation/icon.png similarity index 100% rename from src/gh/components/DF_main_pc_axes/icon.png rename to src/gh/components/DF_pose_estimation/icon.png diff --git a/src/gh/components/DF_main_pc_axes/metadata.json b/src/gh/components/DF_pose_estimation/metadata.json similarity index 62% rename from src/gh/components/DF_main_pc_axes/metadata.json rename to src/gh/components/DF_pose_estimation/metadata.json index 056d13c6..60d1f363 100644 --- a/src/gh/components/DF_main_pc_axes/metadata.json +++ b/src/gh/components/DF_pose_estimation/metadata.json @@ -5,7 +5,7 @@ "subcategory": "PointCloud", "description": "This compoment calculates the pose of a list of point clouds.", "exposure": 4, - "instanceGuid": "22b0c6fc-bc16-4ff5-b789-e99776277f65", + "instanceGuid": "a13c4414-f5df-46e6-beae-7054bb9c3e72", "ghpython": { "hideOutput": true, "hideInput": true, @@ -16,7 +16,7 @@ { "name": "i_clouds", "nickname": "i_clouds", - "description": "clouds whose main axes are to be calculated", + "description": "clouds whose pose is to be calculated", "optional": false, "allowTreeAccess": true, "showTypeHints": true, @@ -25,6 +25,18 @@ "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", @@ -36,6 +48,18 @@ "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": [ @@ -50,7 +74,7 @@ { "name": "o_history", "nickname": "o_history", - "description": "The history of poses of all the elements.", + "description": "The history of poses per elements.", "optional": false, "sourceCount": 0, "graft": false 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/diffCheck/diffCheck/df_geometries.py b/src/gh/diffCheck/diffCheck/df_geometries.py index 821a0849..541efcd5 100644 --- a/src/gh/diffCheck/diffCheck/df_geometries.py +++ b/src/gh/diffCheck/diffCheck/df_geometries.py @@ -101,6 +101,7 @@ def __post_init__(self): self._center: DFVertex = None # the normal of the face self._normal: typing.List[float] = None + self._area: float = None def __getstate__(self): state = self.__dict__.copy() @@ -261,6 +262,12 @@ def normal(self): self._normal = [normal_rg.X, normal_rg.Y, normal_rg.Z] return self._normal + @property + def area(self): + if self._area is None: + self._area = self.to_brep_face().ToBrep().GetArea() + return self._area + @dataclass class DFJoint: """ @@ -375,6 +382,7 @@ def __post_init__(self): self._center: rg.Point3d = None self._axis: rg.Line = self.compute_axis() + self.plane: rg.Plane = self.compute_plane() self._length: float = self._axis.Length self.__uuid = uuid.uuid4().int @@ -506,6 +514,28 @@ def compute_axis(self, is_unitized: bool = True) -> rg.Line: return axis_ln + def compute_plane(self) -> rg.Plane: + """ + This function computes the plane of the beam based on its axis and the first joint's center. + The plane is oriented along the beam's axis. + + :return plane: The plane of the beam + """ + if not self.joints: + raise ValueError("The beam has no joints to compute a plane") + + #main axis as defined above + main_direction = self.compute_axis().Direction + + #secondary axis as normal to the largest face of the beam + largest_face = max(self.faces, key=lambda f: f.area) + secondary_axis = largest_face.normal + secondary_vector = rg.Vector3d(secondary_axis[0], secondary_axis[1], secondary_axis[2]) + first_vector = rg.Vector3d.CrossProduct(main_direction, secondary_vector) + origin = self.center + + return rg.Plane(origin, first_vector, secondary_vector) + def compute_joint_distances_to_midpoint(self) -> typing.List[float]: """ This function computes the distances from the center of the beam to each joint. diff --git a/src/gh/diffCheck/diffCheck/df_poses.py b/src/gh/diffCheck/diffCheck/df_poses.py index 397a3ffb..adaeee16 100644 --- a/src/gh/diffCheck/diffCheck/df_poses.py +++ b/src/gh/diffCheck/diffCheck/df_poses.py @@ -1,7 +1,17 @@ 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: """ @@ -11,20 +21,29 @@ class DFPose: 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_dictionnary: dict + 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_dictionnary[f"pose_{step_number}"] = pose + self.poses_dictionary[f"pose_{step_number}"] = pose def set_n_faces(self, n_faces: int): """ @@ -35,7 +54,7 @@ def set_n_faces(self, n_faces: int): @dataclass class DFPosesAssembly: n_step: int = 0 - poses_per_element_dictionary: dict = field(default_factory=lambda: rh_sticky_dict) + 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. @@ -46,7 +65,7 @@ def __post_init__(self): """ lengths = [] for element in self.poses_per_element_dictionary: - lengths.append(len(self.poses_per_element_dictionary[element].poses_dictionnary)) + 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]): @@ -66,7 +85,7 @@ def get_last_poses(self): 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_dictionnary[f"pose_{self.n_step-1}"]) + 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): @@ -74,7 +93,10 @@ def reset(self): Reset the assembly poses to the initial state. """ self.n_step = 0 - rh_sticky_dict.clear() + # 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): """ @@ -83,6 +105,18 @@ def save(self, file_path: str): 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): """ @@ -111,8 +145,8 @@ def select_vectors(vectors, previous_xDirection, previous_yDirection): new_yDirection = sorted_vectors_by_perpendicularity[0] - compute_dot_product(sorted_vectors_by_perpendicularity[0], new_xDirection) * new_xDirection new_yDirection.Unitize() else: - new_xDirection = vectors[0] + sorted_vectors = sorted(vectors[1:], key=lambda v: compute_dot_product(v, new_xDirection)**2) - new_yDirection = sorted_vectors[0] - compute_dot_product(vectors[1], new_xDirection) * new_xDirection + new_yDirection = sorted_vectors[0] - compute_dot_product(sorted_vectors[0], new_xDirection) * new_xDirection new_yDirection.Unitize() return new_xDirection, new_yDirection