diff --git a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py index 87a1b3652a..b21fbc4389 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py +++ b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py @@ -928,131 +928,7 @@ class ShapeBuilder: start_offset = V(0, 0, start_length) end_extrusion_offset = start_offset.copy() - si_conversion = ifcopenshell.util.unit.calculate_unit_scale(self.file) - - # TODO: move to separate shape_builder method - # so we could check transition length without creating representation - def get_transition_length(start_half_dim, end_half_dim, angle, profile_offset=None, verbose=True): - """get the final transition length for two profiles dimensions, angle and XY offset between them, - - the difference from `calculate_transition` - `get_transition_length` is making sure - that length will fit both sides of the transition - """ - print = lambda *args, **kwargs: __builtins__["print"](*args, **kwargs) if verbose else None - - # offsets tend to have bunch of float point garbage - # that can result in errors when we're calculating value for square root below - offset = V(0, 0) if profile_offset is None else round_vector_to_precision(profile_offset, si_conversion) - diff = start_half_dim.xy - end_half_dim.xy - diff = Vector([abs(i) for i in diff]) - - # TODO: move to separate shape_builder method - # so it could be tested later separately - def calculate_transition( - start_half_dim, end_half_dim, diff, offset, end_profile=False, angle=None, length=None - ): - """will return transition length based on the profile dimension differences and offset. - - If `length` is provided will return transition angle""" - - if end_profile: - diff, offset = diff.yx, offset.yx - - same_dimensions = is_x(diff.length, 0) - - a = diff.x + offset.x - b = diff.x - offset.x - if length is None: - if not same_dimensions: - if diff.x == 0: - return 0 - - t = tan(radians(angle)) - h = (a + b + sqrt(a**2 + 4 * a * b * t**2 + 2 * a * b + b**2)) / (2 * t) - length = sqrt(h**2 - offset.y**2) - - # TODO: move somewhere to tests? - if verbose: - A = (end_half_dim if end_profile else start_half_dim) * V(1, 0, 0) - end_profile_offset = offset.to_3d() + V(0, 0, length) - D = (start_half_dim if end_profile else end_half_dim) * V(1, 0, 0) - B, C = -A, -D - C += end_profile_offset - D += end_profile_offset - tested_angle = degrees((A - D).angle(B - C)) - print(f"II. length = {length}, requested angle = {angle}, tested angle = {tested_angle}") - else: - if is_x(offset.x, 0): - angle = 90 # NOTE: for now we just hardcode the good value for that case - h = start_half_dim.x / tan(radians(angle / 2)) - length = sqrt(h**2 - offset.y**2) - - if verbose: # TODO: move to tests - O = V(0, 0, 0) - A = V(-start_half_dim.x, 0, length) + offset.to_3d() - B = A * V(-1, 1, 1) - tested_angle = degrees((A - O).angle(B - O)) - print(f"I. length = {length}, requested angle = {angle}, tested angle = {tested_angle}") - else: - h = offset.x / tan(radians(angle)) - length = sqrt(h**2 - offset.y**2) - - if verbose: # TODO: move to tests - A = V(-start_half_dim.x, 0, 0) - H = A + V(0, 0, length) - D = H + offset.to_3d() - tested_angle = degrees((H - A).angle(D - A)) - print( - f"III. length = {length}, requested angle = {angle}, tested angle = {tested_angle}" - ) - - return length - - elif angle is None: - # TODO: write some tests here too - if not same_dimensions: - if length == 0: - return 0 - - h = sqrt(length**2 + offset.y**2) - t = -h * (a + b) / (a * b - h**2) - angle = degrees(atan(t)) - else: - h = sqrt(length**2 + offset.y**2) - if is_x(offset.x, 0): - angle = degrees(2 * atan(start_half_dim.x / h)) - else: - angle = degrees(atan(offset.x / length)) - return angle - - print(f"offset = {profile_offset} / {offset}") - print(f"diff = {diff}") - - calculation_arguments = (start_half_dim, end_half_dim, diff, offset) - - def check_transition(end_profile=False): - length = calculate_transition(*calculation_arguments, angle=angle, end_profile=end_profile) - other_side_angle = calculate_transition( - *calculation_arguments, length=length, end_profile=not end_profile - ) - - # NOTE: for now we just hardcode the good value for that case - same_dimensions = is_x(diff.length, 0) - if same_dimensions and is_x(offset.y if not end_profile else offset.x, 0): - requested_angle = 90 - else: - requested_angle = angle - - print(f"other_side_angle = {other_side_angle}, requested_angle = {requested_angle}") - # need to make sure that the worst angle (maximum angle) - # for this transition angle is `requested_angle` - if other_side_angle < requested_angle or is_x(other_side_angle, requested_angle): - print(f"final length = {length}, angle = {requested_angle}, other side angle = {other_side_angle}") - return length - - return check_transition() or check_transition(True) - - transition_length = get_transition_length(start_half_dim, end_half_dim, angle, profile_offset) + transition_length = self.mep_transition_length(start_half_dim, end_half_dim, angle, profile_offset) if transition_length is None: return None, None @@ -1199,3 +1075,134 @@ class ShapeBuilder: } return representation, transition_data + + # TODO: move to separate shape_builder method + # so we could check transition length without creating representation + def mep_transition_length(self, start_half_dim, end_half_dim, angle, profile_offset=None, verbose=True): + """get the final transition length for two profiles dimensions, angle and XY offset between them, + + the difference from `calculate_transition` - `get_transition_length` is making sure + that length will fit both sides of the transition + """ + print = lambda *args, **kwargs: __builtins__["print"](*args, **kwargs) if verbose else None + + # offsets tend to have bunch of float point garbage + # that can result in errors when we're calculating value for square root below + si_conversion = ifcopenshell.util.unit.calculate_unit_scale(self.file) + offset = V(0, 0) if profile_offset is None else round_vector_to_precision(profile_offset, si_conversion) + diff = start_half_dim.xy - end_half_dim.xy + diff = Vector([abs(i) for i in diff]) + + print(f"offset = {profile_offset} / {offset}") + print(f"diff = {diff}") + + calculation_arguments = { + "start_half_dim": start_half_dim, + "end_half_dim": end_half_dim, + "diff": diff, + "offset": offset, + "verbose": verbose, + } + + def check_transition(end_profile=False): + length = self.mep_transition_calculate(**calculation_arguments, angle=angle, end_profile=end_profile) + other_side_angle = self.mep_transition_calculate( + **calculation_arguments, length=length, end_profile=not end_profile + ) + + # NOTE: for now we just hardcode the good value for that case + same_dimensions = is_x(diff.length, 0) + if same_dimensions and is_x(offset.y if not end_profile else offset.x, 0): + requested_angle = 90 + else: + requested_angle = angle + + print(f"other_side_angle = {other_side_angle}, requested_angle = {requested_angle}") + # need to make sure that the worst angle (maximum angle) + # for this transition angle is `requested_angle` + if other_side_angle < requested_angle or is_x(other_side_angle, requested_angle): + print(f"final length = {length}, angle = {requested_angle}, other side angle = {other_side_angle}") + return length + + return check_transition() or check_transition(True) + + def mep_transition_calculate( + self, start_half_dim, end_half_dim, offset, diff=None, end_profile=False, angle=None, length=None, verbose=True + ): + """will return transition length based on the profile dimension differences and offset. + + If `length` is provided will return transition angle""" + + print = lambda *args, **kwargs: __builtins__["print"](*args, **kwargs) if verbose else None + + if diff is None: + diff = start_half_dim.xy - end_half_dim.xy + diff = Vector([abs(i) for i in diff]) + + if end_profile: + diff, offset = diff.yx, offset.yx + + same_dimensions = is_x(diff.length, 0) + + a = diff.x + offset.x + b = diff.x - offset.x + if length is None: + if not same_dimensions: + if diff.x == 0: + return 0 + + t = tan(radians(angle)) + h = (a + b + sqrt(a**2 + 4 * a * b * t**2 + 2 * a * b + b**2)) / (2 * t) + length = sqrt(h**2 - offset.y**2) + + if verbose: + A = (end_half_dim if end_profile else start_half_dim) * V(1, 0, 0) + end_profile_offset = offset.to_3d() + V(0, 0, length) + D = (start_half_dim if end_profile else end_half_dim) * V(1, 0, 0) + B, C = -A, -D + C += end_profile_offset + D += end_profile_offset + tested_angle = degrees((A - D).angle(B - C)) + print(f"A. length = {length}, requested angle = {angle}, tested angle = {tested_angle}") + else: + if is_x(offset.x, 0): + angle = 90 # NOTE: for now we just hardcode the good value for that case + h = start_half_dim.x / tan(radians(angle / 2)) + length = sqrt(h**2 - offset.y**2) + + if verbose: + O = V(0, 0, 0) + A = V(-start_half_dim.x, 0, length) + offset.to_3d() + B = A * V(-1, 1, 1) + tested_angle = degrees((A - O).angle(B - O)) + print(f"B. length = {length}, requested angle = {angle}, tested angle = {tested_angle}") + else: + h = offset.x / tan(radians(angle)) + length = sqrt(h**2 - offset.y**2) + + if verbose: + A = V(-start_half_dim.x, 0, 0) + H = A + V(0, 0, length) + H.y += offset.y + D = H.copy() + D.x += offset.x + tested_angle = degrees((H - A).angle(D - A)) + print(f"C. length = {length}, requested angle = {angle}, tested angle = {tested_angle}") + + return length + + elif angle is None: + if not same_dimensions: + if length == 0: + return 0 + + h = sqrt(length**2 + offset.y**2) + t = -h * (a + b) / (a * b - h**2) + angle = degrees(atan(t)) + else: + h = sqrt(length**2 + offset.y**2) + if is_x(offset.x, 0): + angle = degrees(2 * atan(start_half_dim.x / h)) + else: + angle = degrees(atan(offset.x / h)) + return angle diff --git a/src/ifcopenshell-python/test/util/test_shape_builder.py b/src/ifcopenshell-python/test/util/test_shape_builder.py new file mode 100644 index 0000000000..23b2d92f90 --- /dev/null +++ b/src/ifcopenshell-python/test/util/test_shape_builder.py @@ -0,0 +1,136 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2023 Dion Moult , @Andrej730 +# +# This file is part of IfcOpenShell. +# +# IfcOpenShell is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# IfcOpenShell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with IfcOpenShell. If not, see . + +import pytest +import test.bootstrap +import ifcopenshell.api +from ifcopenshell.util.shape_builder import ShapeBuilder, V, is_x +from math import degrees, radians, tan +from mathutils import Vector + + +class TestCalculateTransitions(test.bootstrap.IFC4): + def calculate_and_test(self, params, length): + end_profile = params["end_profile"] + start_half_dim = params["start_half_dim"] + end_half_dim = params["end_half_dim"] + offset = params["offset"] + offset = offset if not end_profile else offset.yx + angle = params["angle"] + + calculated_length = self.builder.mep_transition_calculate(**params) + assert is_x(calculated_length, length) + + # angle confirmation methods: + # A - between two profiles of different dimensions + # B - between two profiles of same dimensions, no offset by x + # C - between two profiles of same dimensions, has offset by x + same_dimensions = is_x((start_half_dim.xy - end_half_dim.xy).length, 0) + if not same_dimensions: + confirmation_method = "A" + else: + confirmation_method = "B" if is_x(offset.x, 0) else "C" + + if confirmation_method == "A": + A = (end_half_dim if end_profile else start_half_dim) * V(1, 0, 0) + end_profile_offset = offset.to_3d() + V(0, 0, length) + D = (start_half_dim if end_profile else end_half_dim) * V(1, 0, 0) + B, C = -A, -D + C += end_profile_offset + D += end_profile_offset + tested_angle = degrees((A - D).angle(B - C)) + assert is_x(tested_angle, angle) + + elif confirmation_method == "B": + O = V(0, 0, 0) + A = V(-start_half_dim.x, 0, length) + offset.to_3d() + B = A * V(-1, 1, 1) + tested_angle = degrees((A - O).angle(B - O)) + assert is_x(tested_angle, angle) + + elif confirmation_method == "C": + A = V(-start_half_dim.x, 0, 0) + H = A + V(0, 0, length) + H.y += offset.y + D = H.copy() + D.x += offset.x + tested_angle = degrees((H - A).angle(D - A)) + assert is_x(tested_angle, angle) + + angle = self.builder.mep_transition_calculate(**params | {"angle": None, "length": calculated_length}) + assert is_x(angle, angle) + + def test_mep_transition_same_dims_no_offset(self): + self.builder = ShapeBuilder(self.file) + params = { + "start_half_dim": V(100, 50, 0), + "end_half_dim": V(100, 50, 0), + "offset": V(0, 0), + "end_profile": False, + "angle": 90, + "verbose": True, + } + self.calculate_and_test(params, 100) + + def test_mep_transition_same_dims_has_x_offset(self): + self.builder = ShapeBuilder(self.file) + params = { + "start_half_dim": V(100, 50, 0), + "end_half_dim": V(100, 50, 0), + "offset": V(50, 50), + "end_profile": False, + "angle": 30, + "verbose": True, + } + self.calculate_and_test(params, 70.71068) + + def test_mep_transition_same_dims_has_y_offset(self): + self.builder = ShapeBuilder(self.file) + params = { + "start_half_dim": V(100, 50, 0), + "end_half_dim": V(100, 50, 0), + "offset": V(0, 50), + "end_profile": False, + "angle": 90, + "verbose": True, + } + self.calculate_and_test(params, 86.60254) + + def test_mep_transition_diff_dims_no_offset(self): + self.builder = ShapeBuilder(self.file) + params = { + "start_half_dim": V(100, 50, 0), + "end_half_dim": V(50, 100, 0), + "offset": V(0, 0), + "end_profile": False, + "angle": 30, + "verbose": True, + } + self.calculate_and_test(params, 186.60254) + + def test_mep_transition_diff_dims_has_x_y_offset(self): + self.builder = ShapeBuilder(self.file) + params = { + "start_half_dim": V(100, 50, 0), + "end_half_dim": V(50, 100, 0), + "offset": V(50, 50), + "end_profile": False, + "angle": 30, + "verbose": True, + } + self.calculate_and_test(params, 165.83124)