diff --git a/CHANGELOG.md b/CHANGELOG.md index 43bb68a51..bafb1651b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,16 +31,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Added new `utilities` module in `connections` package. * Added new `compas_timber._fabrication.DoubleCut`. * Added new `compas_timber.connections.TBirdsmouthJoint`. +* Added `JointRule.joints_from_beams_and_rules()` static method +* Added `Element.reset()` method. + +* Added new `fasteners.py` module with new `Fastener` element type. +* Added unit tests for `fasteners.py` module. ### Changed * Changed incorrect import of `compas.geometry.intersection_line_plane()` to `compas_timber.utils.intersection_line_plane()` * Renamed `intersection_line_plane` to `intersection_line_plane_param`. * Renamed `intersection_line_line_3D` to `intersection_line_line_param`. +* Reworked the model generation pipeline. +* Reworked `comply` methods for `JointRule`s. ### Removed * Removed module `compas_timber.utils.compas_extra`. +* Removed a bunch of spaghetti from `CT_model` GH component. ## [0.11.0] 2024-09-17 diff --git a/src/compas_timber/connections/__init__.py b/src/compas_timber/connections/__init__.py index b1429ce7d..04ba3f7eb 100644 --- a/src/compas_timber/connections/__init__.py +++ b/src/compas_timber/connections/__init__.py @@ -16,6 +16,7 @@ from .t_halflap import THalfLapJoint from .x_halflap import XHalfLapJoint from .t_dovetail import TDovetailJoint +from .ball_node import BallNodeJoint __all__ = [ "Joint", @@ -37,4 +38,5 @@ "ConnectionSolver", "find_neighboring_beams", "TDovetailJoint", + "BallNodeJoint" ] diff --git a/src/compas_timber/connections/ball_node.py b/src/compas_timber/connections/ball_node.py new file mode 100644 index 000000000..e597bf508 --- /dev/null +++ b/src/compas_timber/connections/ball_node.py @@ -0,0 +1,186 @@ +from compas.geometry import Frame + +from compas_timber.elements import BallNodeFastener +from compas_timber.elements import CutFeature +from compas_timber.elements import MillVolume +from compas_timber.elements import DrillFeature +from compas_timber.elements import BrepSubtraction +from compas_timber.elements.fasteners.fastener import Fastener +from compas_timber.utils import intersection_line_line_param +from compas.geometry import Brep +from compas.geometry import Sphere +from compas.geometry import Cylinder +from compas.geometry import Box +from compas.geometry import Plane +from compas.geometry import Line +from compas.geometry import intersection_sphere_line + + +from .joint import BeamJoinningError +from .joint import Joint +from .solver import JointTopology + + +class BallNodeJoint(Joint): + """Represents a ball node type joint which joins the ends of multiple beams, + trimming the main beam. + + Please use `BallNodeJoint.create()` to properly create an instance of this class and associate it with an model. + + Parameters + ---------- + beams : list(:class:`~compas_timber.parts.Beam`) + The beams to be joined. + + Attributes + ---------- + beams : list(:class:`~compas_timber.parts.Beam`) + The beams joined by this joint. + beam_keys : list(str) + The keys of the beams. + features : list(:class:`~compas_timber.parts.Feature`) + The features created by this joint. + joint_type : str + A string representation of this joint's type. + + """ + SUPPORTED_TOPOLOGY = JointTopology.TOPO_UNKNOWN + GH_ARGS = {"beams": None, "thickness": 10, "holes": 6, "strut_length": 100, "ball_diameter": 50} + + def __init__(self, beams, thickness=10, holes=6, strut_length=100, ball_diameter=50, **kwargs): + super(BallNodeJoint, self).__init__( **kwargs) + self._elements = beams + self.thickness = thickness + self.plate_holes = holes + self.strut_length = strut_length + self.ball_diameter = ball_diameter + self.beam_keys = [str(beam.guid) for beam in self.beams] + self.features = [] + self.joint_type = "BallNode" + self.fastener = BallNodeFastener() + + @property + def elements(self): + return self._elements + + @property + def interactions(self): + for beam in self.beams: + yield (beam, self.fastener, self) + + @property + def element_parameter_count(self): + return 1 + + @classmethod + def create(cls, model, beams, **kwargs): + """Creates an instance of this joint and creates the new connection in `model`. + + `beams` are expected to have been added to `model` before calling this method. + + This code does not verify that the given beams are adjacent and/or lie in a topology which allows connecting + them. This is the responsibility of the calling code. + + A `ValueError` is raised if `beams` contains less than two `Beam` objects. + + Parameters + ---------- + model : :class:`~compas_timber.model.TimberModel` + The model to which the beams and this joing belong. + beams : list(:class:`~compas_timber.parts.Beam`) + A list containing two beams that whould be joined together + + Returns + ------- + :class:`compas_timber.connections.Joint` + The instance of the created joint. + + """ + + joint = cls(beams, **kwargs) + model.add_element(joint.fastener) + for interaction in joint.interactions: + _ = model.add_interaction(*interaction) + return joint + + def add_element(self, element): + self._elements.append(element) + + def add_extensions(self): + """Calculates and adds the necessary extensions to the beams. + + This method is automatically called when joint is created by the call to `Joint.create()`. + + Raises + ------ + BeamJoinningError + If the extension could not be calculated. + + """ + return + + def add_features(self): + ends = [] + beams = list(self.beams) + points = intersection_line_line_param(beams[0].centerline, beams[1].centerline) + cpt = None + if points[0][0] is not None: + cpt = (points[0][0]) + if points[0][1] > 0.5: + ends.append("end") + else: + ends.append("start") + + for beam in list(beams)[1::]: + points = intersection_line_line_param(beams[0].centerline, beam.centerline) + if points[0][0] is not None and points[1][0] is not None: + cpt = cpt + points[1][0] + if points[1][1] > 0.5: + ends.append("end") + else: + ends.append("start") + cpt = cpt*(1.0/len(beams)) + + geometry = Brep.from_sphere(Sphere(self.ball_diameter/2, point= cpt)) + cut_sphere = Sphere(self.strut_length, point= cpt) + feat_dict = {} + for beam, end in zip(beams, ends): + feat_dict[beam.key] = [] + cut_pts = intersection_sphere_line([cut_sphere.base, cut_sphere.radius], beam.centerline) + if cut_pts: + """ trim beam ends""" + cut_pt = cut_pts[0] if beam.midpoint.distance_to_point(cut_pts[0]) 1: pb = b2 - if self._exceed_max_distance(pa, pb, max_distance, tol): + if ConnectionSolver._exceed_max_distance(pa, pb, max_distance, tol): return JointTopology.TOPO_UNKNOWN, None, None # topologies: - xa = self._is_near_end(ta, beam_a.centerline.length, max_distance or 0, tol) - xb = self._is_near_end(tb, beam_b.centerline.length, max_distance or 0, tol) + xa = ConnectionSolver._is_near_end(ta, beam_a.centerline.length, max_distance or 0, tol) + xb = ConnectionSolver._is_near_end(tb, beam_b.centerline.length, max_distance or 0, tol) # L-joint (both meeting at ends) if xa and xb: diff --git a/src/compas_timber/design/workflow.py b/src/compas_timber/design/workflow.py index 31a063a72..5986d8e5f 100644 --- a/src/compas_timber/design/workflow.py +++ b/src/compas_timber/design/workflow.py @@ -1,6 +1,10 @@ +from compas_timber.connections import ConnectionSolver +from compas_timber.connections import JointTopology from compas_timber.connections import LMiterJoint from compas_timber.connections import TButtJoint +from compas_timber.connections import XHalfLapJoint from compas_timber.utils import intersection_line_line_param +from compas_timber.connections import JointTopology class CollectionDef(object): @@ -34,6 +38,87 @@ def comply(self, beams): """ raise NotImplementedError + @staticmethod + def get_direct_rules(rules): + return [rule for rule in rules if rule.__class__.__name__ == "DirectRule"] + + @staticmethod + def get_category_rules(rules): + return [rule for rule in rules if rule.__class__.__name__ == "CategoryRule"] + + @staticmethod + def get_topology_rules(rules, use_defaults=False): + topo_rules = {} + if use_defaults: + topo_rules = { + JointTopology.TOPO_L: TopologyRule(JointTopology.TOPO_L, LMiterJoint), + JointTopology.TOPO_T: TopologyRule(JointTopology.TOPO_T, TButtJoint), + JointTopology.TOPO_X: TopologyRule(JointTopology.TOPO_X, XHalfLapJoint), + } + for rule in rules: # separate category and topo and direct joint rules + if rule.__class__.__name__ == "TopologyRule": + topo_rules[rule.topology_type] = TopologyRule( + rule.topology_type, rule.joint_type, **rule.kwargs + ) # overwrites, meaning last rule wins + return [rule for rule in topo_rules.values() if rule is not None] + + @staticmethod + def joints_from_beams_and_rules(beams, rules, max_distance=1e-6): + """Culls direct rules that are not applicable to the given beams. + + Parameters + ---------- + beams : list(:class:`~compas_timber.parts.Beam`) + A list of beams to be joined. + rules : list(:class:`~compas_timber.design.JointRule`) + A list of joint rules to be applied. + max_distance : float, optional + The maximum distance to consider two beams as intersecting. + + Returns + ------- + list(:class:`~compas_timber.design.JointDefinition`) + A list of joint definitions that can be applied to the given beams. + + """ + + beams = beams if isinstance(beams, list) else list(beams) + direct_rules = JointRule.get_direct_rules(rules) + beam_pairs = ConnectionSolver().find_intersecting_pairs(beams, rtree=True, max_distance=max_distance) + + joint_defs = [] + for rule in direct_rules: + joint_defs.append(JointDefinition(rule.joint_type, rule.beams, **rule.kwargs)) + + while beam_pairs: + pair = beam_pairs.pop() + match_found = False + for rule in direct_rules: # see if pair is used in a direct rule + + if rule.comply(pair): + match_found = True + break + + if not match_found: + for rule in JointRule.get_category_rules(rules): # see if pair is used in a category rule + if rule.comply(pair): + match_found = True + joint_defs.append(JointDefinition(rule.joint_type, rule.reorder(pair), **rule.kwargs)) + break + + if not match_found: + for rule in JointRule.get_topology_rules(rules): # see if pair is used in a topology rule + comply, ordered_pair = rule.comply(pair) + if comply: + match_found = True + joint_defs.append(JointDefinition(rule.joint_type, ordered_pair, **rule.kwargs)) + break + if not match_found: + print( + "Beam pairs could not be resolved by the rules: ", + "({}, {})".format(list(pair)[0].key, list(pair)[1].key), + ) # TODO: add something to catch unresolved pairs + return joint_defs class DirectRule(JointRule): """Creates a Joint Rule that directly joins two beams.""" @@ -52,9 +137,8 @@ def __repr__(self): def comply(self, beams): try: - return set(self.beams) == set(beams) + return set(beams).issubset(set(self.beams)) except TypeError: - print("unable to comply direct joint beam sets") return False @@ -77,10 +161,19 @@ def __repr__(self): CategoryRule.__name__, self.joint_type.__name__, self.category_a, self.category_b, self.topos ) - def comply(self, beams): + def comply(self, beams, max_distance=1e-6): try: beam_cats = set([b.attributes["category"] for b in beams]) - return beam_cats == set([self.category_a, self.category_b]) + comply = False + beams = list(beams) + if beam_cats == set([self.category_a, self.category_b]): + solver = ConnectionSolver() + if ( + self.joint_type.SUPPORTED_TOPOLOGY + == solver.find_topology(beams[0], beams[1], max_distance=max_distance)[0] + ): + comply = True + return comply except KeyError: return False @@ -133,20 +226,24 @@ def ToString(self): def __repr__(self): return "{}({}, {})".format(TopologyRule, self.topology_type, self.joint_type) + def comply(self, beams, max_distance=1e-3): + try: + beams = list(beams) + topo_results = ConnectionSolver.find_topology(beams[0], beams[1], max_distance=max_distance) + return (self.topology_type == topo_results[0], [topo_results[1], topo_results[2]]) # comply, if topologies match, reverse if the beam order should be switched + except KeyError: + + return False + class JointDefinition(object): - """Container for a joint type and the beam that shall be joined. + """Container for a joint type and the beams that shall be joined. This allows delaying the actual joining of the beams to a downstream component. """ def __init__(self, joint_type, beams, **kwargs): - # if not issubclass(joint_type, Joint): - # raise UserWarning("{} is not a valid Joint type!".format(joint_type.__name__)) - if len(beams) != 2: - raise UserWarning("Expected to get two Beams, got {}.".format(len(beams))) - self.joint_type = joint_type self.beams = beams self.kwargs = kwargs @@ -236,7 +333,7 @@ def is_near_end(t, tol=tol): return ["X", (beamA, beamB)] -def set_defaul_joints(model, x_default="x-lap", t_default="t-butt", l_default="l-miter"): +def set_default_joints(model, x_default="x-lap", t_default="t-butt", l_default="l-miter"): beams = list(model.beams) n = len(beams) diff --git a/src/compas_timber/elements/__init__.py b/src/compas_timber/elements/__init__.py index 6fc15a33a..45bb74ec9 100644 --- a/src/compas_timber/elements/__init__.py +++ b/src/compas_timber/elements/__init__.py @@ -6,6 +6,8 @@ from .features import DrillFeature from .features import MillVolume from .features import FeatureApplicationError +from .fasteners.ball_node_fastener import BallNodeFastener +from .fasteners.fastener import Fastener __all__ = [ "Wall", @@ -16,4 +18,6 @@ "MillVolume", "BrepSubtraction", "FeatureApplicationError", + "BallNodeFastener", + "Fastener" ] diff --git a/src/compas_timber/elements/fasteners/__init__.py b/src/compas_timber/elements/fasteners/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/compas_timber/elements/fasteners/ball_node_fastener.py b/src/compas_timber/elements/fasteners/ball_node_fastener.py new file mode 100644 index 000000000..e8a365a0f --- /dev/null +++ b/src/compas_timber/elements/fasteners/ball_node_fastener.py @@ -0,0 +1,187 @@ +from compas_model.elements import reset_computed +from compas_timber.utils import intersection_line_line_param +from compas.geometry import Sphere +from compas.geometry import Cylinder +from compas.geometry import Box +from compas.geometry import Plane +from compas.geometry import Frame +from compas.geometry import Line +from compas.geometry import Brep +from compas.geometry.intersections import intersection_sphere_line +from compas_timber.elements import DrillFeature +from compas_timber.elements import BrepSubtraction +from compas_timber.elements import CutFeature +from compas_timber.elements.fasteners.fastener import Fastener + + + +class BallNodeFastener(Fastener): + """ + A class to represent timber fasteners (screws, dowels, brackets). + + Parameters + ---------- + elements : list(:class:`~compas_timber.parts.Element`) + The elements that are connected with this fastener. + + Attributes + ---------- + frame : :class:`~compas.geometry.Frame` + The coordinate system (frame) of this fastener. + elements : list(:class:`~compas_timber.parts.Element`) + The elements that are connected with this fastener. + + """ + + @property + def __data__(self): + data = super(Fastener, self).__data__ + + return data + + def __init__(self, geometry = None, **kwargs): + super(BallNodeFastener, self).__init__(**kwargs) + self.geometry = geometry + self.features = [] + self.attributes = {} + self.attributes.update(kwargs) + self.debug_info = [] + self.test = [] + + def __repr__(self): + # type: () -> str + element_str = ["{} {}".format(element.__class__.__name__, element.key) for element in self.elements] + return "Fastener({})".format(", ".join(element_str)) + + # ========================================================================== + # Computed attributes + # ========================================================================== + + @property + def is_fastener(self): + return True + + @property + def shape(self): + # type: () -> Brep + return self.geometry + + + @property + def key(self): + # type: () -> int | None + return self.graph_node + + def __str__(self): + return "Ball Node Fastener" + + # ========================================================================== + # Implementations of abstract methods + # ========================================================================== + + @property + def geometry(self): + return self._geometry + + @geometry.setter + def geometry(self, geometry): + self._geometry = geometry + + def compute_geometry(self): + # type: (bool) -> compas.geometry.Brep + """Compute the geometry of the fastener. + + Returns + ------- + :class:`compas.geometry.Brep` + + """ + return self.shape + + def compute_aabb(self, inflate=0.0): + # type: (float) -> compas.geometry.Box + """Computes the Axis Aligned Bounding Box (AABB) of the element. + + Parameters + ---------- + inflate : float, optional + Offset of box to avoid floating point errors. + + Returns + ------- + :class:`~compas.geometry.Box` + The AABB of the element. + + """ + raise NotImplementedError + + def compute_obb(self, inflate=0.0): + # type: (float | None) -> compas.geometry.Box + """Computes the Oriented Bounding Box (OBB) of the element. + + Parameters + ---------- + inflate : float + Offset of box to avoid floating point errors. + + Returns + ------- + :class:`compas.geometry.Box` + The OBB of the element. + + """ + raise NotImplementedError + + def compute_collision_mesh(self): + # type: () -> compas.datastructures.Mesh + """Computes the collision geometry of the element. + + Returns + ------- + :class:`compas.datastructures.Mesh` + The collision geometry of the element. + + """ + return self.shape.to_mesh() + + # ========================================================================== + # Alternative constructors + # ========================================================================== + + + # ========================================================================== + # Features + # ========================================================================== + + @reset_computed + def add_features(self, features): + # type: (Feature | list[Feature]) -> None + """Adds one or more features to the fastener. + + Parameters + ---------- + features : :class:`~compas_timber.parts.Feature` | list(:class:`~compas_timber.parts.Feature`) + The feature to be added. + + """ + if not isinstance(features, list): + features = [features] + self.features.extend(features) # type: ignore + + @reset_computed + def remove_features(self, features=None): + # type: (None | Feature | list[Feature]) -> None + """Removes a feature from the fastener. + + Parameters + ---------- + feature : :class:`~compas_timber.parts.Feature` | list(:class:`~compas_timber.parts.Feature`) + The feature to be removed. If None, all features will be removed. + + """ + if features is None: + self.features = [] + else: + if not isinstance(features, list): + features = [features] + self.features = [f for f in self.features if f not in features] diff --git a/src/compas_timber/elements/fasteners/fastener.py b/src/compas_timber/elements/fasteners/fastener.py new file mode 100644 index 000000000..b1dc67244 --- /dev/null +++ b/src/compas_timber/elements/fasteners/fastener.py @@ -0,0 +1,166 @@ +from compas_model.elements import Element +from compas_model.elements import reset_computed + + +class Fastener(Element): + """ + A class to represent timber fasteners (screws, dowels, brackets). + + Parameters + ---------- + geometry : list(:class:`~compas_timber.parts.Element`) + The geometry that are connected with this fastener. + + Attributes + ---------- + geometry : list(:class:`~compas_timber.parts.Element`) + The geometry that are connected with this fastener. + + """ + #TODO: add interface class for connection to timber elements + + def __init__(self, geometry = None, **kwargs): + super(Fastener, self).__init__(**kwargs) + self._geometry = geometry + self.features = [] + self.attributes = {} + self.attributes.update(kwargs) + self.debug_info = [] + + def __repr__(self): + # type: () -> str + + return "Fastener Element" + + # ========================================================================== + # Computed attributes + # ========================================================================== + + @property + def is_fastener(self): + return True + + + @property + def shape(self): + # type: () -> Brep + return self._create_shape(self.frame, self.beams) + + @property + def key(self): + # type: () -> int | None + return self.graph_node + + def __str__(self): + element_str = ["{} {}".format(element.__class__.__name__, element.key) for element in self.elements] + return "Fastener connecting {}".format(", ".join(element_str)) + + # ========================================================================== + # Implementations of abstract methods + # ========================================================================== + + def compute_geometry(self): + # type: (bool) -> compas.geometry.Brep + """Compute the geometry of the fastener. + + Returns + ------- + :class:`compas.geometry.Brep` + + """ + raise NotImplementedError + + def compute_aabb(self, inflate=0.0): + # type: (float) -> compas.geometry.Box + """Computes the Axis Aligned Bounding Box (AABB) of the element. + + Parameters + ---------- + inflate : float, optional + Offset of box to avoid floating point errors. + + Returns + ------- + :class:`~compas.geometry.Box` + The AABB of the element. + + """ + raise NotImplementedError + + def compute_obb(self, inflate=0.0): + # type: (float | None) -> compas.geometry.Box + """Computes the Oriented Bounding Box (OBB) of the element. + + Parameters + ---------- + inflate : float + Offset of box to avoid floating point errors. + + Returns + ------- + :class:`compas.geometry.Box` + The OBB of the element. + + """ + raise NotImplementedError + + def compute_collision_mesh(self): + # type: () -> compas.datastructures.Mesh + """Computes the collision geometry of the element. + + Returns + ------- + :class:`compas.datastructures.Mesh` + The collision geometry of the element. + + """ + return self.shape.to_mesh() + + # ========================================================================== + # Alternative constructors + # ========================================================================== + + @staticmethod + def _create_shape(frame, beams): + # type: (Frame, list[TimberElement]) -> Brep + raise NotImplementedError + + # ========================================================================== + # Featrues + # ========================================================================== + + @reset_computed + def add_features(self, features): + # type: (Feature | list[Feature]) -> None + """Adds one or more features to the fastener. + + Parameters + ---------- + features : :class:`~compas_timber.parts.Feature` | list(:class:`~compas_timber.parts.Feature`) + The feature to be added. + + """ + if not isinstance(features, list): + features = [features] + self.features.extend(features) # type: ignore + + @reset_computed + def remove_features(self, features=None): + # type: (None | Feature | list[Feature]) -> None + """Removes a feature from the fastener. + + Parameters + ---------- + feature : :class:`~compas_timber.parts.Feature` | list(:class:`~compas_timber.parts.Feature`) + The feature to be removed. If None, all features will be removed. + + """ + if features is None: + self.features = [] + else: + if not isinstance(features, list): + features = [features] + self.features = [f for f in self.features if f not in features] + if not isinstance(features, list): + features = [features] + self.features = [f for f in self.features if f not in features] diff --git a/src/compas_timber/elements/features.py b/src/compas_timber/elements/features.py index 3415eb28b..d625e5daf 100644 --- a/src/compas_timber/elements/features.py +++ b/src/compas_timber/elements/features.py @@ -144,6 +144,7 @@ def apply(self, element_geometry, *args, **kwargs): drill_volume = Cylinder(frame=Frame.from_plane(plane), radius=self.diameter / 2.0, height=self.length) try: + print("trying to subtract drill volume from element geometry") return element_geometry - Brep.from_cylinder(drill_volume) except IndexError: raise FeatureApplicationError( diff --git a/src/compas_timber/elements/timber.py b/src/compas_timber/elements/timber.py index dfdf3a94d..3350a2b1f 100644 --- a/src/compas_timber/elements/timber.py +++ b/src/compas_timber/elements/timber.py @@ -28,3 +28,13 @@ def is_plate(self): @property def is_wall(self): return False + + @property + def is_fastener(self): + return False + + def reset(self): + self.remove_features() + if hasattr(self, "remove_blank_extension"): # only beams should have this attribute + self.remove_blank_extension() + self.debug_info = [] diff --git a/src/compas_timber/ghpython/components/CT_Beam_fromCurve/code.py b/src/compas_timber/ghpython/components/CT_Beam_fromCurve/code.py index d16de4312..cda6892be 100644 --- a/src/compas_timber/ghpython/components/CT_Beam_fromCurve/code.py +++ b/src/compas_timber/ghpython/components/CT_Beam_fromCurve/code.py @@ -74,7 +74,6 @@ def RunScript(self, centerline, z_vector, width, height, category, updateRefObj) beam = CTBeam.from_centerline(centerline=line, width=w, height=h, z_vector=z) beam.attributes["rhino_guid"] = str(guid) if guid else None beam.attributes["category"] = c - print(guid) if updateRefObj and guid: update_rhobj_attributes_name(guid, "width", str(w)) update_rhobj_attributes_name(guid, "height", str(h)) diff --git a/src/compas_timber/ghpython/components/CT_Joint_Rule_Category/code.py b/src/compas_timber/ghpython/components/CT_Joint_Rule_Category/code.py index a37942b7a..a3ea3c4a6 100644 --- a/src/compas_timber/ghpython/components/CT_Joint_Rule_Category/code.py +++ b/src/compas_timber/ghpython/components/CT_Joint_Rule_Category/code.py @@ -44,7 +44,7 @@ def RunScript(self, *args): for i, val in enumerate(args[2:]): if val is not None: kwargs[self.arg_names()[i + 2]] = val - print(kwargs) + if not cat_a: self.AddRuntimeMessage( Warning, "Input parameter {} failed to collect data.".format(self.arg_names()[0]) diff --git a/src/compas_timber/ghpython/components/CT_Joint_Rule_From_Beams/code.py b/src/compas_timber/ghpython/components/CT_Joint_Rule_From_Beams/code.py new file mode 100644 index 000000000..bee4ab330 --- /dev/null +++ b/src/compas_timber/ghpython/components/CT_Joint_Rule_From_Beams/code.py @@ -0,0 +1,68 @@ +import inspect + +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 Joint +from compas_timber.connections import JointTopology +from compas_timber.design import DirectRule +from compas_timber.ghpython.ghcomponent_helpers import get_leaf_subclasses +from compas_timber.ghpython.ghcomponent_helpers import manage_dynamic_params +from compas_timber.ghpython.ghcomponent_helpers import rename_gh_output + + +class JointRuleFromBeams(component): + def __init__(self): + super(JointRuleFromBeams, self).__init__() + self.classes = {} + for cls in get_leaf_subclasses(Joint): + if cls.SUPPOTED_TOPOLOGY == 0: + self.classes[cls.__name__] = cls + + if ghenv.Component.Params.Output[0].NickName == "Rule": + self.joint_type = None + else: + self.joint_type = self.classes.get(ghenv.Component.Params.Output[0].NickName, None) + + def RunScript(self, *args): + if not self.joint_type: + ghenv.Component.Message = "Select joint type from context menu (right click)" + self.AddRuntimeMessage(Warning, "Select joint type from context menu (right click)") + return None + else: + ghenv.Component.Message = self.joint_type.__name__ + + beams = args[0] + if not beams: + self.AddRuntimeMessage( + Warning, "Input parameter {} failed to collect data.".format(self.arg_names()[0]) + ) + return + kwargs = {} + for i, val in enumerate(args[1:]): + if val is not None: + kwargs[self.arg_names()[i + 1]] = val + if len(beams) < 2: + self.AddRuntimeMessage( + Warning, "At least two beams are required to create a joint." + ) + return + Rule = DirectRule(self.joint_type, beams, **kwargs) + return Rule + + def arg_names(self): + return inspect.getargspec(self.joint_type.__init__)[0][1:] + + def AppendAdditionalMenuItems(self, menu): + for name in self.classes.keys(): + item = menu.Items.Add(name, None, self.on_item_click) + if self.joint_type and name == self.joint_type.__name__: + item.Checked = True + + def on_item_click(self, sender, event_info): + self.joint_type = self.classes[str(sender)] + rename_gh_output(self.joint_type.__name__, 0, ghenv) + manage_dynamic_params(self.arg_names(), ghenv, rename_count=0, permanent_param_count=1) + ghenv.Component.ExpireSolution(True) diff --git a/src/compas_timber/ghpython/components/CT_Joint_Rule_From_Beams/icon.png b/src/compas_timber/ghpython/components/CT_Joint_Rule_From_Beams/icon.png new file mode 100644 index 000000000..1048a8466 Binary files /dev/null and b/src/compas_timber/ghpython/components/CT_Joint_Rule_From_Beams/icon.png differ diff --git a/src/compas_timber/ghpython/components/CT_Joint_Rule_From_Beams/metadata.json b/src/compas_timber/ghpython/components/CT_Joint_Rule_From_Beams/metadata.json new file mode 100644 index 000000000..9b715c935 --- /dev/null +++ b/src/compas_timber/ghpython/components/CT_Joint_Rule_From_Beams/metadata.json @@ -0,0 +1,26 @@ +{ + "name": "Joint Rule From Beams", + "nickname": "JointFromBeams", + "category": "COMPAS Timber", + "subcategory": "Joint Rules", + "description": "Generates a single joint from a list of beams. This overrides other joint rules.", + "exposure": 8, + "ghpython": { + "isAdvancedMode": true, + "iconDisplay": 0, + "inputParameters": [ + { + "name": "Beams", + "description": "List of beams to connect with a single joint.", + "typeHintID": "none", + "scriptParamAccess": 1 + } + ], + "outputParameters": [ + { + "name": "Rule", + "description": "Direct Joint Rule." + } + ] + } +} diff --git a/src/compas_timber/ghpython/components/CT_Model/code.py b/src/compas_timber/ghpython/components/CT_Model/code.py index 093db1974..b0c8108f6 100644 --- a/src/compas_timber/ghpython/components/CT_Model/code.py +++ b/src/compas_timber/ghpython/components/CT_Model/code.py @@ -4,16 +4,12 @@ from Grasshopper.Kernel.GH_RuntimeMessageLevel import Warning from compas_timber.connections import BeamJoinningError -from compas_timber.connections import ConnectionSolver from compas_timber.connections import JointTopology from compas_timber.connections import LMiterJoint from compas_timber.connections import TButtJoint from compas_timber.connections import XHalfLapJoint -from compas_timber.design import CategoryRule from compas_timber.design import DebugInfomation -from compas_timber.design import DirectRule -from compas_timber.design import JointDefinition -from compas_timber.design import TopologyRule +from compas_timber.design import JointRule from compas_timber.model import TimberModel JOINT_DEFAULTS = { @@ -22,100 +18,11 @@ JointTopology.TOPO_L: LMiterJoint, } - # workaround for https://github.com/gramaziokohler/compas_timber/issues/280 TOL.absolute = 1e-6 class ModelComponent(component): - def get_joints_from_rules(self, beams, rules, topologies): - if not isinstance(rules, list): - rules = [rules] - rules = [r for r in rules if r is not None] - - joints = [] - # rules have to be resolved into joint definitions - topo_rules = {} - cat_rules = [] - direct_rules = [] - - # TODO: refactor this into some kind of a rule reloving class/function - for r in rules: # separate category and topo and direct joint rules - if isinstance(r, TopologyRule): - if topo_rules.get(r.topology_type, None): # if rule for this Topo exists - if (r.joint_type != JOINT_DEFAULTS[r.topology_type]) or ( - len(r.kwargs) != 0 - ): # if this rule is NOT default - topo_rules[r.topology_type] = r - else: - topo_rules[r.topology_type] = r - elif isinstance(r, CategoryRule): - cat_rules.append(r) - if isinstance(r, DirectRule): - direct_rules.append(r) - - for topo in topologies: - beam_a = topo["beam_a"] - beam_b = topo["beam_b"] - detected_topo = topo["detected_topo"] - pair = beam_a, beam_b - pair_joined = False - - if detected_topo == JointTopology.TOPO_UNKNOWN: - continue - - for rule in direct_rules: # apply direct rules first - if rule.comply(pair): - joints.append(JointDefinition(rule.joint_type, rule.beams, **rule.kwargs)) - pair_joined = True - break - - if not pair_joined: # if no direct rule applies, apply category rules next - for rule in cat_rules: - if not rule.comply(pair): - continue - if rule.joint_type.SUPPORTED_TOPOLOGY != detected_topo: - msg = "Conflict detected! Beams: {}, {} meet with topology: {} but rule assigns: {}" - self.AddRuntimeMessage( - Warning, - msg.format( - beam_a.guid, - beam_b.guid, - JointTopology.get_name(detected_topo), - rule.joint_type.__name__, - ), - ) - continue - if rule.topos and detected_topo not in rule.topos: - msg = "Conflict detected! Beams: {}, {} meet with topology: {} but rule allows: {}" - self.AddRuntimeMessage( - Warning, - msg.format( - beam_a.guid, - beam_b.guid, - JointTopology.get_name(detected_topo), - [JointTopology.get_name(topo) for topo in rule.topos], - ), - ) - continue - # sort by category to allow beam role by order (main beam first, cross beam second) - beam_a, beam_b = rule.reorder([beam_a, beam_b]) - joints.append(JointDefinition(rule.joint_type, [beam_a, beam_b], **rule.kwargs)) - break # first matching rule - - else: # no category rule applies, apply topology rules - if detected_topo not in topo_rules: - continue - else: - joints.append( - JointDefinition( - topo_rules[detected_topo].joint_type, - [beam_a, beam_b], - **topo_rules[detected_topo].kwargs - ) - ) - return joints - def RunScript(self, Elements, JointRules, Features, MaxDistance, CreateGeometry): if not Elements: self.AddRuntimeMessage(Warning, "Input parameter Beams failed to collect data") @@ -130,41 +37,18 @@ def RunScript(self, Elements, JointRules, Features, MaxDistance, CreateGeometry) debug_info = DebugInfomation() for element in Elements: # prepare elements for downstream processing - if element is None: - continue - element.remove_features() - if hasattr(element, "remove_blank_extension"): - element.remove_blank_extension() - element.debug_info = [] + element.reset() Model.add_element(element) - topologies = [] - solver = ConnectionSolver() - found_pairs = solver.find_intersecting_pairs(list(Model.beams), rtree=True, max_distance=MaxDistance) - for pair in found_pairs: - beam_a, beam_b = pair - detected_topo, beam_a, beam_b = solver.find_topology(beam_a, beam_b, max_distance=MaxDistance) - if not detected_topo == JointTopology.TOPO_UNKNOWN: - topologies.append({"detected_topo": detected_topo, "beam_a": beam_a, "beam_b": beam_b}) - Model.set_topologies(topologies) - - joints = self.get_joints_from_rules(Model.beams, JointRules, topologies) + joints = JointRule.joints_from_beams_and_rules(Model.beams, JointRules) if joints: - handled_beams = [] - joints = [j for j in joints if j is not None] # apply reversed. later joints in orginal list override ealier ones for joint in joints[::-1]: - beams_to_pair = joint.beams - beam_pair_ids = set([id(beam) for beam in beams_to_pair]) - if beam_pair_ids in handled_beams: - continue try: - joint.joint_type.create(Model, *beams_to_pair, **joint.kwargs) + joint.joint_type.create(Model, joint.beams, **joint.kwargs) except BeamJoinningError as bje: debug_info.add_joint_error(bje) - else: - handled_beams.append(beam_pair_ids) # applies extensions and features resulting from joints Model.process_joinery() diff --git a/src/compas_timber/model/model.py b/src/compas_timber/model/model.py index 4c85e18d7..c36988beb 100644 --- a/src/compas_timber/model/model.py +++ b/src/compas_timber/model/model.py @@ -70,9 +70,13 @@ def plates(self): @property def joints(self): # type: () -> Generator[Joint, None, None] + # TODO: consider if there are other interaction types... + # TODO: consider, multiple interactions can have the same joint... + joints = [] for interaction in self.interactions(): if isinstance(interaction, Joint): - yield interaction # TODO: consider if there are other interaction types... + joints.append(interaction) + return list(set(joints)) @property def walls(self): @@ -123,8 +127,8 @@ def element_by_guid(self, guid): """ return self._guid_element[guid] - def add_joint(self, joint, beams): - # type: (Joint, tuple[Beam]) -> None + def add_joint(self, joint): + # type: (Joint) -> None """Add a joint object to the model. Parameters @@ -132,14 +136,9 @@ def add_joint(self, joint, beams): joint : :class:`~compas_timber.connections.joint` An instance of a Joint class. - beams : tuple(:class:`~compas_timber.elements.Beam`) - The two beams that should be joined. - """ - if len(beams) != 2: - raise ValueError("Expected 2 parts. Got instead: {}".format(len(beams))) - a, b = beams - _ = self.add_interaction(a, b, interaction=joint) + for a,b, interaction in joint.interactions: + _ = self.add_interaction(a, b, interaction=interaction) def remove_joint(self, joint): # type: (Joint) -> None diff --git a/tests/compas_timber/test_fastener.py b/tests/compas_timber/test_fastener.py new file mode 100644 index 000000000..fd0f862e6 --- /dev/null +++ b/tests/compas_timber/test_fastener.py @@ -0,0 +1,58 @@ +import pytest +from compas_timber.elements.fasteners.fastener import Fastener +from compas_timber.elements import Beam +from compas.geometry import Frame + + +@pytest.fixture +def mock_elements(): + beam_1 = Beam(Frame([0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]), 100.0, 10.0, 20.0) + beam_2 = Beam(Frame([0.0, 0.0, 0.0], [0.0, 1.0, 0.0], [-1.0, 0.0, 0.0]), 100.0, 10.0, 20.0) + return [beam_1, beam_2] + + +def test_fastener_initialization(mock_elements): + fastener = Fastener(mock_elements) + assert fastener.elements == mock_elements + assert fastener.features == [] + assert fastener.attributes == {} + assert fastener.debug_info == [] + + +def test_fastener_repr(mock_elements): + fastener = Fastener(mock_elements) + assert repr(fastener) == "Fastener(Beam None, Beam None)" + + +def test_fastener_str(mock_elements): + fastener = Fastener(mock_elements) + assert str(fastener) == "Fastener connecting Beam None, Beam None" + + +def test_fastener_is_fastener(mock_elements): + fastener = Fastener(mock_elements) + assert fastener.is_fastener is True + + +def test_fastener_add_features(mock_elements): + fastener = Fastener(mock_elements) + feature = "feature1" + fastener.add_features(feature) + assert fastener.features == [feature] + + +def test_fastener_remove_features(mock_elements): + fastener = Fastener(mock_elements) + feature = "feature1" + fastener.add_features(feature) + fastener.remove_features(feature) + assert fastener.features == [] + + +def test_fastener_remove_all_features(mock_elements): + fastener = Fastener(mock_elements) + feature1 = "feature1" + feature2 = "feature2" + fastener.add_features([feature1, feature2]) + fastener.remove_features() + assert fastener.features == []