diff --git a/CHANGELOG.md b/CHANGELOG.md index 6145cd137..69c7361ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +* Added new joint type: Half-lap joint. + ### Changed * Beam transformed geometry with features is available using property `geometry`. diff --git a/src/compas_timber/connections/__init__.py b/src/compas_timber/connections/__init__.py index d125661d7..7402acac9 100644 --- a/src/compas_timber/connections/__init__.py +++ b/src/compas_timber/connections/__init__.py @@ -20,6 +20,7 @@ TButtJoint LButtJoint LMiterJoint + XHalfLapJoint JointTopology ConnectionSolver @@ -47,6 +48,7 @@ from .t_butt import TButtJoint from .l_butt import LButtJoint from .l_miter import LMiterJoint +from .x_halflap import XHalfLapJoint from .solver import JointTopology from .solver import ConnectionSolver from .solver import find_neighboring_beams @@ -59,6 +61,7 @@ "TButtJoint", "LButtJoint", "LMiterJoint", + "XHalfLapJoint", "JointTopology", "ConnectionSolver", "find_neighboring_beams", diff --git a/src/compas_timber/connections/x_halflap.py b/src/compas_timber/connections/x_halflap.py new file mode 100644 index 000000000..2cf0eed49 --- /dev/null +++ b/src/compas_timber/connections/x_halflap.py @@ -0,0 +1,178 @@ +from compas.geometry import Brep +from compas.geometry import Frame +from compas.geometry import Line +from compas.geometry import Plane +from compas.geometry import Point +from compas.geometry import Polyhedron +from compas.geometry import Vector +from compas.geometry import angle_vectors +from compas.geometry import intersection_line_plane +from compas.geometry import intersection_plane_plane +from compas.geometry import length_vector +from compas.geometry import midpoint_point_point + +from compas_timber.parts import BeamBooleanSubtraction +from compas_timber.utils import intersection_line_line_3D + +from .joint import Joint +from .solver import JointTopology + + +class XHalfLapJoint(Joint): + SUPPORTED_TOPOLOGY = JointTopology.TOPO_X + + def __init__(self, beam_a=None, beam_b=None, cut_plane_choice=None, frame=None, key=None): + super(XHalfLapJoint, self).__init__(frame, key) + self.beam_a = beam_a + self.beam_b = beam_b + self.beam_a_key = beam_a.key if beam_a else None + self.beam_b_key = beam_b.key if beam_b else None + self.cut_plane_choice = cut_plane_choice # Decide if Direction of beam_a or beam_b + self.features = [] + + @property + def data(self): + data_dict = { + "beam_a": self.beam_a_key, + "beam_b": self.beam_b_key, + } + data_dict.update(Joint.data.fget(self)) + return data_dict + + @classmethod + def from_data(cls, value): + instance = cls(frame=Frame.from_data(value["frame"]), key=value["key"], cutoff=value["cut_plane_choice"]) + instance.beam_a_key = value["beam_a"] + instance.beam_b_key = value["beam_b"] + instance.cut_plane_choice = value["cut_plane_choice"] + return instance + + @property + def joint_type(self): + return "X-HalfLap" + + @property + def beams(self): + return [self.beam_a, self.beam_b] + + def _cutplane(self): + # Find the Point for the Cut Plane + centerline_a = self.beam_a.centerline + centerline_b = self.beam_b.centerline + max_distance = float("inf") + int_a, int_b = intersection_line_line_3D(centerline_a, centerline_b, max_distance) + int_a, _ = int_a + int_b, _ = int_b + point_cut = Point(*midpoint_point_point(int_a, int_b)) + + # Vector Cross Product + beam_a_start = self.beam_a.centerline_start + beam_b_start = self.beam_b.centerline_start + beam_a_end = self.beam_a.centerline_end + beam_b_end = self.beam_b.centerline_end + centerline_vec_a = Vector.from_start_end(beam_a_start, beam_a_end) + centerline_vec_b = Vector.from_start_end(beam_b_start, beam_b_end) + plane_cut = Plane.from_point_and_two_vectors(point_cut, centerline_vec_a, centerline_vec_b) + + # Flip Cut Plane if its Normal Z-Coordinate is positive + if plane_cut[1][2] > 0: + plane_cut[1] = plane_cut[1] * -1 + + # Cutplane Normal Vector pointing from a and b to Cutplane Origin + cutplane_vector_a = Vector.from_start_end(int_a, point_cut) + cutplane_vector_b = Vector.from_start_end(int_b, point_cut) + + # If Centerlines crossing, take the Cutplane Normal + if length_vector(cutplane_vector_a) < 1e-6: + cutplane_vector_a = plane_cut.normal + if length_vector(cutplane_vector_b) < 1e-6: + cutplane_vector_b = plane_cut.normal * -1 + + return plane_cut, cutplane_vector_a, cutplane_vector_b + + @staticmethod + def _sort_beam_planes(beam, cutplane_vector): + # Sorts the Beam Face Planes according to the Cut Plane + + frames = beam.faces[:4] + planes = [] + planes_angles = [] + for i in frames: + planes.append(Plane.from_frame(i)) + planes_angles.append(angle_vectors(cutplane_vector, i.normal)) + planes_angles, planes = zip(*sorted(zip(planes_angles, planes))) + return planes + + @staticmethod + def _create_polyhedron(plane_a, plane_b, lines): # Hexahedron from 2 Planes and 4 Lines + # Step 1: Get 8 Intersection Points from 2 Planes and 4 Lines + int_points = [] + for i in lines: + point_top = intersection_line_plane(i, plane_a) + point_bottom = intersection_line_plane(i, plane_b) + point_top = Point(*point_top) + point_bottom = Point(*point_bottom) + int_points.append(point_top) + int_points.append(point_bottom) + + # Step 2: Check if int_points Order results in an inward facing Polyhedron + test_face_vector1 = Vector.from_start_end(int_points[0], int_points[2]) + test_face_vector2 = Vector.from_start_end(int_points[0], int_points[6]) + test_face_normal = Vector.cross(test_face_vector1, test_face_vector2) + check_vector = Vector.from_start_end(int_points[0], int_points[1]) + # Flip int_points Order if needed + if angle_vectors(test_face_normal, check_vector) < 1: + a, b, c, d, e, f, g, h = int_points + int_points = b, a, d, c, f, e, h, g + + # Step 3: Create a Hexahedron with 6 Faces from the 8 Points + return Polyhedron( + int_points, + [ + [1, 7, 5, 3], # top + [0, 2, 4, 6], # bottom + [1, 3, 2, 0], # left + [3, 5, 4, 2], # back + [5, 7, 6, 4], # right + [7, 1, 0, 6], # front + ], + ) + + def _create_negative_volumes(self): + # Get Cut Plane + plane_cut, plane_cut_vector_a, plane_cut_vector_b = self._cutplane() + + # Get Beam Faces (Planes) in right order + planes_a = self._sort_beam_planes(self.beam_a, plane_cut_vector_a) + plane_a0, plane_a1, plane_a2, plane_a3 = planes_a + + planes_b = self._sort_beam_planes(self.beam_b, plane_cut_vector_b) + plane_b0, plane_b1, plane_b2, plane_b3 = planes_b + + # Lines as Frame Intersections + lines = [] + x = intersection_plane_plane(plane_a1, plane_b1) + lines.append(Line(x[0], x[1])) + x = intersection_plane_plane(plane_a1, plane_b2) + lines.append(Line(x[0], x[1])) + x = intersection_plane_plane(plane_a2, plane_b2) + lines.append(Line(x[0], x[1])) + x = intersection_plane_plane(plane_a2, plane_b1) + lines.append(Line(x[0], x[1])) + + # Create Polyhedrons + negative_polyhedron_beam_a = self._create_polyhedron(plane_a0, plane_cut, lines) + negative_polyhedron_beam_b = self._create_polyhedron(plane_b0, plane_cut, lines) + + # Create BREP + return Brep.from_mesh(negative_polyhedron_beam_a), Brep.from_mesh(negative_polyhedron_beam_b) + + def restore_beams_from_keys(self, assemly): + """After de-serialization, resotres references to the main and cross beams saved in the assembly.""" + self.beam_a = assemly.find_by_key(self.beam_a_key) + self.beam_b = assemly.find_by_key(self.beam_b_key) + + def add_features(self): + negative_brep_beam_a, negative_brep_beam_b = self._create_negative_volumes() + self.beam_a.add_feature(BeamBooleanSubtraction(negative_brep_beam_a)) + self.beam_b.add_feature(BeamBooleanSubtraction(negative_brep_beam_b)) diff --git a/src/compas_timber/ghpython/components/CT_JointDef_Rule/code.py b/src/compas_timber/ghpython/components/CT_JointDef_Rule/code.py index 970337d97..cacbe7864 100644 --- a/src/compas_timber/ghpython/components/CT_JointDef_Rule/code.py +++ b/src/compas_timber/ghpython/components/CT_JointDef_Rule/code.py @@ -3,12 +3,13 @@ from compas_timber.connections import LButtJoint from compas_timber.connections import LMiterJoint from compas_timber.connections import TButtJoint +from compas_timber.connections import XHalfLapJoint from compas_timber.ghpython import CategoryRule class JointCategoryRule(component): # TODO: auto fill with subclasses of Joint - MAP = {"T-Butt": TButtJoint, "L-Miter": LMiterJoint, "L-Butt": LButtJoint} + MAP = {"T-Butt": TButtJoint, "L-Miter": LMiterJoint, "L-Butt": LButtJoint, "X-HalfLap": XHalfLapJoint} def RunScript(self, JointType, CatA, CatB): if JointType and CatA and CatB: diff --git a/src/compas_timber/ghpython/components/CT_JointDef_XHalfLap/code.py b/src/compas_timber/ghpython/components/CT_JointDef_XHalfLap/code.py new file mode 100644 index 000000000..568eb4e63 --- /dev/null +++ b/src/compas_timber/ghpython/components/CT_JointDef_XHalfLap/code.py @@ -0,0 +1,39 @@ +from ghpythonlib.componentbase import executingcomponent as component +from Grasshopper.Kernel.GH_RuntimeMessageLevel import Error +from Grasshopper.Kernel.GH_RuntimeMessageLevel import Warning + +from compas_timber.connections import ConnectionSolver +from compas_timber.connections import XHalfLapJoint +from compas_timber.connections import JointTopology +from compas_timber.ghpython import JointDefinition + + +class XHalfLapDefinition(component): + def RunScript(self, MainBeam, CrossBeam): + if not MainBeam: + self.AddRuntimeMessage(Warning, "Input parameter MainBeams failed to collect data.") + if not CrossBeam: + self.AddRuntimeMessage(Warning, "Input parameter CrossBeams failed to collect data.") + if not (MainBeam and CrossBeam): + return + if not isinstance(MainBeam, list): + MainBeam = [MainBeam] + if not isinstance(CrossBeam, list): + CrossBeam = [CrossBeam] + if len(MainBeam) != len(CrossBeam): + self.AddRuntimeMessage(Error, "Number of items in MainBeams and CrossBeams must match!") + return + Joint = [] + for main, cross in zip(MainBeam, CrossBeam): + max_distance_from_beams = max([main.width, main.height, cross.width, cross.height]) + topology, _, _ = ConnectionSolver().find_topology(main, cross, max_distance=max_distance_from_beams) + if topology != XHalfLapJoint.SUPPORTED_TOPOLOGY: + self.AddRuntimeMessage( + Warning, + "Beams meet with topology: {} which does not agree with joint of type: {}".format( + JointTopology.get_name(topology), XHalfLapJoint.__name__ + ), + ) + continue + Joint.append(JointDefinition(XHalfLapJoint, [main, cross])) + return Joint diff --git a/src/compas_timber/ghpython/components/CT_JointDef_XHalfLap/icon.png b/src/compas_timber/ghpython/components/CT_JointDef_XHalfLap/icon.png new file mode 100644 index 000000000..397e968d3 Binary files /dev/null and b/src/compas_timber/ghpython/components/CT_JointDef_XHalfLap/icon.png differ diff --git a/src/compas_timber/ghpython/components/CT_JointDef_XHalfLap/metadata.json b/src/compas_timber/ghpython/components/CT_JointDef_XHalfLap/metadata.json new file mode 100644 index 000000000..edafd116c --- /dev/null +++ b/src/compas_timber/ghpython/components/CT_JointDef_XHalfLap/metadata.json @@ -0,0 +1,32 @@ +{ + "name": "JointDef: XHalfLap", + "nickname": "JointDef: XHalfLap", + "category": "COMPAS Timber", + "subcategory": "Joints", + "description": "Defines an X-HalfLap Joint for the given two Beams.", + "exposure": 4, + "ghpython": { + "isAdvancedMode": true, + "iconDisplay": 0, + "inputParameters": [ + { + "name": "BeamA", + "description": "First Beam.", + "typeHintID": "none", + "scriptParamAccess": 1 + }, + { + "name": "BeamB", + "description": "Second Beam.", + "typeHintID": "none", + "scriptParamAccess": 1 + } + ], + "outputParameters": [ + { + "name": "Joint", + "description": "XHalfLap JointDefinition." + } + ] + } +} diff --git a/src/compas_timber/ghpython/ghuser_manual/CT_SelectionList_JointType.ghuser b/src/compas_timber/ghpython/ghuser_manual/CT_SelectionList_JointType.ghuser index 88fd5586e..6eaedf097 100644 Binary files a/src/compas_timber/ghpython/ghuser_manual/CT_SelectionList_JointType.ghuser and b/src/compas_timber/ghpython/ghuser_manual/CT_SelectionList_JointType.ghuser differ