mirror of
https://github.com/IfcOpenShell/IfcOpenShell.git
synced 2026-09-12 06:32:09 +00:00
1266 lines
58 KiB
Python
1266 lines
58 KiB
Python
# Bonsai - OpenBIM Blender Add-on
|
|
# Copyright (C) 2020, 2021 Dion Moult <dion@thinkmoult.com>
|
|
#
|
|
# This file is part of Bonsai.
|
|
#
|
|
# Bonsai is free software: you can redistribute it and/or modify
|
|
# it under the terms of the GNU General Public License as published by
|
|
# the Free Software Foundation, either version 3 of the License, or
|
|
# (at your option) any later version.
|
|
#
|
|
# Bonsai 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 General Public License for more details.
|
|
#
|
|
# You should have received a copy of the GNU General Public License
|
|
# along with Bonsai. If not, see <http://www.gnu.org/licenses/>.
|
|
|
|
import collections.abc
|
|
import json
|
|
import re
|
|
from copy import copy
|
|
from math import cos, degrees, pi, radians, sin, tan
|
|
|
|
import bpy
|
|
import ifcopenshell.api.geometry
|
|
import ifcopenshell.api.material
|
|
import ifcopenshell.api.pset
|
|
import ifcopenshell.api.system
|
|
import ifcopenshell.util.element
|
|
import ifcopenshell.util.placement
|
|
import ifcopenshell.util.representation
|
|
import ifcopenshell.util.system
|
|
import ifcopenshell.util.unit
|
|
import numpy as np
|
|
from ifcopenshell.util.shape_builder import ShapeBuilder
|
|
from mathutils import Matrix, Vector
|
|
|
|
import bonsai.core.root
|
|
import bonsai.tool as tool
|
|
from bonsai.bim.module.model.profile import DumbProfileJoiner
|
|
from bonsai.tool.cad import VTX_PRECISION
|
|
|
|
V = lambda *x: Vector([float(i) for i in x])
|
|
|
|
|
|
class RegenerateDistributionElement(bpy.types.Operator, tool.Ifc.Operator):
|
|
bl_idname = "bim.regenerate_distribution_element"
|
|
bl_description = (
|
|
"Regenerates the positions and segment lengths of a distribution element and all connected elements.\n"
|
|
"Will try to adjust as less elements as possible, never rotate them. Segments will also try to change their length to fit"
|
|
)
|
|
bl_label = "Regenerate Distribution Element"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
|
|
def _execute(self, context):
|
|
current_element = tool.Ifc.get_entity(bpy.context.active_object)
|
|
processed_elements = set()
|
|
|
|
# The goal is to regenerate all recursively connected elements that
|
|
# minimise movement as much as possible.
|
|
|
|
# A queue is a list of branches. A branch is a list of elements in
|
|
# sequence, each one connecting to another element. An element in a
|
|
# branch may have a child queue. The queue and child queues are
|
|
# acyclic.
|
|
|
|
def extend_branch(element, branch, predecessor=None):
|
|
processed_elements.add(element)
|
|
branch_element = {"element": element, "children": [], "predecessor": predecessor}
|
|
branch.append(branch_element)
|
|
|
|
connected = {e for e in ifcopenshell.util.system.get_connected_to(element) if e not in processed_elements}
|
|
connected.update(
|
|
[e for e in ifcopenshell.util.system.get_connected_from(element) if e not in processed_elements]
|
|
)
|
|
|
|
for connected_element in connected:
|
|
branch_element["children"].append(extend_branch(connected_element, [], element))
|
|
|
|
return branch
|
|
|
|
extended_branch = extend_branch(current_element, [])
|
|
queue = extended_branch[0]["children"]
|
|
|
|
# import pprint
|
|
# pprint.pprint(queue)
|
|
|
|
def get_connected_ports_between(element1, element2):
|
|
ports1 = tool.System.get_ports(element1)
|
|
ports2 = tool.System.get_ports(element2)
|
|
|
|
for p in ports1:
|
|
connected_port = tool.System.get_connected_port(p)
|
|
# in IFC2X3 there is no PredefinedType
|
|
if getattr(p, "PredefinedType", None) == "WIRELESS":
|
|
continue
|
|
if connected_port in ports2:
|
|
return p, connected_port
|
|
|
|
return None, None
|
|
|
|
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
|
|
|
def process_branch(branch):
|
|
for branch_element in branch:
|
|
element = branch_element["element"]
|
|
print("processing", element)
|
|
predecessor = branch_element["predecessor"]
|
|
|
|
# Perform the extend, translate, rotate, etc the element as necessary based on the predecessor.
|
|
# For everything besides segments, only translate. No rotation.
|
|
|
|
obj = tool.Ifc.get_object(element)
|
|
obj_pred = tool.Ifc.get_object(predecessor)
|
|
tool.Model.sync_object_ifc_position(obj)
|
|
tool.Model.sync_object_ifc_position(obj_pred)
|
|
|
|
port, port_pred = get_connected_ports_between(element, predecessor)
|
|
port_matrix_pred = tool.Model.get_element_matrix(port_pred)
|
|
|
|
# Only segments can be extended
|
|
# extension for them takes priority over translation
|
|
if element.is_a("IfcFlowSegment"):
|
|
DumbProfileJoiner().join_E(obj, port_matrix_pred.translation * si_conversion)
|
|
context.view_layer.update() # update since extrusion might involve changing object's location
|
|
|
|
port_martix = tool.Model.get_element_matrix(port)
|
|
port_location = port_martix.translation
|
|
port_location_pred = port_matrix_pred.translation
|
|
if not tool.Cad.are_vectors_equal(port_location, port_location_pred):
|
|
obj.location += (port_location_pred - port_location) * si_conversion
|
|
context.view_layer.update() # otherwise tool.Ifc.is_moved won't get triggered
|
|
else:
|
|
# If the element does not need to be transformed, return early.
|
|
return
|
|
|
|
for child_branch in branch_element["children"]:
|
|
process_branch(child_branch)
|
|
|
|
for branch in queue:
|
|
process_branch(branch)
|
|
|
|
|
|
class FitFlowSegments(bpy.types.Operator, tool.Ifc.Operator):
|
|
bl_idname = "bim.fit_flow_segments"
|
|
bl_description = "Add a fitting based on currently selected elements and cursor"
|
|
bl_label = "Fit Flow Segments"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
|
|
def _execute(self, context):
|
|
# TODO: need to add ui for parameters:
|
|
# - obstruction cap thickness
|
|
# - start/end thickness and angle for transition
|
|
selected_objs = []
|
|
selected_profiles = []
|
|
|
|
selected_class = None
|
|
for obj in context.selected_objects:
|
|
element = tool.Ifc.get_entity(obj)
|
|
if element and element.is_a("IfcFlowSegment"):
|
|
if selected_class and not element.is_a(selected_class):
|
|
return # The user is mixing up ducts and pipes.
|
|
profile = tool.Model.get_flow_segment_profile(element)
|
|
if profile:
|
|
selected_profiles.append(profile)
|
|
selected_objs.append(obj)
|
|
selected_class = element.is_a()
|
|
|
|
total_selected_objs = len(selected_objs)
|
|
total_profiles = len(set(selected_profiles))
|
|
fitting_type = None
|
|
|
|
if total_selected_objs == 1:
|
|
fitting_type = "OBSTRUCTION"
|
|
bpy.ops.bim.mep_add_obstruction()
|
|
|
|
elif total_selected_objs == 2:
|
|
# Shorten the axis by the profile size to allow for fuzzy intersections
|
|
# e.g. if two ducts touch, we want a bend, not a cross.
|
|
|
|
axis1 = tool.Model.get_flow_segment_axis(selected_objs[0])
|
|
profile_size = max(selected_objs[0].dimensions.x, selected_objs[0].dimensions.y)
|
|
offset = (axis1[1] - axis1[0]).normalized() * profile_size
|
|
axis1 = (axis1[0] + offset, axis1[1] - offset)
|
|
|
|
axis2 = tool.Model.get_flow_segment_axis(selected_objs[1])
|
|
profile_size = max(selected_objs[1].dimensions.x, selected_objs[1].dimensions.y)
|
|
offset = (axis2[1] - axis2[0]).normalized() * profile_size
|
|
axis2 = (axis2[0] + offset, axis2[1] - offset)
|
|
|
|
angle = tool.Cad.angle_edges(axis1, axis2, signed=False, degrees=True)
|
|
is_parallel = tool.Cad.is_x(angle, (0, 180), tolerance=0.001)
|
|
|
|
if total_profiles == 1:
|
|
if is_parallel:
|
|
return
|
|
intersect1, intersect2 = tool.Cad.intersect_edges(axis1, axis2)
|
|
is_on_axis1 = tool.Cad.is_point_on_edge(intersect1, axis1)
|
|
is_on_axis2 = tool.Cad.is_point_on_edge(intersect2, axis2)
|
|
if not is_on_axis1 and not is_on_axis2:
|
|
fitting_type = "BEND"
|
|
bpy.ops.bim.mep_add_bend()
|
|
elif is_on_axis1 and is_on_axis2:
|
|
fitting_type = "CROSS"
|
|
else:
|
|
fitting_type = "TEE"
|
|
elif total_profiles == 2:
|
|
if is_parallel:
|
|
fitting_type = "TRANSITION"
|
|
bpy.ops.bim.mep_add_transition()
|
|
|
|
elif total_selected_objs == 3:
|
|
if total_profiles > 1:
|
|
return
|
|
|
|
axis1 = tool.Model.get_flow_segment_axis(selected_objs[0])
|
|
axis2 = tool.Model.get_flow_segment_axis(selected_objs[1])
|
|
axis3 = tool.Model.get_flow_segment_axis(selected_objs[2])
|
|
|
|
angle12 = tool.Cad.angle_edges(axis1, axis2, signed=False, degrees=True)
|
|
angle13 = tool.Cad.angle_edges(axis1, axis3, signed=False, degrees=True)
|
|
angle21 = tool.Cad.angle_edges(axis2, axis1, signed=False, degrees=True)
|
|
angle23 = tool.Cad.angle_edges(axis2, axis3, signed=False, degrees=True)
|
|
is_parallel12 = tool.Cad.is_x(angle12, (0, 180), tolerance=0.001)
|
|
is_parallel13 = tool.Cad.is_x(angle13, (0, 180), tolerance=0.001)
|
|
is_parallel21 = tool.Cad.is_x(angle21, (0, 180), tolerance=0.001)
|
|
is_parallel23 = tool.Cad.is_x(angle23, (0, 180), tolerance=0.001)
|
|
|
|
if not all([is_parallel12, is_parallel13, is_parallel21, is_parallel23]):
|
|
fitting_type = "WYE"
|
|
|
|
if not fitting_type:
|
|
return
|
|
|
|
print(fitting_type)
|
|
|
|
|
|
class MEPGenerator:
|
|
def __init__(self, relating_type=None):
|
|
self.relating_type = relating_type
|
|
|
|
def setup_ports(self, obj):
|
|
self.file = tool.Ifc.get()
|
|
|
|
segment = tool.Ifc.get_entity(obj)
|
|
representation = ifcopenshell.util.representation.get_representation(segment, "Model", "Body", "MODEL_VIEW")
|
|
extrusion = tool.Model.get_extrusion(representation)
|
|
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get())
|
|
length = extrusion.Depth * si_conversion
|
|
start_port_matrix = obj.matrix_world @ Matrix()
|
|
end_port_matrix = obj.matrix_world @ Matrix.Translation((0, 0, length))
|
|
|
|
ports = tool.System.get_ports(segment)
|
|
if segment.is_a("IfcFlowSegment") and not ports:
|
|
tool.System.add_ports(obj)
|
|
return
|
|
|
|
# adjust current segment ports and related flow segments
|
|
segment_data = self.get_segment_data(segment)
|
|
|
|
for port_position in ("start_port", "end_port"):
|
|
port = segment_data.get(port_position, None)
|
|
if not port:
|
|
continue
|
|
|
|
# no need to correct start port position - it's corrected automatically
|
|
# as DumbProfileJoiner already moved the general segment position in that case
|
|
if port_position == "end_port":
|
|
tool.Model.edit_element_placement(port, end_port_matrix)
|
|
|
|
continue
|
|
|
|
# NOTE: currently this functionality is moved to bim.regenerate_distribution_element
|
|
|
|
connected_port = tool.System.get_connected_port(port)
|
|
if not connected_port:
|
|
continue
|
|
|
|
# handle only obstructions for now
|
|
connected_element = tool.System.get_port_relating_element(connected_port)
|
|
|
|
def get_predefined_type(element):
|
|
element_type = ifcopenshell.util.element.get_type(element)
|
|
if element_type:
|
|
return element_type.PredefinedType
|
|
return element.PredefinedType
|
|
|
|
connected_obj = tool.Ifc.get_object(connected_element)
|
|
connected_element_length = connected_obj.dimensions.z
|
|
if (segment.is_a("IfcFlowSegment") and get_predefined_type(connected_element) == "OBSTRUCTION") or (
|
|
segment.is_a("IfcFlowFitting") and connected_element.is_a("IfcFlowSegment")
|
|
):
|
|
if port_position == "start_port":
|
|
if segment.is_a("IfcFlowFitting"):
|
|
connected_element_length = (
|
|
tool.Model.get_flow_segment_axis(connected_obj)[0]
|
|
- tool.Model.get_flow_segment_axis(obj)[0]
|
|
).length
|
|
|
|
connected_port_matrix = start_port_matrix @ Matrix.Translation((0, 0, -connected_element_length))
|
|
else:
|
|
connected_port_matrix = end_port_matrix
|
|
connected_obj.matrix_world = connected_port_matrix
|
|
if port_position == "start_port" and segment.is_a("IfcFlowFitting"):
|
|
profile_joiner = DumbProfileJoiner()
|
|
profile_joiner.set_depth(connected_obj, connected_element_length)
|
|
|
|
def get_segment_data(self, segment):
|
|
"""returns points data is in world space"""
|
|
ports = tool.System.get_ports(segment)
|
|
segment_object = tool.Ifc.get_object(segment)
|
|
start_point = segment_object.location
|
|
extrusion_depth = segment_object.dimensions.z
|
|
end_point = segment_object.matrix_world @ V(0, 0, extrusion_depth)
|
|
segment_data = {
|
|
"start_point": start_point.copy().freeze(),
|
|
"end_point": end_point.freeze(),
|
|
"ports": ports,
|
|
"extrusion_depth": extrusion_depth,
|
|
}
|
|
|
|
for port in ports:
|
|
port_local_position = V(*port.ObjectPlacement.RelativePlacement.Location.Coordinates)
|
|
if tool.Cad.is_x(port_local_position.length, 0.0):
|
|
segment_data["start_port"] = port
|
|
else:
|
|
segment_data["end_port"] = port
|
|
|
|
return segment_data
|
|
|
|
def get_mep_element_class_name(self, element, mep_class_type):
|
|
split_camel_case = lambda x: re.findall("[A-Z][^A-Z]*", x)
|
|
class_name = "".join(split_camel_case(element.is_a())[:-1] + [mep_class_type])
|
|
return class_name
|
|
|
|
def get_compatible_fitting_type(self, segment_or_segments, port_or_ports, predefined_type, bbim_data=None):
|
|
"""
|
|
returns a dict of compatible fitting_type and start_port_match flag to correctly place the fitting.
|
|
|
|
We find compatible fitting only by checking
|
|
if they were already used with that segment type before
|
|
and fitting's ports should match `port_or_ports` by PredefinedType and SystemType.
|
|
|
|
If port from `port_or_ports` has PredefinedType/SystemType == None/NOTDEFINED then
|
|
those parameters won't be taken into account checking compatibility.
|
|
|
|
There lies the problem that it won't be
|
|
able to identify the fittings that were not yet connected to any segments yet.
|
|
|
|
|
|
`bbim_data` is used to find compatible fitting build with BBIM parametrically (BBIM_Fitting pset).
|
|
All data in `bbim_data` supposed to be in project units.
|
|
"""
|
|
|
|
if not isinstance(segment_or_segments, collections.abc.Iterable):
|
|
segments = [segment_or_segments]
|
|
ports = [port_or_ports]
|
|
else:
|
|
segments = segment_or_segments
|
|
ports = port_or_ports
|
|
|
|
ifc_file = tool.Ifc.get()
|
|
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
|
|
precision = VTX_PRECISION / si_conversion
|
|
angle_precision = degrees(precision)
|
|
start_port_match = True
|
|
|
|
segments_data = []
|
|
for segment, port in zip(segments, ports, strict=True):
|
|
segment_type = ifcopenshell.util.element.get_type(segment)
|
|
# if segment doesn't have type we cannot check compatibility by available occurrences
|
|
if segment_type is None:
|
|
return
|
|
segments_data.append((segment_type, port.PredefinedType, port.SystemType))
|
|
|
|
def compatible_with_bbim_data(fitting_type):
|
|
nonlocal start_port_match
|
|
start_port_match = True
|
|
if not bbim_data:
|
|
return True
|
|
fitting_type_obj = tool.Ifc.get_object(fitting_type)
|
|
fitting_bbim_data = tool.Model.get_modeling_bbim_pset_data(fitting_type_obj, "BBIM_Fitting")
|
|
if not fitting_bbim_data:
|
|
return False
|
|
|
|
fitting_bbim_data = fitting_bbim_data["data_dict"]
|
|
|
|
def compare_value(key, second_key=None):
|
|
second_key = second_key or key
|
|
requested_value = bbim_data[key]
|
|
fitting_value = fitting_bbim_data[second_key]
|
|
|
|
if isinstance(requested_value, float):
|
|
compare_precision = angle_precision if key == "angle" else precision
|
|
compare = tool.Cad.is_x(requested_value, fitting_value, compare_precision)
|
|
elif isinstance(fitting_value, list):
|
|
compare = tool.Cad.are_vectors_equal(requested_value, Vector(fitting_value), precision)
|
|
return compare
|
|
|
|
ignore_keys = []
|
|
if predefined_type == "BEND":
|
|
ignore_keys.extend(("start_length", "end_length"))
|
|
# for bends there is a special case when lengths might not match
|
|
# but fitting is still compatible if we flip it
|
|
# since bend connects segments of the same type
|
|
default_lengths_match = compare_value("start_length") and compare_value("end_length")
|
|
if not default_lengths_match:
|
|
switched_lengths_match = compare_value("start_length", "end_length") and compare_value(
|
|
"end_length", "start_length"
|
|
)
|
|
if switched_lengths_match:
|
|
start_port_match = False
|
|
else:
|
|
return False
|
|
|
|
for key in bbim_data:
|
|
if key in ignore_keys:
|
|
continue
|
|
if not compare_value(key):
|
|
return False
|
|
return True
|
|
|
|
def are_connected_elements_compatible(segments_data, fitting_data):
|
|
# prevent arguments mutation, not using deepcopy because of the errors with ifc elements
|
|
segments_data = [copy(i) for i in segments_data]
|
|
fitting_data = [copy(i) for i in fitting_data]
|
|
not_defined_values = {"NOTDEFINED", None}
|
|
|
|
if len(segments_data) != len(fitting_data):
|
|
return False
|
|
|
|
def are_segments_compatible(test_segment_data, base_segment_data):
|
|
segment_type, predefined_type, system_type = test_segment_data
|
|
base_segment_type, base_predefined_type, base_system_type = base_segment_data
|
|
|
|
if segment_type != base_segment_type:
|
|
return False
|
|
|
|
if predefined_type not in not_defined_values and predefined_type != base_predefined_type:
|
|
return False
|
|
|
|
if system_type not in not_defined_values and system_type != base_system_type:
|
|
return False
|
|
|
|
return True
|
|
|
|
# NOTE: I have a feeling that there are cases where order
|
|
# in which we're checking the segments is important
|
|
# but I couldn't pin it down to exact cases
|
|
for test_segment_data in fitting_data:
|
|
for base_segment_data in segments_data:
|
|
if not are_segments_compatible(test_segment_data, base_segment_data):
|
|
continue
|
|
segments_data.remove(base_segment_data)
|
|
break
|
|
|
|
# all segments were sorted
|
|
return len(segments_data) == 0
|
|
|
|
def pack_return_data(fitting_type, ports, segments_data):
|
|
packed_data = {"fitting_type": fitting_type}
|
|
|
|
if predefined_type == "OBSTRUCTION":
|
|
return packed_data
|
|
|
|
for port in ports:
|
|
port_local_position = V(*port.ObjectPlacement.RelativePlacement.Location.Coordinates)
|
|
if tool.Cad.is_x(port_local_position.length, 0.0):
|
|
start_port = port
|
|
break
|
|
|
|
connected_port = tool.System.get_connected_port(start_port)
|
|
connected_element = tool.System.get_port_relating_element(connected_port)
|
|
element_type = ifcopenshell.util.element.get_type(connected_element)
|
|
packed_data["start_port_match"] = element_type == segments_data[0][0] and start_port_match
|
|
|
|
return packed_data
|
|
|
|
fitting_types = tool.Ifc.get().by_type(self.get_mep_element_class_name(segments[0], "FittingType"))
|
|
for fitting_type in fitting_types:
|
|
if fitting_type.PredefinedType != predefined_type:
|
|
continue
|
|
fittings = tool.Ifc.get_all_element_occurrences(fitting_type)
|
|
if not fittings:
|
|
continue
|
|
|
|
for fitting in fittings:
|
|
ports = ifcopenshell.util.system.get_ports(fitting)
|
|
fitting_data = []
|
|
skipped_the_occurrence = False
|
|
for port in ports:
|
|
connected_port = tool.System.get_connected_port(port)
|
|
|
|
# fitting port is not connected to anything
|
|
if not connected_port:
|
|
skipped_the_occurrence = True
|
|
break
|
|
|
|
connected_element = tool.System.get_port_relating_element(connected_port)
|
|
element_type = ifcopenshell.util.element.get_type(connected_element)
|
|
|
|
# fitting is connected to none type
|
|
if element_type is None:
|
|
skipped_the_occurrence = True
|
|
break
|
|
|
|
fitting_data.append((element_type, port.PredefinedType, port.SystemType))
|
|
|
|
# if we skipped the occurrence we still need to check other occurrences
|
|
# otherwise checking 1 occurrence is enough
|
|
if not skipped_the_occurrence:
|
|
if compatible_with_bbim_data(fitting_type) and are_connected_elements_compatible(
|
|
segments_data, fitting_data
|
|
):
|
|
return pack_return_data(fitting_type, ports, segments_data)
|
|
break
|
|
|
|
def create_obstruction_type(self, segment):
|
|
# code is very similar to "bim.add_element"
|
|
profile_set = ifcopenshell.util.element.get_material(segment, should_skip_usage=True)
|
|
material_profile = profile_set.MaterialProfiles[0]
|
|
profile = material_profile.Profile
|
|
material = material_profile.Material
|
|
ifc_class = self.get_mep_element_class_name(segment, "FittingType")
|
|
ifc_file = tool.Ifc.get()
|
|
body = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW")
|
|
|
|
obj = bpy.data.objects.new("Obstruction", None)
|
|
# TODO: OBSTRUCTION predefined type is available only for IfcDuctFitting and IfcPipeFitting
|
|
element = bonsai.core.root.assign_class(
|
|
tool.Ifc,
|
|
tool.Collector,
|
|
tool.Root,
|
|
obj=obj,
|
|
ifc_class=ifc_class,
|
|
predefined_type="OBSTRUCTION",
|
|
should_add_representation=True,
|
|
context=body,
|
|
ifc_representation_class=None,
|
|
)
|
|
|
|
rel = ifcopenshell.api.material.assign_material(ifc_file, products=[element], type="IfcMaterialProfileSet")
|
|
profile_set = rel.RelatingMaterial
|
|
material_profile = ifcopenshell.api.material.add_profile(ifc_file, profile_set=profile_set, material=material)
|
|
ifcopenshell.api.material.assign_profile(ifc_file, material_profile=material_profile, profile=profile)
|
|
return element
|
|
|
|
def add_obstruction(self, segment, length, at_segment_start=False):
|
|
"""
|
|
`segment` is a segment ifc element
|
|
|
|
`length` is obstruction length provided in si units
|
|
|
|
returns `(None, error_message)` if there was some error in the process
|
|
or returns `(obstruction_element, None)` if everything went fine.
|
|
"""
|
|
|
|
related_port_name = "start" if at_segment_start else "end"
|
|
segment_data = self.get_segment_data(segment)
|
|
related_port = segment_data[f"{related_port_name}_port"]
|
|
|
|
# communicate error cases
|
|
if related_port.ConnectedTo or related_port.ConnectedFrom:
|
|
return None, f"Failed to add obstruction - {related_port_name} port is already connected."
|
|
if length >= segment_data["extrusion_depth"]:
|
|
return None, "Failed to add obstruction - obstruction length is larger than the segment."
|
|
|
|
ifc_file = tool.Ifc.get()
|
|
segment_obj = tool.Ifc.get_object(segment)
|
|
assert isinstance(segment_obj, bpy.types.Object)
|
|
segment_matrix = segment_obj.matrix_world
|
|
segment_rotation = segment_matrix.to_quaternion()
|
|
fitting_data = self.get_compatible_fitting_type(segment, related_port, "OBSTRUCTION")
|
|
obstruction_type = fitting_data["fitting_type"] if fitting_data else None
|
|
if not obstruction_type:
|
|
obstruction_type = self.create_obstruction_type(segment)
|
|
|
|
profile_joiner = DumbProfileJoiner()
|
|
# create obstruction occurrence and setup it's length and port
|
|
# NOTE: at this point we loose current blender objects selection
|
|
bpy.ops.bim.add_occurrence(relating_type_id=obstruction_type.id())
|
|
obstruction_obj = bpy.context.active_object
|
|
assert obstruction_obj
|
|
obstruction_obj.matrix_world = segment_matrix
|
|
|
|
profile_joiner.set_depth(obstruction_obj, length)
|
|
# NOTE: we add ports to the obstruction occurence and not to the type
|
|
# since it's material profile based like segments
|
|
obstruction_port = tool.System.add_ports(
|
|
obstruction_obj,
|
|
add_start_port=not at_segment_start,
|
|
add_end_port=at_segment_start,
|
|
)[0]
|
|
|
|
# change segment length
|
|
new_segment_length = segment_data["extrusion_depth"] - length
|
|
profile_joiner.set_depth(segment_obj, new_segment_length)
|
|
|
|
if at_segment_start:
|
|
segment_obj.location += segment_rotation @ V(0, 0, length)
|
|
else:
|
|
obstruction_obj.location += segment_rotation @ V(0, 0, new_segment_length)
|
|
|
|
ifcopenshell.api.system.connect_port(
|
|
ifc_file, port1=related_port, port2=obstruction_port, direction="NOTDEFINED"
|
|
)
|
|
obstruction = tool.Ifc.get_entity(obstruction_obj)
|
|
return obstruction, None
|
|
|
|
|
|
class MEPAddObstruction(bpy.types.Operator, tool.Ifc.Operator):
|
|
bl_idname = "bim.mep_add_obstruction"
|
|
bl_label = "Add Obstruction"
|
|
bl_description = "Adds obstruction to the MEP segment"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
length: bpy.props.FloatProperty(
|
|
name="Obstruction Length", description="Obstruction length in SI units", default=0.1, subtype="DISTANCE"
|
|
)
|
|
segment_id: bpy.props.IntProperty(name="Segment Element ID", default=0)
|
|
|
|
def _execute(self, context):
|
|
if self.segment_id:
|
|
element = tool.Ifc.get().by_id(self.segment_id)
|
|
else:
|
|
element = tool.Ifc.get_entity(context.active_object)
|
|
if not element:
|
|
return {"CANCELLED"}
|
|
|
|
if not element.is_a("IfcFlowSegment"):
|
|
self.report({"ERROR"}, f"Failed to add obstruction - object is not a MEP segment: {element.is_a()}.")
|
|
return {"CANCELLED"}
|
|
|
|
# derive obstruction position from the cursor
|
|
cursor_location = bpy.context.scene.cursor.location
|
|
obj = tool.Ifc.get_object(element)
|
|
axis = tool.Model.get_flow_segment_axis(obj)
|
|
# check if cursor is closer to the segment start
|
|
at_segment_start = tool.Cad.edge_percent(cursor_location, axis) < 0.5
|
|
|
|
obstruction, error_msg = MEPGenerator().add_obstruction(element, self.length, at_segment_start)
|
|
if error_msg:
|
|
self.report({"ERROR"}, error_msg)
|
|
return {"CANCELLED"}
|
|
|
|
return {"FINISHED"}
|
|
|
|
|
|
class MEPAddTransition(bpy.types.Operator, tool.Ifc.Operator):
|
|
bl_idname = "bim.mep_add_transition"
|
|
bl_label = "Add Transition"
|
|
bl_description = "Adds transition between two selected MEP Elements"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
start_length: bpy.props.FloatProperty(
|
|
name="Start Length", description="Transition start length in SI units", default=0.1, subtype="DISTANCE", min=0
|
|
)
|
|
end_length: bpy.props.FloatProperty(
|
|
name="End Length", description="Transition end length in SI units", default=0.1, subtype="DISTANCE", min=0
|
|
)
|
|
angle: bpy.props.FloatProperty(
|
|
name="Transition Angle", description="Transition angle in degrees", default=pi / 6, subtype="ANGLE", min=0
|
|
)
|
|
start_segment_id: bpy.props.IntProperty(name="Start Segment Element ID", default=0)
|
|
end_segment_id: bpy.props.IntProperty(name="End Segment Element ID", default=0)
|
|
|
|
def _execute(self, context):
|
|
start_element, end_element = None, None
|
|
ifc_file = tool.Ifc.get()
|
|
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
|
|
|
|
if self.start_segment_id and self.end_segment_id:
|
|
start_element = ifc_file.by_id(self.start_segment_id)
|
|
end_element = ifc_file.by_id(self.end_segment_id)
|
|
start_object = tool.Ifc.get_object(start_element)
|
|
end_object = tool.Ifc.get_object(end_element)
|
|
|
|
elif len(context.selected_objects) == 2:
|
|
start_object = context.active_object
|
|
end_object = next(o for o in context.selected_objects if o != context.active_object)
|
|
start_element = tool.Ifc.get_entity(start_object)
|
|
end_element = tool.Ifc.get_entity(end_object)
|
|
if not start_element or not end_element:
|
|
self.report({"ERROR"}, f"Two IFC elements should be selected for the transition")
|
|
return {"CANCELLED"}
|
|
|
|
else:
|
|
self.report({"ERROR"}, f"Two IFC elements should be provided for the transition")
|
|
return {"CANCELLED"}
|
|
|
|
# TODO: support IfcFlowTerminal
|
|
def is_mep(element):
|
|
return element.is_a("IfcFlowSegment") or element.is_a("IfcFlowFitting")
|
|
|
|
if not is_mep(start_element) or not is_mep(end_element):
|
|
self.report(
|
|
{"ERROR"},
|
|
f"Failed to add transition - some object is not a MEP element: {start_element.is_a()}, {end_element.is_a()}.",
|
|
)
|
|
return {"CANCELLED"}
|
|
|
|
start_axis = tool.Model.get_flow_segment_axis(start_object)
|
|
end_axis = tool.Model.get_flow_segment_axis(end_object)
|
|
start_object_rotation = start_object.matrix_world.to_quaternion()
|
|
start_object_z_basis = start_object_rotation.to_matrix().col[2] # z basis vector
|
|
keep_only_z_axis = lambda p_ws: p_ws.dot(start_object_z_basis) * start_object_z_basis
|
|
|
|
if not tool.Cad.are_edges_parallel(start_axis, end_axis):
|
|
self.report({"ERROR"}, f"Failed to add transition - segments are not parallel.")
|
|
return {"CANCELLED"}
|
|
|
|
# TODO: support different profiles rotation by local Z
|
|
# check rotation difference
|
|
end_object_rotation = end_object.matrix_world.to_quaternion()
|
|
rotation_difference_z = (
|
|
start_object.matrix_world.to_quaternion().rotation_difference(end_object_rotation).to_euler().z
|
|
)
|
|
|
|
def is_multiple_of_pi(value):
|
|
n = round(value / pi)
|
|
return tool.Cad.is_x(abs(value - n * pi), 0)
|
|
|
|
if not is_multiple_of_pi(rotation_difference_z):
|
|
self.report(
|
|
{"ERROR"},
|
|
"There is some rotation difference between profiles by local Z axis: "
|
|
f"{round(degrees(rotation_difference_z))} deg, this kind of transition is not yet supported.",
|
|
)
|
|
return {"CANCELLED"}
|
|
|
|
# setup start / end points
|
|
start_segment_data = MEPGenerator().get_segment_data(start_element)
|
|
end_segment_data = MEPGenerator().get_segment_data(end_element)
|
|
points_ports_map = {
|
|
start_segment_data["start_point"]: start_segment_data["start_port"],
|
|
start_segment_data["end_point"]: start_segment_data["end_port"],
|
|
end_segment_data["start_point"]: end_segment_data["start_port"],
|
|
end_segment_data["end_point"]: end_segment_data["end_port"],
|
|
}
|
|
# transition points
|
|
(start_point, end_point), (first_segment_start, second_segment_end) = tool.Cad.closest_points(
|
|
(start_segment_data["start_point"], start_segment_data["end_point"]),
|
|
(end_segment_data["start_point"], end_segment_data["end_point"]),
|
|
)
|
|
start_port = points_ports_map[start_point]
|
|
end_port = points_ports_map[end_point]
|
|
start_point_on_origin = start_point == start_segment_data["start_point"]
|
|
start_connection = "ATSTART" if start_point_on_origin else "ATEND"
|
|
start_segment_sign = -1 if start_point_on_origin else 1
|
|
|
|
end_point_on_origin = end_point == end_segment_data["start_point"]
|
|
end_connection = "ATSTART" if end_point_on_origin else "ATEND"
|
|
|
|
# figure profile offset
|
|
base_transition_dir = keep_only_z_axis(end_point - start_point).normalized()
|
|
flip_profile_offset = base_transition_dir.dot(start_object_z_basis) < 0
|
|
|
|
if tool.Cad.are_edges_collinear(start_axis, end_axis):
|
|
profile_offset = V(0, 0)
|
|
else:
|
|
to_start_object_space = start_object_rotation.inverted()
|
|
profile_offset = (
|
|
(to_start_object_space @ end_object.location) - (to_start_object_space @ start_object.location)
|
|
).xy
|
|
profile_offset = profile_offset / si_conversion
|
|
if flip_profile_offset:
|
|
profile_offset *= V(1, -1)
|
|
|
|
# world space profile offset
|
|
profile_offset_si = (profile_offset * si_conversion).to_3d()
|
|
profile_offset_ws = start_object_rotation @ profile_offset_si
|
|
|
|
def get_segments_length():
|
|
start_dir = (start_point - first_segment_start).normalized()
|
|
segments_vector = second_segment_end - first_segment_start
|
|
return segments_vector.dot(start_dir)
|
|
|
|
entire_length = get_segments_length()
|
|
|
|
# can't rely on (end_point-start_point) here because
|
|
# transition might change the segments length and therefore direction will be changed
|
|
segments_dir = (start_point - first_segment_start).normalized()
|
|
|
|
# add transition representation
|
|
builder = ShapeBuilder(ifc_file)
|
|
rep, transition_data = builder.mep_transition_shape(
|
|
start_element,
|
|
end_element,
|
|
self.start_length / si_conversion,
|
|
self.end_length / si_conversion,
|
|
angle=degrees(self.angle),
|
|
profile_offset=profile_offset,
|
|
)
|
|
|
|
if not rep:
|
|
self.report({"ERROR"}, f"Failed to add transition - this kind of profiles is not yet supported.")
|
|
return {"CANCELLED"}
|
|
|
|
full_transition_length = transition_data["full_transition_length"] * si_conversion
|
|
if full_transition_length >= entire_length:
|
|
self.report(
|
|
{"ERROR"},
|
|
f"Failed to add transition - transition length is larger the segments and the distance between them.\n"
|
|
+ f"Transition length: {full_transition_length:.2f}m, segments length: {entire_length:.2f}m",
|
|
)
|
|
ifcopenshell.api.geometry.remove_representation(ifc_file, representation=rep)
|
|
return {"CANCELLED"}
|
|
|
|
# calculate bunch of points to for adjustments
|
|
middle_point = keep_only_z_axis((start_point + end_point) / 2 - start_point) + start_point
|
|
start_segment_extend_point = middle_point - segments_dir * full_transition_length / 2
|
|
end_segment_extend_point = middle_point + segments_dir * full_transition_length / 2 + profile_offset_ws
|
|
transition_dir = keep_only_z_axis(end_segment_extend_point - start_segment_extend_point).normalized()
|
|
|
|
# adjust the segments
|
|
DumbProfileJoiner().join_E(start_object, start_segment_extend_point, start_connection)
|
|
DumbProfileJoiner().join_E(end_object, end_segment_extend_point, end_connection)
|
|
|
|
# For bbim transitions, there is small convention that:
|
|
# - start_length segment positioned at the start of the transition's Z-axis.
|
|
# - end_length segment positioned at the of it.
|
|
# this is why we sort the lengths in parametric data too
|
|
parametric_data = {
|
|
"start_length": (self.start_length if start_segment_sign == 1 else self.end_length) / si_conversion,
|
|
"end_length": (self.end_length if start_segment_sign == 1 else self.start_length) / si_conversion,
|
|
"profile_offset": profile_offset,
|
|
"angle": degrees(self.angle),
|
|
}
|
|
|
|
# find the compatible fitting type
|
|
fitting_data = MEPGenerator().get_compatible_fitting_type(
|
|
[start_element, end_element], [start_port, end_port], "TRANSITION", bbim_data=parametric_data
|
|
)
|
|
transition_type = fitting_data["fitting_type"] if fitting_data else None
|
|
start_port_match = fitting_data["start_port_match"] if fitting_data else True
|
|
if transition_type:
|
|
# TODO: handle the case without creating a representation in the first place?
|
|
ifcopenshell.api.geometry.remove_representation(ifc_file, representation=rep)
|
|
else: # create new fitting type if nothing is compatible
|
|
mesh = bpy.data.meshes.new("Transition")
|
|
obj = bpy.data.objects.new("Transition", mesh)
|
|
transition_type = bonsai.core.root.assign_class(
|
|
tool.Ifc,
|
|
tool.Collector,
|
|
tool.Root,
|
|
obj=obj,
|
|
ifc_class=MEPGenerator().get_mep_element_class_name(start_element, "FittingType"),
|
|
predefined_type="TRANSITION",
|
|
should_add_representation=False,
|
|
)
|
|
body = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW")
|
|
# Will implicitly remove `mesh`.
|
|
tool.Model.replace_object_ifc_representation(body, obj, rep)
|
|
pset = ifcopenshell.api.pset.add_pset(tool.Ifc.get(), product=transition_type, name="BBIM_Fitting")
|
|
ifcopenshell.api.pset.edit_pset(
|
|
tool.Ifc.get(),
|
|
pset=pset,
|
|
properties={"Data": tool.Ifc.get().createIfcText(json.dumps(transition_data, default=list))},
|
|
)
|
|
tool.System.add_ports(obj, offset_end_port=profile_offset_si)
|
|
|
|
# NOTE: at this point we loose current blender objects selection
|
|
# create transition element
|
|
bpy.ops.bim.add_occurrence(relating_type_id=transition_type.id())
|
|
transition_obj = bpy.context.active_object
|
|
assert transition_obj
|
|
|
|
# adjust transition segment rotation and location
|
|
# required since we'll base our `transition_obj_dir` on this
|
|
transition_obj.matrix_world = start_object.matrix_world
|
|
context.view_layer.update()
|
|
|
|
# depending on transition direction we may need to flip it or attach it's origin to end segment
|
|
# direction can be different depending on:
|
|
# - order of the current segments
|
|
# - order of the segments that were used with the same transition type before
|
|
transition_obj_dir = tool.Cad.get_edge_direction(tool.Model.get_flow_segment_axis(transition_obj))
|
|
direction_match = tool.Cad.are_vectors_equal(transition_dir, transition_obj_dir)
|
|
# if there are no mismatches or everything matches up we don't need to flip the transition
|
|
if start_port_match != direction_match:
|
|
transition_obj.matrix_world = start_object.matrix_world @ Matrix.Rotation(radians(180), 4, "X")
|
|
transition_obj.location = start_segment_extend_point if start_port_match else end_segment_extend_point
|
|
|
|
# add ports and connect them
|
|
ports = tool.System.get_ports(tool.Ifc.get_entity(transition_obj))
|
|
if not start_port_match:
|
|
start_port, end_port = end_port, start_port
|
|
ifcopenshell.api.system.connect_port(ifc_file, port1=ports[0], port2=start_port, direction="NOTDEFINED")
|
|
ifcopenshell.api.system.connect_port(ifc_file, port1=ports[1], port2=end_port, direction="NOTDEFINED")
|
|
return {"FINISHED"}
|
|
|
|
|
|
class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator):
|
|
bl_idname = "bim.mep_add_bend"
|
|
bl_label = "Add Bend"
|
|
bl_description = "Adds a bend between two selected MEP Elements"
|
|
bl_options = {"REGISTER", "UNDO"}
|
|
start_length: bpy.props.FloatProperty(
|
|
name="Start Length", description="Bend start length in SI units", default=0.1, subtype="DISTANCE", min=0
|
|
)
|
|
end_length: bpy.props.FloatProperty(
|
|
name="End Length", description="Bend end length in SI units", default=0.1, subtype="DISTANCE", min=0
|
|
)
|
|
start_segment_id: bpy.props.IntProperty(name="Start Segment Element ID", default=0)
|
|
end_segment_id: bpy.props.IntProperty(name="End Segment Element ID", default=0)
|
|
radius: bpy.props.FloatProperty(
|
|
name="Bend Inner Radius", description="Bend inner radius in SI units", default=0.2, subtype="DISTANCE", min=0
|
|
)
|
|
|
|
def _execute(self, context):
|
|
start_element, end_element = None, None
|
|
ifc_file = tool.Ifc.get()
|
|
si_conversion = ifcopenshell.util.unit.calculate_unit_scale(ifc_file)
|
|
|
|
if self.start_segment_id and self.end_segment_id:
|
|
start_element = ifc_file.by_id(self.start_segment_id)
|
|
end_element = ifc_file.by_id(self.end_segment_id)
|
|
start_object = tool.Ifc.get_object(start_element)
|
|
end_object = tool.Ifc.get_object(end_element)
|
|
|
|
elif len(context.selected_objects) == 2:
|
|
start_object = context.active_object
|
|
end_object = next(o for o in context.selected_objects if o != context.active_object)
|
|
start_element = tool.Ifc.get_entity(start_object)
|
|
end_element = tool.Ifc.get_entity(end_object)
|
|
if not start_element or not end_element:
|
|
self.report({"ERROR"}, "Two IFC elements should be selected for the bend.")
|
|
return {"CANCELLED"}
|
|
|
|
else:
|
|
self.report({"ERROR"}, "Two IFC elements should be provided for the bend.")
|
|
return {"CANCELLED"}
|
|
|
|
# check rotation difference
|
|
def rotation_difference_check():
|
|
end_object_rotation = end_object.matrix_world.to_quaternion()
|
|
rotation_difference = (
|
|
start_object.matrix_world.to_quaternion().rotation_difference(end_object_rotation).to_euler()
|
|
)
|
|
|
|
def is_multiple_of_pi(value):
|
|
n = round(value / pi)
|
|
return tool.Cad.is_x(abs(value - n * pi), 0)
|
|
|
|
if not is_multiple_of_pi(rotation_difference.z):
|
|
error_msg = (
|
|
"There is some rotation difference between profiles by local Z axis: "
|
|
f"{round(degrees(rotation_difference.z))} deg, adding a bend is not possible."
|
|
)
|
|
return error_msg
|
|
|
|
if error_msg := rotation_difference_check():
|
|
self.report({"ERROR"}, error_msg)
|
|
return {"CANCELLED"}
|
|
|
|
# check segments types
|
|
def types_check():
|
|
start_type = ifcopenshell.util.element.get_type(start_element)
|
|
end_type = ifcopenshell.util.element.get_type(end_element)
|
|
if not start_type or not end_type:
|
|
return False
|
|
return start_type == end_type
|
|
|
|
if not types_check():
|
|
self.report(
|
|
{"ERROR"},
|
|
"Segments types do not match or one of the segments doesn't have type which is required for a bend.",
|
|
)
|
|
return {"CANCELLED"}
|
|
|
|
profile = tool.Model.get_flow_segment_profile(start_element)
|
|
if not profile.is_a("IfcRectangleProfileDef") and not profile.is_a("IfcCircleProfileDef"):
|
|
self.report(
|
|
{"ERROR"},
|
|
"For now Only IfcRectangleProfileDef/IfcCircleProfileDef profiles supported for a bend, "
|
|
f"the segments are {profile.is_a()}",
|
|
)
|
|
return {"CANCELLED"}
|
|
|
|
def get_dim(profile):
|
|
if profile.is_a("IfcRectangleProfileDef"):
|
|
return V(profile.XDim / 2, profile.YDim / 2)
|
|
elif profile.is_a("IfcCircleProfileDef"):
|
|
return V(profile.Radius, profile.Radius)
|
|
return None
|
|
|
|
# setup start / end points
|
|
start_object_rotation = start_object.matrix_world.to_quaternion().to_matrix()
|
|
start_segment_data = MEPGenerator().get_segment_data(start_element)
|
|
end_segment_data = MEPGenerator().get_segment_data(end_element)
|
|
# use id() to match by the exact vector objects and not by their values
|
|
# since vectors position could match
|
|
points_ports_map = {
|
|
id(start_segment_data["start_point"]): start_segment_data["start_port"],
|
|
id(start_segment_data["end_point"]): start_segment_data["end_port"],
|
|
id(end_segment_data["start_point"]): end_segment_data["start_port"],
|
|
id(end_segment_data["end_point"]): end_segment_data["end_port"],
|
|
}
|
|
|
|
get_z_basis = lambda o: tool.Cad.get_basis_vector(o, 2)
|
|
segments_intersection_ws = tool.Cad.intersect_edges(
|
|
(start_object.location, start_object.location + get_z_basis(start_object)),
|
|
(end_object.location, end_object.location + get_z_basis(end_object)),
|
|
)[0]
|
|
|
|
start_point, first_segment_start = tool.Cad.closest_and_furthest_vectors(
|
|
segments_intersection_ws, (start_segment_data["start_point"], start_segment_data["end_point"])
|
|
)
|
|
end_point, second_segment_end = tool.Cad.closest_and_furthest_vectors(
|
|
segments_intersection_ws, (end_segment_data["start_point"], end_segment_data["end_point"])
|
|
)
|
|
|
|
# start_/end_segment_sign indicate
|
|
# whether segments' z axes are directed towards the bend
|
|
start_port = points_ports_map[id(start_point)]
|
|
end_port = points_ports_map[id(end_point)]
|
|
start_point_on_origin = start_point == start_segment_data["start_point"]
|
|
start_connection = "ATSTART" if start_point_on_origin else "ATEND"
|
|
start_segment_sign = -1 if start_point_on_origin else 1
|
|
|
|
end_point_on_origin = end_point == end_segment_data["start_point"]
|
|
end_connection = "ATSTART" if end_point_on_origin else "ATEND"
|
|
end_segment_sign = -1 if end_point_on_origin else 1
|
|
|
|
profile_dim = get_dim(profile) * si_conversion
|
|
|
|
# TODO: profile offset may need to be flipped (check transition code)
|
|
to_start_object_space = start_object_rotation.inverted()
|
|
ref_point = end_point.copy()
|
|
end_segment_dir = (second_segment_end - end_point).normalized()
|
|
# we prioritize direction between end_point and start_point for bend_vector
|
|
# if those point match we use general end segment direction
|
|
if tool.Cad.is_x((end_point - start_point).length, 0):
|
|
ref_point = end_point + end_segment_dir
|
|
bend_vector = (to_start_object_space @ ref_point) - (to_start_object_space @ start_point)
|
|
|
|
z_axis_end_object_local = to_start_object_space @ tool.Cad.get_basis_vector(end_object, 2)
|
|
|
|
def check_for_double_bends():
|
|
# The theory is To avoid double bends, the profile offset should occur along only two axes:
|
|
# 1) The local Z-axis of the start segment
|
|
# 2) One of the lateral axes (either X or Y)
|
|
#
|
|
# Double bend required when:
|
|
# - there are 2 or 0 lateral axes involved
|
|
# - offset appear by the non-lateral axis
|
|
#
|
|
# NOTE: some double bends are only possible for square profiles:
|
|
# https://i.imgur.com/ZhdGbEp.png
|
|
|
|
lateral_axes = [i for i in range(2) if not tool.Cad.is_x(z_axis_end_object_local[i], 0)]
|
|
|
|
if len(lateral_axes) != 1:
|
|
return (
|
|
None,
|
|
f"For now only one lateral axis is supported for a bend (double bends not supported). Found lateral axes: {len(lateral_axes)}.",
|
|
)
|
|
|
|
non_lateral_axis = 0 if lateral_axes[0] == 1 else 1
|
|
non_lateral_axis_offset = bend_vector[non_lateral_axis]
|
|
if not tool.Cad.is_x(non_lateral_axis_offset, 0):
|
|
return (
|
|
None,
|
|
"For now offset by non-lateral axis is not supported for a bend (double bends not supported).\n"
|
|
f"Detected an offset of {round(non_lateral_axis_offset, 5)} along the local axis {'XY'[non_lateral_axis]} when lateral axis is {'XY'[lateral_axes[0]]}.",
|
|
)
|
|
|
|
return lateral_axes[0], None
|
|
|
|
lateral_axis, error_msg = check_for_double_bends()
|
|
if error_msg:
|
|
self.report({"ERROR"}, error_msg)
|
|
return {"CANCELLED"}
|
|
non_lateral_axis = 0 if lateral_axis == 1 else 1
|
|
|
|
def get_bend_rotation():
|
|
O = V(0, 0, 0)
|
|
edge1 = (get_z_basis(start_object) * start_segment_sign, O)
|
|
edge2 = (get_z_basis(end_object) * end_segment_sign, O)
|
|
angle = pi - tool.Cad.angle_edges(edge1, edge2)
|
|
axis = (edge2[1] - edge2[0]).cross(edge1[1] - edge1[0])
|
|
return angle, axis
|
|
|
|
angle, rotation_axis = get_bend_rotation()
|
|
|
|
lateral_sign = tool.Cad.sign(bend_vector[lateral_axis])
|
|
radial_offset = V(0, 0, 0)
|
|
ref_point_radius = self.radius + profile_dim[lateral_axis]
|
|
radial_offset[lateral_axis] = ref_point_radius * (1 - cos(angle)) * lateral_sign
|
|
radial_offset.z = ref_point_radius * sin(angle) * start_segment_sign
|
|
end_port_offset = radial_offset + V(0, 0, self.start_length * start_segment_sign)
|
|
end_port_offset += z_axis_end_object_local * (self.end_length * -end_segment_sign)
|
|
|
|
def get_segments_extend_points():
|
|
# since tangent segments are equal
|
|
# if drawn for the circle from the same point
|
|
required_offset = ref_point_radius * tan(angle / 2)
|
|
|
|
start_segment_extend_point = segments_intersection_ws - start_segment_sign * (
|
|
self.start_length + required_offset
|
|
) * get_z_basis(start_object)
|
|
end_segment_extend_point = segments_intersection_ws - end_segment_sign * (
|
|
self.end_length + required_offset
|
|
) * get_z_basis(end_object)
|
|
|
|
return start_segment_extend_point, end_segment_extend_point
|
|
|
|
def check_new_segment_length(start_point, end_point, extend_point):
|
|
"""Check if segment is placed too near to the bend point.
|
|
|
|
The idea is that we can either extend segment toward the bend
|
|
but we can shrink it only until it's start.
|
|
|
|
If the segment is too near it will return offset to fix the problem,
|
|
otherwise returns `None`.
|
|
|
|
"""
|
|
base_edge = end_point - start_point
|
|
new_edge = extend_point - start_point
|
|
projection = new_edge.dot(base_edge.normalized())
|
|
if projection < 0 or tool.Cad.is_x(projection, 0):
|
|
return projection
|
|
return None
|
|
|
|
# adjust segments to fit the radius and angle
|
|
start_segment_extend_point, end_segment_extend_point = get_segments_extend_points()
|
|
projection = check_new_segment_length(first_segment_start, start_point, start_segment_extend_point)
|
|
if projection is not None:
|
|
self.report(
|
|
{"ERROR"},
|
|
f"Start segment starts too near to the bend, need to offset it atleast by {round(projection, 3)} m.",
|
|
)
|
|
return {"ERROR"}
|
|
|
|
projection = check_new_segment_length(second_segment_end, end_point, end_segment_extend_point)
|
|
if projection is not None:
|
|
self.report(
|
|
{"ERROR"},
|
|
f"End segment starts too near to the bend, need to offset it atleast by {round(projection, 3)} m.",
|
|
)
|
|
return {"ERROR"}
|
|
|
|
DumbProfileJoiner().join_E(start_object, start_segment_extend_point, start_connection)
|
|
DumbProfileJoiner().join_E(end_object, end_segment_extend_point, end_connection)
|
|
|
|
context.view_layer.update() # update matrices
|
|
|
|
builder = ShapeBuilder(ifc_file)
|
|
rep, bend_data = builder.mep_bend_shape(
|
|
start_element,
|
|
self.start_length / si_conversion,
|
|
self.end_length / si_conversion,
|
|
angle,
|
|
self.radius / si_conversion,
|
|
bend_vector / si_conversion,
|
|
flip_z_axis=start_segment_sign == -1,
|
|
)
|
|
|
|
parametric_data = {
|
|
"start_length": self.start_length / si_conversion,
|
|
"end_length": self.end_length / si_conversion,
|
|
"radius": self.radius / si_conversion,
|
|
"angle": degrees(angle),
|
|
"main_profile_dimension": profile_dim[lateral_axis] / si_conversion,
|
|
}
|
|
# find the compatible fitting type
|
|
fitting_data = MEPGenerator().get_compatible_fitting_type(
|
|
[start_element, end_element], [start_port, end_port], "BEND", bbim_data=parametric_data
|
|
)
|
|
bend_type = fitting_data["fitting_type"] if fitting_data else None
|
|
start_port_match = fitting_data["start_port_match"] if fitting_data else True
|
|
|
|
# use current segments axes if no fitting type found
|
|
lateral_axis_type = lateral_axis
|
|
lateral_sign_type = lateral_sign
|
|
z_sign_type = start_segment_sign
|
|
non_lateral_axis_type = non_lateral_axis
|
|
if bend_type:
|
|
bend_obj = tool.Ifc.get_object(bend_type)
|
|
bbim_data = tool.Model.get_modeling_bbim_pset_data(bend_obj, "BBIM_Fitting")["data_dict"]
|
|
lateral_axis_type, lateral_sign_type = bbim_data["lateral_axis"], bbim_data["lateral_sign"]
|
|
non_lateral_axis_type = 0 if lateral_axis_type == 1 else 1
|
|
z_sign_type = bbim_data.get("z_axis_sign", None)
|
|
# TODO: drop flip_z_axis a bit later
|
|
if z_sign_type is None:
|
|
z_sign_type = -1 if bbim_data["flip_z_axis"] else 1
|
|
|
|
# TODO: handle the case without creating a representation in the first place?
|
|
ifcopenshell.api.geometry.remove_representation(ifc_file, representation=rep)
|
|
else: # create new fitting type if nothing is compatible
|
|
mesh = bpy.data.meshes.new("Bend")
|
|
obj = bpy.data.objects.new("Bend", mesh)
|
|
bend_type = bonsai.core.root.assign_class(
|
|
tool.Ifc,
|
|
tool.Collector,
|
|
tool.Root,
|
|
obj=obj,
|
|
ifc_class=MEPGenerator().get_mep_element_class_name(start_element, "FittingType"),
|
|
predefined_type="BEND",
|
|
should_add_representation=False,
|
|
)
|
|
body = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW")
|
|
# Will implicitly remove `mesh`.
|
|
tool.Model.replace_object_ifc_representation(body, obj, rep)
|
|
pset = ifcopenshell.api.pset.add_pset(tool.Ifc.get(), product=bend_type, name="BBIM_Fitting")
|
|
ifcopenshell.api.pset.edit_pset(
|
|
tool.Ifc.get(),
|
|
pset=pset,
|
|
properties={"Data": tool.Ifc.get().createIfcText(json.dumps(bend_data, default=list))},
|
|
)
|
|
tool.System.add_ports(obj, end_port_pos=end_port_offset)
|
|
|
|
# NOTE: at this point we loose current blender objects selection
|
|
# create transition element
|
|
bpy.ops.bim.add_occurrence(relating_type_id=bend_type.id())
|
|
fitting_obj = bpy.context.active_object
|
|
|
|
# adjust fitting object rotation and location
|
|
# required since we'll base our `fitting_obj_dir` on this
|
|
fitting_obj.matrix_world = start_object.matrix_world
|
|
context.view_layer.update()
|
|
|
|
# depending on bend direction we may need to rotate it to match
|
|
# we just calculate the matrix basises - it's simpler than describing all possible conditions
|
|
def get_fitting_matrix():
|
|
matrix = Matrix.Identity(3)
|
|
start_object_z_basis = tool.Cad.get_basis_vector(start_object, 2)
|
|
start_object_lateral_basis = tool.Cad.get_basis_vector(start_object, lateral_axis)
|
|
|
|
def axis_direction(current_axis_sign, type_axis_sign):
|
|
return -1 if current_axis_sign != type_axis_sign else 1
|
|
|
|
matrix.col[2] = start_object_z_basis * axis_direction(start_segment_sign, z_sign_type)
|
|
matrix.col[lateral_axis_type] = start_object_lateral_basis * axis_direction(lateral_sign, lateral_sign_type)
|
|
if not start_port_match:
|
|
matrix.col[2] *= -1
|
|
|
|
if non_lateral_axis_type == 0:
|
|
non_lateral_axis = matrix.col[lateral_axis_type].cross(matrix.col[2])
|
|
else:
|
|
non_lateral_axis = matrix.col[2].cross(matrix.col[lateral_axis_type])
|
|
matrix.col[non_lateral_axis_type] = non_lateral_axis
|
|
|
|
if not start_port_match:
|
|
angle_sign = np.sign(rotation_axis.dot(non_lateral_axis))
|
|
matrix = matrix @ Matrix.Rotation(angle * angle_sign, 3, "XY"[non_lateral_axis_type])
|
|
|
|
matrix = matrix.to_4x4()
|
|
matrix.translation = start_segment_extend_point if start_port_match else end_segment_extend_point
|
|
return matrix
|
|
|
|
fitting_obj.matrix_world = get_fitting_matrix()
|
|
tool.Model.sync_object_ifc_position(fitting_obj)
|
|
|
|
# add ports and connect them
|
|
ports = tool.System.get_ports(tool.Ifc.get_entity(fitting_obj))
|
|
start_co = ifcopenshell.util.placement.get_local_placement(start_port.ObjectPlacement)[:, 3]
|
|
port0_co = ifcopenshell.util.placement.get_local_placement(ports[0].ObjectPlacement)[:, 3]
|
|
# We cannot use start_port_match because tool.System.get_ports is unordered
|
|
if not np.allclose(start_co, port0_co):
|
|
start_port, end_port = end_port, start_port
|
|
ifcopenshell.api.system.connect_port(ifc_file, port1=ports[0], port2=start_port, direction="NOTDEFINED")
|
|
ifcopenshell.api.system.connect_port(ifc_file, port1=ports[1], port2=end_port, direction="NOTDEFINED")
|
|
|
|
self.report({"INFO"}, f"Success!.. kind of. The angle was {round(bend_data['angle'])}")
|
|
return {"FINISHED"}
|