diff --git a/CHANGELOG.md b/CHANGELOG.md index ca5e8b825..501b57851 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased ### Added +* Added `distance_segment_segment` to `compas_timber.utils` ### Changed @@ -27,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Fixed `restore_beams_from_keys` in `LMiterJoint` to use the correct variable names. * Reworked `DoubleCut` to more reliably produce the feature and geometry with the `from_planes_and_element` class method. * Renamed `intersection_box_line()` to `intersection_beam_line_param()`, which now take a beam input and outputs the intersecting ref_face_index. +* Added `max_distance` argument to `JointRule` subclasses and GH components so that max_distance can be set for each joint rule individually. ### Removed diff --git a/src/compas_timber/design/workflow.py b/src/compas_timber/design/workflow.py index e7b86f3b0..724e0a58a 100644 --- a/src/compas_timber/design/workflow.py +++ b/src/compas_timber/design/workflow.py @@ -1,8 +1,11 @@ +from itertools import combinations + 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 XLapJoint +from compas_timber.utils import distance_segment_segment from compas_timber.utils import intersection_line_line_param @@ -56,7 +59,7 @@ def get_topology_rules(rules, use_defaults=False): } 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 + topo_rules[rule.topology_type] = TopologyRule(rule.topology_type, rule.joint_type, rule.max_distance, **rule.kwargs) # overwrites, meaning last rule wins return [rule for rule in topo_rules.values() if rule is not None] @staticmethod @@ -81,30 +84,31 @@ def joints_from_beams_and_rules(elements, rules, max_distance=1e-6): elements = elements if isinstance(elements, list) else list(elements) direct_rules = JointRule.get_direct_rules(rules) solver = ConnectionSolver() - - element_pairs = solver.find_intersecting_pairs(elements, rtree=True, max_distance=max_distance) + max_distances = [rule.max_distance for rule in rules if rule.max_distance] + max_rule_distance = max(max_distances) if max_distances else max_distance + element_pairs = solver.find_intersecting_pairs(elements, rtree=True, max_distance=max_rule_distance) joint_defs = [] unmatched_pairs = [] - for rule in direct_rules: - joint_defs.append(JointDefinition(rule.joint_type, rule.elements, **rule.kwargs)) while element_pairs: pair = element_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 + for rule in direct_rules: + if rule.contains(pair): # see if pair is used in a direct rule + if rule.comply(pair, model_max_distance=max_distance): # see if pair complies with max distance + joint_defs.append(JointDefinition(rule.joint_type, rule.elements, **rule.kwargs)) + 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, max_distance=max_distance): + if rule.comply(pair, model_max_distance=max_distance): 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, max_distance=max_distance) + comply, ordered_pair = rule.comply(pair, model_max_distance=max_distance) if comply: match_found = True joint_defs.append(JointDefinition(rule.joint_type, ordered_pair, **rule.kwargs)) @@ -115,11 +119,24 @@ def joints_from_beams_and_rules(elements, rules, max_distance=1e-6): class DirectRule(JointRule): - """Creates a Joint Rule that directly joins multiple elements.""" + """Creates a Joint Rule that directly joins multiple elements. - def __init__(self, joint_type, elements, **kwargs): + Parameters + ---------- + joint_type : cls(:class:`~compas_timber.connections.Joint`) + The joint type to be applied to the elements. + elements : list(:class:`~compas_timber.elements.TimberElement`) + The elements to be joined. + max_distance : float, optional + The maximum distance to consider two elements as intersecting. + kwargs : dict + The keyword arguments to be passed to the joint. + """ + + def __init__(self, joint_type, elements, max_distance=None, **kwargs): self.elements = elements self.joint_type = joint_type + self.max_distance = max_distance self.kwargs = kwargs def ToString(self): @@ -129,21 +146,55 @@ def ToString(self): def __repr__(self): return "{}({}, {})".format(DirectRule, self.elements, self.joint_type) - def comply(self, elements): + def contains(self, elements): + """Returns True if the given elements are defined within this DirectRule.""" try: return set(elements).issubset(set(self.elements)) except TypeError: raise UserWarning("unable to comply direct joint element sets") + def comply(self, elements, model_max_distance=1e-6): + """Returns True if the given elements comply with this DirectRule. + only checks if the distance between the centerlines of the elements is less than the max_distance. + allows joint topology overrides. + """ + if self.max_distance: + max_distance = self.max_distance + else: + max_distance = model_max_distance + + try: + for pair in combinations(list(elements), 2): + return distance_segment_segment(pair[0].centerline, pair[1].centerline) <= max_distance + except TypeError: + raise UserWarning("unable to comply direct joint element sets") + class CategoryRule(JointRule): - """Based on the category attribute attached to the elements, this rule assigns""" + """Based on the category attribute attached to the elements, this rule assigns - def __init__(self, joint_type, category_a, category_b, topos=None, **kwargs): + Parameters + ---------- + joint_type : cls(:class:`~compas_timber.connections.Joint`) + The joint type to be applied to the elements. + category_a : str + The category of the first element. + category_b : str + The category of the second element. + topos : list(:class:`~compas_timber.connections.JointTopology`), optional + The topologies that are supported by this rule. + max_distance : float, optional + The maximum distance to consider two elements as intersecting. + kwargs : dict + The keyword arguments to be passed to the joint. + """ + + def __init__(self, joint_type, category_a, category_b, topos=None, max_distance=None, **kwargs): self.joint_type = joint_type self.category_a = category_a self.category_b = category_b self.topos = topos or [] + self.max_distance = max_distance self.kwargs = kwargs def ToString(self): @@ -153,7 +204,11 @@ def ToString(self): def __repr__(self): return "{}({}, {}, {}, {})".format(CategoryRule.__name__, self.joint_type.__name__, self.category_a, self.category_b, self.topos) - def comply(self, elements, max_distance=1e-6): + def comply(self, elements, model_max_distance=1e-6): + if self.max_distance: + max_distance = self.max_distance + else: + max_distance = model_max_distance try: element_cats = set([e.attributes["category"] for e in elements]) comply = False @@ -207,9 +262,10 @@ class TopologyRule(JointRule): The keyword arguments to be passed to the joint. """ - def __init__(self, topology_type, joint_type, **kwargs): + def __init__(self, topology_type, joint_type, max_distance=None, **kwargs): self.topology_type = topology_type self.joint_type = joint_type + self.max_distance = max_distance self.kwargs = kwargs def ToString(self): @@ -217,9 +273,17 @@ def ToString(self): return repr(self) def __repr__(self): - return "{}({}, {})".format(TopologyRule, self.topology_type, self.joint_type) - - def comply(self, elements, max_distance=1e-3): + return "{}({}, {})".format( + TopologyRule, + self.topology_type, + self.joint_type, + ) + + def comply(self, elements, model_max_distance=1e-6): + if self.max_distance: + max_distance = self.max_distance + else: + max_distance = model_max_distance try: elements = list(elements) solver = ConnectionSolver() 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 cdfea7057..b66ba81ea 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 @@ -62,7 +62,7 @@ def arg_names(self): names = inspect.getargspec(self.joint_type.__init__)[0][1:] for i in range(2): names[i] += " category" - return [name for name in names if (name != "key") and (name != "frame")] + return [name for name in names if (name != "key") and (name != "frame")] + ["max_distance"] def AppendAdditionalMenuItems(self, menu): for name in self.classes.keys(): diff --git a/src/compas_timber/ghpython/components/CT_Joint_Rule_Direct/code.py b/src/compas_timber/ghpython/components/CT_Joint_Rule_Direct/code.py index 20e108671..5575f8f38 100644 --- a/src/compas_timber/ghpython/components/CT_Joint_Rule_Direct/code.py +++ b/src/compas_timber/ghpython/components/CT_Joint_Rule_Direct/code.py @@ -68,7 +68,7 @@ def RunScript(self, *args): return Rules def arg_names(self): - return inspect.getargspec(self.joint_type.__init__)[0][1:] + return inspect.getargspec(self.joint_type.__init__)[0][1:] + ["max_distance"] def AppendAdditionalMenuItems(self, menu): for name in self.classes.keys(): diff --git a/src/compas_timber/ghpython/components/CT_Joint_Rule_From_List/code.py b/src/compas_timber/ghpython/components/CT_Joint_Rule_From_List/code.py index cdbeccbf6..be21c058d 100644 --- a/src/compas_timber/ghpython/components/CT_Joint_Rule_From_List/code.py +++ b/src/compas_timber/ghpython/components/CT_Joint_Rule_From_List/code.py @@ -58,7 +58,7 @@ def arg_start_index(self): @property def arg_names(self): - return inspect.getargspec(self.joint_type.__init__)[0][self.arg_start_index :] + return inspect.getargspec(self.joint_type.__init__)[0][self.arg_start_index :] + ["max_distance"] def AppendAdditionalMenuItems(self, menu): for name in self.classes.keys(): diff --git a/src/compas_timber/ghpython/components/CT_Joint_Rule_Topology_L/code.py b/src/compas_timber/ghpython/components/CT_Joint_Rule_Topology_L/code.py index d25b7c29d..680338a04 100644 --- a/src/compas_timber/ghpython/components/CT_Joint_Rule_Topology_L/code.py +++ b/src/compas_timber/ghpython/components/CT_Joint_Rule_Topology_L/code.py @@ -49,7 +49,7 @@ def RunScript(self, *args): def arg_names(self): names = inspect.getargspec(self.joint_type.__init__)[0][3:] - return [name for name in names if (name != "key") and (name != "frame")] + return [name for name in names if (name != "key") and (name != "frame")] + ["max_distance"] def AppendAdditionalMenuItems(self, menu): for name in self.classes.keys(): diff --git a/src/compas_timber/ghpython/components/CT_Joint_Rule_Topology_T/code.py b/src/compas_timber/ghpython/components/CT_Joint_Rule_Topology_T/code.py index 011a18ec8..7a595f5ef 100644 --- a/src/compas_timber/ghpython/components/CT_Joint_Rule_Topology_T/code.py +++ b/src/compas_timber/ghpython/components/CT_Joint_Rule_Topology_T/code.py @@ -49,7 +49,7 @@ def RunScript(self, *args): def arg_names(self): names = inspect.getargspec(self.joint_type.__init__)[0][3:] - return [name for name in names if (name != "key") and (name != "frame")] + return [name for name in names if (name != "key") and (name != "frame")] + ["max_distance"] def AppendAdditionalMenuItems(self, menu): for name in self.classes.keys(): diff --git a/src/compas_timber/ghpython/components/CT_Joint_Rule_Topology_X/code.py b/src/compas_timber/ghpython/components/CT_Joint_Rule_Topology_X/code.py index 8f321a9b6..9da5fff75 100644 --- a/src/compas_timber/ghpython/components/CT_Joint_Rule_Topology_X/code.py +++ b/src/compas_timber/ghpython/components/CT_Joint_Rule_Topology_X/code.py @@ -49,7 +49,7 @@ def RunScript(self, *args): def arg_names(self): names = inspect.getargspec(self.joint_type.__init__)[0][3:] - return [name for name in names if (name != "key") and (name != "frame")] + return [name for name in names if (name != "key") and (name != "frame")] + ["max_distance"] def AppendAdditionalMenuItems(self, menu): for name in self.classes.keys(): diff --git a/src/compas_timber/utils/__init__.py b/src/compas_timber/utils/__init__.py index 86d66f2b6..c4a9ff859 100644 --- a/src/compas_timber/utils/__init__.py +++ b/src/compas_timber/utils/__init__.py @@ -13,6 +13,8 @@ from compas.geometry import Frame from compas.geometry import Transformation from compas.geometry import intersection_line_plane +from compas.geometry import closest_point_on_segment +from compas.geometry import intersection_line_line def intersection_line_line_param(line1, line2, max_distance=1e-6, limit_to_segments=True, tol=1e-6): @@ -171,4 +173,26 @@ def intersection_line_beam_param(line, beam, ignore_ends=False): return [Point(*coords) for coords in pts], ref_side_indices -__all__ = ["intersection_line_line_param", "intersection_line_plane_param", "intersection_line_beam_param"] +def distance_segment_segment(segment_a, segment_b): + """Computes the distance between two segments. + + Parameters + ---------- + segment_a : tuple(tuple(float, float, float), tuple(float, float, float)) + The first segment, defined by two points. + segment_b : tuple(tuple(float, float, float), tuple(float, float, float)) + The second segment, defined by two points. + + Returns + ------- + float + The distance between the two segments. + + """ + pta, ptb = intersection_line_line(segment_a, segment_b) + pt_seg_a = closest_point_on_segment(pta, segment_a) + pt_seg_b = closest_point_on_segment(ptb, segment_b) + return distance_point_point(pt_seg_a, pt_seg_b) + + +__all__ = ["intersection_line_line_param", "intersection_line_plane_param", "intersection_line_beam_param", "distance_segment_segment"] diff --git a/tests/compas_timber/test_joint_rules.py b/tests/compas_timber/test_joint_rules.py new file mode 100644 index 000000000..d15e6eda1 --- /dev/null +++ b/tests/compas_timber/test_joint_rules.py @@ -0,0 +1,205 @@ +import pytest +from compas.geometry import Line +from compas.geometry import Point + +from compas_timber.connections import JointTopology +from compas_timber.connections import LButtJoint +from compas_timber.connections import LLapJoint +from compas_timber.connections import TButtJoint +from compas_timber.connections import XLapJoint +from compas_timber.connections import LMiterJoint +from compas_timber.elements import Beam +from compas_timber.design import JointRule +from compas_timber.design import DirectRule +from compas_timber.design import CategoryRule +from compas_timber.design import TopologyRule + + +@pytest.fixture +def beams(): + """ + 0 <=> 1:L + 1 <=> 2:T + 2 <=> 3:L + 3 <=> 0:X + + """ + w = 0.2 + h = 0.2 + lines = [ + Line(Point(-1, 0, 0), Point(1, 0, 0)), + Line(Point(1, 0, 0), Point(1, 2, 0)), + Line(Point(1, 1, 0), Point(0, 1, 0)), + Line(Point(0, 1, 0), Point(0, -1, 0)), + ] + return [Beam.from_centerline(line, w, h) for line in lines] + + +@pytest.fixture +def separated_beams(): + """ + 0 <=> 1:L + 1 <=> 2:T + 2 <=> 3:L + 3 <=> 0:X + + """ + w = 0.2 + h = 0.2 + lines = [ + Line(Point(-1, 0, 0), Point(1, 0, 0)), + Line(Point(1, 0, 0.1), Point(1, 2, 0.1)), + Line(Point(1, 1, 0), Point(0, 1, 0)), + Line(Point(0, 1, 0.1), Point(0, -1, 0.1)), + ] + return [Beam.from_centerline(line, w, h) for line in lines] + + +@pytest.fixture +def L_beams(): + """ + 0 <=> 1:L + 1 <=> 2:T + 2 <=> 3:L + 3 <=> 0:X + + """ + w = 0.2 + h = 0.2 + lines = [ + Line(Point(-1, 0, 0), Point(1, 0, 0)), + Line(Point(1, 0, 0), Point(1, 1, 0)), + Line(Point(1, 1, 0), Point(0, 1, 0)), + Line(Point(0, 1, 0), Point(0, -1, 0)), + ] + return [Beam.from_centerline(line, w, h) for line in lines] + + +@pytest.fixture +def L_beams_separated(): + """ + 0 <=> 1:L + 1 <=> 2:T + 2 <=> 3:L + 3 <=> 0:X + + """ + w = 0.2 + h = 0.2 + lines = [ + Line(Point(-1, 0, 0), Point(1, 0, 0)), + Line(Point(1, 0, 0.1), Point(1, 1, 0.1)), + Line(Point(1, 1, 0), Point(0, 1, 0)), + Line(Point(0, 1, 0.1), Point(0, -1, 0.1)), + ] + return [Beam.from_centerline(line, w, h) for line in lines] + + +def test_joints_from_beams_and_topo_rules(beams): + rules = [ + TopologyRule(JointTopology.TOPO_L, LMiterJoint), + TopologyRule(JointTopology.TOPO_T, TButtJoint), + TopologyRule(JointTopology.TOPO_X, XLapJoint), + ] + joint_defs, unmatched_pairs = JointRule.joints_from_beams_and_rules(beams, rules) + assert len(joint_defs) == 4 + assert len(unmatched_pairs) == 0 + names = set([joint_def.joint_type.__name__ for joint_def in joint_defs]) + assert names == set(["LMiterJoint", "TButtJoint", "XLapJoint"]) + + +def test_joints_from_beams_and_rules_with_max_distance(separated_beams): + rules = [ + TopologyRule(JointTopology.TOPO_L, LMiterJoint), + TopologyRule(JointTopology.TOPO_T, TButtJoint), + TopologyRule(JointTopology.TOPO_X, XLapJoint), + ] + joint_defs, unmatched_pairs = JointRule.joints_from_beams_and_rules(separated_beams, rules) + assert len(joint_defs) == 0 + assert len(unmatched_pairs) == 4 + + rules = [ + TopologyRule(JointTopology.TOPO_L, LMiterJoint), + TopologyRule(JointTopology.TOPO_T, TButtJoint, max_distance=0.15), + TopologyRule(JointTopology.TOPO_X, XLapJoint), + ] + joint_defs, unmatched_pairs = JointRule.joints_from_beams_and_rules(separated_beams, rules) + assert len(joint_defs) == 1 + assert len(unmatched_pairs) == 3 + + rules = [ + TopologyRule(JointTopology.TOPO_L, LMiterJoint), + TopologyRule(JointTopology.TOPO_T, TButtJoint, max_distance=0.05), + TopologyRule(JointTopology.TOPO_X, XLapJoint), + ] + joint_defs, unmatched_pairs = JointRule.joints_from_beams_and_rules(separated_beams, rules, max_distance=0.15) + assert len(joint_defs) == 3 + assert len(unmatched_pairs) == 1 + + +def test_direct_rule_contains(beams): + rule = DirectRule(LMiterJoint, beams[:2]) + assert rule.contains(beams[:2]) is True + assert rule.contains(beams[1:3]) is False + assert rule.contains(beams[2:]) is False + assert rule.contains([beams[0], beams[3]]) is False + + +def test_direct_rule_comply(beams): + rule = DirectRule(LMiterJoint, [beams[0], beams[1]]) + assert rule.comply([beams[0], beams[1]]) is True + assert rule.comply([beams[2], beams[3]]) is True + assert rule.comply([beams[1], beams[2]]) is True + assert rule.comply([beams[3], beams[0]]) is True + + +def test_direct_rule_comply_max_distance(separated_beams): + rule = DirectRule(LMiterJoint, [separated_beams[0], separated_beams[1]], max_distance=0.05) + assert rule.comply([separated_beams[0], separated_beams[1]]) is False + assert rule.comply([separated_beams[2], separated_beams[3]]) is False + assert rule.comply([separated_beams[1], separated_beams[2]]) is False + assert rule.comply([separated_beams[3], separated_beams[0]]) is False + + +def test_category_rule_comply(beams): + for beam in beams: + beam.attributes["category"] = "A" + beams[1].attributes["category"] = "B" + rule = CategoryRule(LMiterJoint, "A", "B") + assert rule.comply(beams[:2]) is True + assert rule.comply(beams[2:]) is False + + +def test_topology_rule_comply(beams): + rule = TopologyRule(JointTopology.TOPO_L, LMiterJoint) + assert rule.comply([beams[0], beams[1]])[0] is True + assert rule.comply([beams[1], beams[2]])[0] is False + assert rule.comply([beams[2], beams[3]])[0] is True + assert rule.comply([beams[3], beams[0]])[0] is False + + +def test_different_rules(L_beams): + for beam in L_beams: + beam.attributes["category"] = "A" + L_beams[1].attributes["category"] = "B" + rules = [DirectRule(LLapJoint, L_beams[:2]), CategoryRule(LButtJoint, "A", "B"), TopologyRule(JointTopology.TOPO_L, LMiterJoint)] + joint_defs, unmatched_pairs = JointRule.joints_from_beams_and_rules(L_beams, rules) + joints_names = set([joint_def.joint_type.__name__ for joint_def in joint_defs]) + assert joints_names == set(["LLapJoint", "LButtJoint", "LMiterJoint"]) + assert len(joint_defs) == 3 + + +def test_different_rules_max_distance(L_beams_separated): + for beam in L_beams_separated: + beam.attributes["category"] = "A" + L_beams_separated[1].attributes["category"] = "B" + rules = [DirectRule(LLapJoint, L_beams_separated[:2]), CategoryRule(LButtJoint, "A", "B"), TopologyRule(JointTopology.TOPO_L, LMiterJoint)] + joint_defs, unmatched_pairs = JointRule.joints_from_beams_and_rules(L_beams_separated, rules) + joints_names = set([joint_def.joint_type.__name__ for joint_def in joint_defs]) + assert len(joint_defs) == 0 + + rules = [DirectRule(LLapJoint, L_beams_separated[:2]), CategoryRule(LButtJoint, "A", "B"), TopologyRule(JointTopology.TOPO_L, LMiterJoint, max_distance=0.15)] + joint_defs, unmatched_pairs = JointRule.joints_from_beams_and_rules(L_beams_separated, rules) + joints_names = set([joint_def.joint_type.__name__ for joint_def in joint_defs]) + assert joints_names == set(["LMiterJoint"]) + assert len(joint_defs) == 3