diff --git a/README.md b/README.md index 4032c2ac..ec2fac43 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ Open your Grasshopper canvas and search for the `DF` components! The full documentation, with tutorials, automatic documentation for GHComponents and PythonAPI is available [here](https://diffcheckorg.github.io/diffCheck/). + ## Roadmap ```mermaid diff --git a/src/gh/components/DF_csv_exporter/code.py b/src/gh/components/DF_csv_exporter/code.py index 877944dd..b27f5671 100644 --- a/src/gh/components/DF_csv_exporter/code.py +++ b/src/gh/components/DF_csv_exporter/code.py @@ -66,44 +66,29 @@ def __init__(self): "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 - - if self.prefix == "beam": - return idx - elif self.prefix == "joint": - for idx_b, beam in enumerate(i_result.assembly.beams): - for idx_j, joint in enumerate(beam.joints): - if counter == idx: - return f"{idx_b}--{idx_j}--{0}" - counter += 1 - elif self.prefix == "joint_face": - for idx_b, beam in enumerate(i_result.assembly.beams): - for idx_j, joint in enumerate(beam.joints): - for idx_f, face in enumerate(joint.faces): - if counter == idx: - return f"{idx_b}--{idx_j}--{idx_f}" - counter += 1 - def _prepare_row(self, idx: int, i_result: DFVizResults - ) -> typing.Dict: + ) -> typing.Dict[str, typing.Any]: """ Convert the results contained in the DFVizResults object to a dict to be written in 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 + :return: Dict of values containing 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] + return { + f"{self.prefix} id": i_result.find_id(idx), + "invalid_type": invalid_type, + "min_deviation": invalid_type, + "max_deviation": invalid_type, + "std_deviation": invalid_type, + "rmse": invalid_type, + "mean": invalid_type + } distances = [round(value, 4) for value in i_result.distances[idx]] min_dev = round(i_result.distances_min_deviation[idx], 4) @@ -112,8 +97,8 @@ def _prepare_row(self, rmse = round(i_result.distances_rmse[idx], 4) mean = round(i_result.distances_mean[idx], 4) - row: typing.Dict = { - f"{self.prefix} id": self._get_id(idx, i_result), + row: typing.Dict[str, typing.Any] = { + f"{self.prefix} id": i_result.find_id(idx), "distances": distances, "min_deviation": min_dev, "max_deviation": max_dev, @@ -121,18 +106,38 @@ def _prepare_row(self, "rmse": rmse, "mean": mean } + + # Add extra geometric info based on analysis type here: + if i_result.analysis_type == "beam": + row.update({ + "beam_length": i_result.assembly.beams[idx].length + }) + elif i_result.analysis_type == "joint": + # NB:: for conviniency, if there is only one beam, we add the lenght of the beam i nthe joint csv analysis output + if i_result.assembly.has_only_one_beam: + row.update({ + "beam_length": i_result.assembly.beams[0].length + }) + row.update({ + "joint_distance_to_beam_midpoint": i_result.assembly.compute_all_joint_distances_to_midpoint()[idx] + }) + elif i_result.analysis_type == "joint_face": + row.update({ + "jointface_angle": i_result.assembly.compute_all_joint_angles()[idx] + }) + return row def _write_csv(self, csv_path: str, - rows: typing.List[typing.Dict], + rows: typing.List[typing.Dict[str, typing.Any]], 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 rows: List of dictionaries containing 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 @@ -157,7 +162,7 @@ def RunScript(self, i_file_name: str, i_export_seperate_files: bool, i_export_distances: bool, - i_result): + i_result: DFVizResults) -> None: csv_analysis_path: str = None csv_distances_path: str = None @@ -165,12 +170,7 @@ def RunScript(self, if i_dump: os.makedirs(i_export_dir, exist_ok=True) - if len(i_result.assembly.beams) == len(i_result.source): - self.prefix = "beam" - elif len(i_result.assembly.all_joints) == len(i_result.source): - self.prefix = "joint" - elif len(i_result.assembly.all_joint_faces) == len(i_result.source): - self.prefix = "joint_face" + self.prefix = i_result.analysis_type if i_export_seperate_files: for idx in range(len(i_result.source)): diff --git a/src/gh/components/DF_export_results/code.py b/src/gh/components/DF_export_results/code.py index a051167f..02abf349 100644 --- a/src/gh/components/DF_export_results/code.py +++ b/src/gh/components/DF_export_results/code.py @@ -3,7 +3,7 @@ import System from ghpythonlib.componentbase import executingcomponent as component -import Grasshopper as gh +import Grasshopper def add_button(self, @@ -24,7 +24,7 @@ def add_button(self, """ param = ghenv.Component.Params.Input[indx] # noqa: F821 if param.SourceCount == 0: - button = gh.Kernel.Special.GH_ButtonObject() + button = Grasshopper.Kernel.Special.GH_ButtonObject() button.NickName = "" button.Description = "" button.CreateAttributes() @@ -33,7 +33,7 @@ def add_button(self, Y_param_coord - (button.Attributes.Bounds.Height / 2 - 0.1) ) button.Attributes.ExpireLayout() - gh.Instances.ActiveCanvas.Document.AddObject(button, False) + Grasshopper.Instances.ActiveCanvas.Document.AddObject(button, False) ghenv.Component.Params.Input[indx].AddSource(button) # noqa: F821 class DFExportResults(component): @@ -52,7 +52,8 @@ 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 + o_path = None if i_dump: - i_results.dump_serialization(i_export_dir) + o_path = i_results.dump_serialization(i_export_dir) - return None + return o_path diff --git a/src/gh/components/DF_export_results/metadata.json b/src/gh/components/DF_export_results/metadata.json index 15978295..7fb6d194 100644 --- a/src/gh/components/DF_export_results/metadata.json +++ b/src/gh/components/DF_export_results/metadata.json @@ -50,6 +50,15 @@ "typeHintID": "ghdoc" } ], - "outputParameters": [] + "outputParameters": [ + { + "name": "o_path", + "nickname": "o_path", + "description": "The file path of the generated .diffCheck files.", + "optional": false, + "sourceCount": 0, + "graft": false + } + ] } } \ No newline at end of file diff --git a/src/gh/components/DF_preview_assembly/code.py b/src/gh/components/DF_preview_assembly/code.py index 7a99cebe..6bf73ebe 100644 --- a/src/gh/components/DF_preview_assembly/code.py +++ b/src/gh/components/DF_preview_assembly/code.py @@ -2,8 +2,6 @@ import System -import typing - import Rhino.Geometry as rg from ghpythonlib.componentbase import executingcomponent as component @@ -93,18 +91,7 @@ def DrawViewportWires(self, args): ## DFBeams ####################################### if len(self._dfassembly.beams) > 1: - # beams' obb - df_cloud = diffCheck.diffcheck_bindings.dfb_geometry.DFPointCloud() - vertices_pt3d_rh : typing.List[rg.Point3d] = [vertex.to_rg_point3d() for vertex in beam.vertices] - df_cloud.points = [np.array([vertex.X, vertex.Y, vertex.Z]).reshape(3, 1) for vertex in vertices_pt3d_rh] - obb: rg.Brep = diffCheck.df_cvt_bindings.cvt_dfOBB_2_rhbrep(df_cloud.get_tight_bounding_box()) - # args.Display.DrawBrepWires(obb, System.Drawing.Color.Red) ## keep for debugging - - # axis arrow - obb_faces = obb.Faces - obb_faces = sorted(obb_faces, key=lambda face: rg.AreaMassProperties.Compute(face).Area) - obb_endfaces = obb_faces[:2] - beam_axis = rg.Line(obb_endfaces[0].GetBoundingBox(True).Center, obb_endfaces[1].GetBoundingBox(True).Center) + beam_axis = beam.axis extension_length = 0.5 * diffCheck.df_util.get_doc_2_meters_unitf() beam_axis.Extend(extension_length, extension_length) args.Display.DrawArrow(beam_axis, System.Drawing.Color.Magenta) diff --git a/src/gh/diffCheck/diffCheck/df_error_estimation.py b/src/gh/diffCheck/diffCheck/df_error_estimation.py index 4607e1e6..b9ab03c0 100644 --- a/src/gh/diffCheck/diffCheck/df_error_estimation.py +++ b/src/gh/diffCheck/diffCheck/df_error_estimation.py @@ -26,6 +26,8 @@ class NumpyEncoder(json.JSONEncoder): def default(self, obj): if isinstance(obj, np.ndarray): return obj.tolist() + elif isinstance(obj, Enum): + return obj.value # or use obj.name if you prefer return super().default(obj) class DFInvalidData(Enum): @@ -41,7 +43,7 @@ class DFInvalidData(Enum): class DFVizResults: """ - This class compiles the resluts of the error estimation into one object + This class compiles the results of the error estimation into one object """ __serial_file_extenion: str = ".diffCheck" @@ -61,6 +63,8 @@ def __init__(self, assembly): self.distances_sd_deviation = [] self.distances = [] + self._analysis_type: str = None + def __repr__(self): return f"DFVizResults of({self.assembly})" @@ -109,7 +113,8 @@ def dump_serialization(self, dir: str) -> str: 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}") + result_type: str = self.analysis_type + serial_file_path = os.path.join(dir, f"{assembly_name}_{result_type}_{timestamp}{self.__serial_file_extenion}") try: with open(serial_file_path, "w") as f: @@ -135,6 +140,49 @@ def load_serialization(file_path: str) -> 'DFVizResults': raise e return obj + def _compute_dfresult_type(self): + """ + Detect if the DFVizResults object contains results of beam, joint of joint_face level analysis + """ + # check that source and target have the same length + if len(self.source) != len(self.target): + raise ValueError("Source and target have different length, cannot determine the type of analysis") + if len(self.assembly.beams) == len(self.source): + self._analysis_type = "beam" + elif len(self.assembly.all_joints) == len(self.source): + self._analysis_type = "joint" + elif len(self.assembly.all_joint_faces) == len(self.source): + self._analysis_type = "joint_face" + return self._analysis_type + + def find_id(self, idx: int,) -> str: + """ + Return the ID in str format of the element. This func is used during + the csv export. With the following format: + - beam: idx + - joint: idx_b--idx_j--0 + - joint_face: idx_b--idx_j--idx_f + + :param idx: the index of the element + """ + counter = 0 + + if self.analysis_type == "beam": + return str(idx) + elif self.analysis_type == "joint": + for idx_b, beam in enumerate(self.assembly.beams): + for idx_j, joint in enumerate(beam.joints): + if counter == idx: + return f"{idx_b}--{idx_j}--{0}" + counter += 1 + elif self.analysis_type == "joint_face": + for idx_b, beam in enumerate(self.assembly.beams): + for idx_j, joint in enumerate(beam.joints): + for idx_f, face in enumerate(joint.faces): + if counter == idx: + return f"{idx_b}--{idx_j}--{idx_f}" + counter += 1 + return "" def add(self, source, target, distances, sanity_check: DFInvalidData = DFInvalidData.VALID): @@ -215,6 +263,11 @@ def filter_values_based_on_valuetype(self, settings): def is_source_cloud(self): return type(self.source[0]) is diffcheck_bindings.dfb_geometry.DFPointCloud + @property + def analysis_type(self): + self._analysis_type = self._compute_dfresult_type() + return self._analysis_type + # FIXME: ths is currently broken, we need to fix it def df_cloud_2_df_cloud_comparison( assembly: DFAssembly, diff --git a/src/gh/diffCheck/diffCheck/df_geometries.py b/src/gh/diffCheck/diffCheck/df_geometries.py index 44e502a6..821a0849 100644 --- a/src/gh/diffCheck/diffCheck/df_geometries.py +++ b/src/gh/diffCheck/diffCheck/df_geometries.py @@ -37,13 +37,14 @@ def __post_init__(self): self.__uuid = uuid.uuid4().int def __getstate__(self): - return self.__dict__ + state = self.__dict__.copy() + return state 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}" + return f"DFVertex: X={self.x}, Y={self.y}, Z={self.z}" def __hash__(self): return hash((self.x, self.y, self.z)) @@ -97,6 +98,9 @@ def __post_init__(self): # if df_face is created from a rhino brep face, we store the rhino brep face self._rh_brepface: rg.BrepFace = None self.is_roundwood = False + self._center: DFVertex = None + # the normal of the face + self._normal: typing.List[float] = None def __getstate__(self): state = self.__dict__.copy() @@ -105,6 +109,8 @@ def __getstate__(self): # 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()) + if "_center" in state and state["_center"] is not None: + state["_center"] = state["_center"].__getstate__() return state def __setstate__(self, state: typing.Dict): @@ -116,6 +122,8 @@ def __setstate__(self, state: typing.Dict): vertex.__setstate__(vertex_state) all_loops.append(loop) state["all_loops"] = all_loops + if "_center" in state and state["_center"] is not None: + state["_center"] = DFVertex.__new__(DFVertex).__setstate__(state["_center"]) # 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] @@ -123,7 +131,6 @@ def __setstate__(self, state: typing.Dict): 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)}" @@ -240,6 +247,20 @@ def is_joint(self): def uuid(self): return self.__uuid + @property + def center(self): + if self._center is None: + vertices = [vertex.to_rg_point3d() for vertex in self.all_loops[0]] + self._center = DFVertex.from_rg_point3d(rg.BoundingBox(vertices).Center) + return self._center + + @property + def normal(self): + if self._normal is None: + normal_rg = self.to_brep_face().NormalAt(0, 0) + self._normal = [normal_rg.X, normal_rg.Y, normal_rg.Z] + return self._normal + @dataclass class DFJoint: """ @@ -256,11 +277,16 @@ def __post_init__(self): # this is an automatic identifier self.__uuid = uuid.uuid4().int + # the center from the AABB of the joint + self._center: DFVertex = None + self.distance_to_beam_midpoint: float = None 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 "_center" in state and state["_center"] is not None: + state["_center"] = state["_center"].__getstate__() return state def __setstate__(self, state: typing.Dict): @@ -271,6 +297,8 @@ def __setstate__(self, state: typing.Dict): face.__setstate__(face_state) faces.append(face) state["faces"] = faces + if "_center" in state and state["_center"] is not None: + state["_center"] = DFVertex.__new__(DFVertex).__setstate__(state["_center"]) self.__dict__.update(state) def __repr__(self): @@ -313,6 +341,17 @@ def uuid(self): """ It retrives the automatic identifier, not the one of the joint in the beam """ return self.__uuid + @property + def center(self): + if self._center is None: + vertices = [] + for face in self.faces: + vertices.extend(face.all_loops[0]) + vertices = [vertex.to_rg_point3d() for vertex in vertices] + self._center = DFVertex.from_rg_point3d(rg.BoundingBox(vertices).Center) + return self._center + + @dataclass class DFBeam: """ @@ -330,17 +369,18 @@ def __post_init__(self): self._joint_faces: typing.List[DFFace] = [] self._side_faces: typing.List[DFFace] = [] self._vertices: typing.List[DFVertex] = [] - 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: int = None self._center: rg.Point3d = None + self._axis: rg.Line = self.compute_axis() + self._length: float = self._axis.Length + + self.__uuid = uuid.uuid4().int self.__id = uuid.uuid4().int + def __getstate__(self): state = self.__dict__.copy() if "faces" in state and state["faces"] is not None: @@ -353,8 +393,17 @@ def __getstate__(self): 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 "_axis" in state and state["_axis"] is not None: + state["_axis"] = [ + state["_axis"].From.X, + state["_axis"].From.Y, + state["_axis"].From.Z, + state["_axis"].To.X, + state["_axis"].To.Y, + state["_axis"].To.Z + ] if "_center" in state and state["_center"] is not None: - state["_center"] = self._center.ToJSON(SerializationOptions()) + state["_center"] = DFVertex(self._center.X, self._center.Y, self._center.Z).__getstate__() return state def __setstate__(self, state: typing.Dict): @@ -393,8 +442,15 @@ def __setstate__(self, state: typing.Dict): joint.__setstate__(joint_state) joints.append(joint) state["_joints"] = joints + if "_axis" in state and state["_axis"] is not None: + state["_axis"] = rg.Line( + rg.Point3d(state["_axis"][0], state["_axis"][1], state["_axis"][2]), + rg.Point3d(state["_axis"][3], state["_axis"][4], state["_axis"][5]) + ) if "_center" in state and state["_center"] is not None: - state["_center"] = rg.Point3d.FromJSON(state["_center"]) + center = DFVertex.__new__(DFVertex) + center.__setstate__(state["_center"]) + state["_center"] = rg.Point3d(center.x, center.y, center.z) self.__dict__.update(state) def __repr__(self): @@ -403,6 +459,113 @@ def __repr__(self): def deepcopy(self): return DFBeam(self.name, [face.deepcopy() for face in self.faces]) + def compute_axis(self, is_unitized: bool = True) -> rg.Line: + """ + This is an utility function that computes the axis of the beam as a line. + The axis is calculated as the vector passing through the two most distance joint's centroids. + + :return axis: The axis of the beam as a line + """ + joints = self.joints + joint1 = None + joint2 = None + if len(joints) > 2: + joint1 = joints[0] + joint2 = joints[1] + max_distance = 0 + for j1 in joints: + for j2 in joints: + distance = rg.Point3d.DistanceTo( + j1.center.to_rg_point3d(), + j2.center.to_rg_point3d()) + if distance > max_distance: + max_distance = distance + joint1 = j1 + joint2 = j2 + else: + #get the two farthest dffaces for simplicity + df_faces = [face for face in self.faces] + max_distance = 0 + for i in range(len(df_faces)): + for j in range(i+1, len(df_faces)): + distance = rg.Point3d.DistanceTo( + df_faces[i].center.to_rg_point3d(), + df_faces[j].center.to_rg_point3d()) + if distance > max_distance: + max_distance = distance + joint1 = df_faces[i] + joint2 = df_faces[j] + + if joint1 is None or joint2 is None: + raise ValueError("The beam axis cannot be calculated") + + axis_ln = rg.Line( + joint1.center.to_rg_point3d(), + joint2.center.to_rg_point3d() + ) + + return axis_ln + + def compute_joint_distances_to_midpoint(self) -> typing.List[float]: + """ + This function computes the distances from the center of the beam to each joint. + """ + def _project_point_to_line(point, line): + """ Compute the projection of a point onto a line """ + + line_start = line.From + line_end = line.To + line_direction = rg.Vector3d(line_end - line_start) + + line_direction.Unitize() + + vector_to_point = rg.Vector3d(point - line_start) + dot_product = rg.Vector3d.Multiply(vector_to_point, line_direction) + projected_point = line_start + line_direction * dot_product + + return projected_point + + distances = [] + for idx, joint in enumerate(self.joints): + joint_ctr = joint.center.to_rg_point3d() + ln = self.axis + ln.Extend(self.axis.Length, self.axis.Length) + + projected_point = _project_point_to_line(joint_ctr, ln) + + dist = rg.Point3d.DistanceTo( + self.center, + projected_point + ) + distances.append(dist) + return distances + + def compute_joint_angles(self) -> typing.List[float]: + """ + This function computes the angles between the beam's axis and the joints'jointfaces' normals. + The angles are remapped between 0 and 90 degrees, where -1 indicates the bottom of any half-lap joint. + + :return angles: The angles between the beam's axis and the joints'jointfaces' normals + """ + jointface_angles = [] + for joint in self.joints: + jointfaces_angles = [] + for joint_face in joint.faces: + joint_normal = joint_face.normal + joint_normal = rg.Vector3d(joint_normal[0], joint_normal[1], joint_normal[2]) + angle = rg.Vector3d.VectorAngle(self.axis.Direction, joint_normal) + angle_degree = Rhino.RhinoMath.ToDegrees(angle) + jointfaces_angles.append(angle_degree) + angle_degree = int(angle_degree) + + if angle_degree > 90: + angle_degree = 180 - angle_degree + if angle_degree >= 89 and angle_degree <= 90: + angle_degree = -1 + + jointface_angles.append(angle_degree) + return jointface_angles + @classmethod def from_brep_face(cls, brep, is_roundwood=False): """ @@ -498,6 +661,15 @@ def vertices(self): self._vertices.extend(loop) return self._vertices + @property + def axis(self): + self._axis = self.compute_axis() + return self._axis + + @property + def length(self): + self._length = self._axis.Length + return self._length @dataclass class DFAssembly: @@ -526,6 +698,8 @@ def __post_init__(self): self._mass_center: rg.Point3d = None + self._has_onle_one_beam: bool = False + def __getstate__(self): state = self.__dict__.copy() if "beams" in state and state["beams"] is not None: @@ -603,6 +777,24 @@ def remove_beam(self, beam_assembly_index: int): self.beams.pop(idx) break + def compute_all_joint_distances_to_midpoint(self) -> typing.List[float]: + """ + This function computes the distances from the center of the assembly to each joint. + """ + distances = [] + for beam in self.beams: + distances.extend(beam.compute_joint_distances_to_midpoint()) + return distances + + def compute_all_joint_angles(self) -> typing.List[float]: + """ + This function computes the angles between the beam's axis and the joints'jointfaces' normals. + """ + angles = [] + for beam in self.beams: + angles.extend(beam.compute_joint_angles()) + return angles + def to_xml(self): """ Dump the assembly's meshes to an XML file. On top of the DiffCheck datatypes and structure, @@ -705,3 +897,9 @@ def mass_center(self): @property def uuid(self): return self.__uuid + + @property + def has_only_one_beam(self): + if len(self.beams) == 1: + self._has_onle_one_beam = True + return self._has_onle_one_beam