From aace987149af181c1cdd1949a6687dda6e525dbf Mon Sep 17 00:00:00 2001 From: Dirk Olbrich Date: Sun, 3 Sep 2023 13:44:49 +0200 Subject: [PATCH 001/190] ifcgeom: fix compiler warning -Wreorder-ctor --- src/ifcgeom_schema_agnostic/IfcGeomRepresentation.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ifcgeom_schema_agnostic/IfcGeomRepresentation.h b/src/ifcgeom_schema_agnostic/IfcGeomRepresentation.h index 0d7df7d7d1..9e5fe20d3f 100644 --- a/src/ifcgeom_schema_agnostic/IfcGeomRepresentation.h +++ b/src/ifcgeom_schema_agnostic/IfcGeomRepresentation.h @@ -159,8 +159,8 @@ namespace IfcGeom { , _normals(normals) , uvs_(uvs) , _material_ids(material_ids) - , styles_(styles) , _item_ids(item_ids) + , styles_(styles) { for (auto& s : styles_) { _materials.push_back(IfcGeom::Material(s)); From 6816ad5c0f6fede9ca2f4b724d8ca97c90e07ea0 Mon Sep 17 00:00:00 2001 From: Dirk Olbrich Date: Sun, 3 Sep 2023 13:59:36 +0200 Subject: [PATCH 002/190] ifcparse: fix compiler warning -Wignored-qualifiers --- src/ifcparse/IfcFile.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ifcparse/IfcFile.h b/src/ifcparse/IfcFile.h index eaf5d8848c..88bed3c065 100644 --- a/src/ifcparse/IfcFile.h +++ b/src/ifcparse/IfcFile.h @@ -272,7 +272,7 @@ public: unsigned int getMaxId() const { return MaxId; } - const IfcParse::declaration* const ifcroot_type() const { return ifcroot_type_; } + const IfcParse::declaration* ifcroot_type() const { return ifcroot_type_; } void recalculate_id_counter(); From fe4823681fb08a43e48f913f11edbb9195453025 Mon Sep 17 00:00:00 2001 From: Dirk Olbrich Date: Sun, 3 Sep 2023 14:01:33 +0200 Subject: [PATCH 003/190] ifcgeom: fix compiler warning -Wunused-but-set-variable --- src/ifcgeom/IfcGeom.cpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/ifcgeom/IfcGeom.cpp b/src/ifcgeom/IfcGeom.cpp index d0b5ebd19f..e743461445 100644 --- a/src/ifcgeom/IfcGeom.cpp +++ b/src/ifcgeom/IfcGeom.cpp @@ -1084,11 +1084,9 @@ bool IfcGeom::Kernel::convert_layerset(const IfcSchema::IfcProduct* product, std TopExp_Explorer exp(axis_shape, TopAbs_EDGE); TopoDS_Edge axis_edge; - int edge_count = 0; if (exp.More()) { axis_edge = TopoDS::Edge(exp.Current()); - ++ edge_count; } else { Logger::Message(Logger::LOG_WARNING, "No edge found in axis representation:", product); return false; From 07579ee9a1be8633d0ff05c1d47d1511612ac848 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 4 Sep 2023 15:59:03 +1000 Subject: [PATCH 004/190] See #3700. Fix issue in drawing include filter where "addition" selections like GlobalIds and classes didn't have any impact. --- src/blenderbim/blenderbim/tool/drawing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blenderbim/blenderbim/tool/drawing.py b/src/blenderbim/blenderbim/tool/drawing.py index 8c2751c4f6..bea490ba72 100644 --- a/src/blenderbim/blenderbim/tool/drawing.py +++ b/src/blenderbim/blenderbim/tool/drawing.py @@ -1509,7 +1509,7 @@ class Drawing(blenderbim.core.tool.Drawing): elements = cls.get_elements_in_camera_view(tool.Ifc.get_object(drawing), bpy.data.objects) include = pset.get("Include", None) if include: - elements = ifcopenshell.util.selector.filter_elements(ifc_file, include, elements=elements) + elements = ifcopenshell.util.selector.filter_elements(ifc_file, include) else: if tool.Ifc.get_schema() == "IFC2X3": base_elements = set(ifc_file.by_type("IfcElement") + ifc_file.by_type("IfcSpatialStructureElement")) From 3aa72ae74a58e25ea9012bf230df324d4c0ade4f Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Tue, 29 Aug 2023 14:30:24 +0500 Subject: [PATCH 005/190] notify about unsupported transition case --- src/blenderbim/blenderbim/bim/module/model/mep.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/blenderbim/blenderbim/bim/module/model/mep.py b/src/blenderbim/blenderbim/bim/module/model/mep.py index e6aaa734da..94bd3dffcc 100644 --- a/src/blenderbim/blenderbim/bim/module/model/mep.py +++ b/src/blenderbim/blenderbim/bim/module/model/mep.py @@ -639,11 +639,20 @@ class MEPAddTransition(bpy.types.Operator, tool.Ifc.Operator): 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 - # TODO: support cases when segments are partially or completely overlapping each other 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 + rotation_difference_z = start_object.matrix_world.to_quaternion().rotation_difference(end_object.matrix_world.to_quaternion()).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"}, f"There is some rotation difference between profiles by local Z axis: {round(degrees(rotation_difference_z))} deg, this kind of transition is not yet supported.") + return {"CANCELLED"} + start_segment_data = MEPGenerator().get_segment_data(start_element) end_segment_data = MEPGenerator().get_segment_data(end_element) end_port = end_segment_data["start_port"] From cfac835607fbf0747df3b2fc5d0052c9a79c5e2b Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 28 Aug 2023 15:25:10 +0500 Subject: [PATCH 006/190] bim.mep_add_bend Added operator to create bend fittings between objects, it's still work in progress and now it only supports rectangular profiles. Small demo - https://imgur.com/a/vM7hnJa --- .../blenderbim/bim/module/model/__init__.py | 1 + .../blenderbim/bim/module/model/mep.py | 362 ++++++++++++++++-- src/blenderbim/blenderbim/tool/cad.py | 53 ++- src/blenderbim/blenderbim/tool/system.py | 3 +- src/blenderbim/test/tool/test_cad.py | 31 ++ .../ifcopenshell/util/shape_builder.py | 208 ++++++++-- 6 files changed, 578 insertions(+), 80 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/model/__init__.py b/src/blenderbim/blenderbim/bim/module/model/__init__.py index eed2604fe1..bdb5f5fe01 100644 --- a/src/blenderbim/blenderbim/bim/module/model/__init__.py +++ b/src/blenderbim/blenderbim/bim/module/model/__init__.py @@ -179,6 +179,7 @@ classes = ( roof.SetGableRoofEdgeAngle, mep.MEPAddObstruction, mep.MEPAddTransition, + mep.MEPAddBend, ) addon_keymaps = [] diff --git a/src/blenderbim/blenderbim/bim/module/model/mep.py b/src/blenderbim/blenderbim/bim/module/model/mep.py index 94bd3dffcc..eb7fae3ee2 100644 --- a/src/blenderbim/blenderbim/bim/module/model/mep.py +++ b/src/blenderbim/blenderbim/bim/module/model/mep.py @@ -35,7 +35,7 @@ import blenderbim.core.type import blenderbim.core.root import blenderbim.core.geometry import blenderbim.tool as tool -from math import pi, degrees, radians +from math import pi, degrees, radians, sin, cos, asin from copy import copy from mathutils import Vector, Matrix from ifcopenshell.util.shape_builder import ShapeBuilder @@ -201,6 +201,7 @@ class FitFlowSegments(bpy.types.Operator, tool.Ifc.Operator): 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: @@ -644,32 +645,40 @@ class MEPAddTransition(bpy.types.Operator, tool.Ifc.Operator): return {"CANCELLED"} # TODO: support different profiles rotation by local Z - rotation_difference_z = start_object.matrix_world.to_quaternion().rotation_difference(end_object.matrix_world.to_quaternion()).to_euler().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"}, f"There is some rotation difference between profiles by local Z axis: {round(degrees(rotation_difference_z))} deg, this kind of transition is not yet supported.") + 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) - end_port = end_segment_data["start_port"] - start_port = start_segment_data["end_port"] - 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 = tool.Cad.closest_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] # figure profile offset base_transition_dir = keep_only_z_axis(end_point - start_point).normalized() @@ -694,18 +703,6 @@ class MEPAddTransition(bpy.types.Operator, tool.Ifc.Operator): start_object_rotation @ (profile_offset * si_conversion).to_3d() if profile_offset else V(0, 0, 0) ) - # will need entire_length to check that transition length fill fit - first_segment_start, second_segment_end = [ - p - for p in ( - start_segment_data["start_point"], - start_segment_data["end_point"], - end_segment_data["start_point"], - end_segment_data["end_point"], - ) - if p not in (start_point, end_point) - ] - def get_segments_length(): start_dir = (start_point - first_segment_start).normalized() segments_vector = second_segment_end - first_segment_start @@ -716,8 +713,6 @@ class MEPAddTransition(bpy.types.Operator, tool.Ifc.Operator): # 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() - start_port = points_ports_map[start_point] - end_port = points_ports_map[end_point] # add transition representation builder = ShapeBuilder(ifc_file) @@ -752,6 +747,8 @@ class MEPAddTransition(bpy.types.Operator, tool.Ifc.Operator): # adjust the segments end_object_rotation = end_object.matrix_world.to_quaternion() end_object_z_basis = end_object_rotation.to_matrix().col[2] # z basis vector + + # TODO: do it beforehand, as with bends if tool.Cad.is_x(start_object_z_basis.dot(transition_dir), 1): start_connection = "ATEND" else: @@ -768,13 +765,11 @@ class MEPAddTransition(bpy.types.Operator, tool.Ifc.Operator): [start_element, end_element], [start_port, end_port], "TRANSITION" ) 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.run("geometry.remove_representation", ifc_file, representation=rep) - start_port_match = fitting_data["start_port_match"] if fitting_data else True - - # create new fitting type if nothing is compatible - if not transition_type: + else: # create new fitting type if nothing is compatible mesh = bpy.data.meshes.new("Transition") obj = bpy.data.objects.new("Transition", mesh) transition_type = blenderbim.core.root.assign_class( @@ -825,3 +820,316 @@ class MEPAddTransition(bpy.types.Operator, tool.Ifc.Operator): tool.Ifc.run("system.connect_port", 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 MEP elements. Elements are either provided by ID or selected in Blender" + bl_options = {"REGISTER", "UNDO"} + start_length: bpy.props.FloatProperty( + name="Start Length", description="Bend start length in SI units", default=0.1, subtype="DISTANCE" + ) + end_length: bpy.props.FloatProperty( + name="End Length", description="Bend end length in SI units", default=0.1, subtype="DISTANCE" + ) + 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( + "Bend Inner Radius", description="Bend inner radius in SI units", default=0.2, subtype="DISTANCE" + ) + + 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) + + self.start_length, self.end_length = 0, 0 + + if not (self.start_length == 0 and self.end_length == 0): + self.report({"ERROR"}, f"Only zero lengths are now supported.") + return {"CANCELLED"} + + 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 bend.") + return {"CANCELLED"} + + else: + self.report({"ERROR"}, f"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"} + + # TODO: support circular profiles + profile = tool.Model.get_flow_segment_profile(start_element) + if not profile.is_a("IfcRectangleProfileDef"): + self.report( + { + "ERROR", + "For now Only IfcRectangleProfileDef 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) + 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"], + } + (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" + 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() + profile_offset = (to_start_object_space @ end_point) - (to_start_object_space @ start_point) + + 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 + + z_axis_end_object = end_object.matrix_world.col[2].normalized().to_3d() + z_axis_end_object_local = to_start_object_space @ z_axis_end_object + 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 = profile_offset[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"} + + O = V(0, 0, 0) + get_z_basis = lambda o: o.matrix_world.col[2].normalized().to_3d() + angle = tool.Cad.angle_edges((get_z_basis(start_object), O), (get_z_basis(end_object), O)) + + lateral_sign = tool.Cad.sign(profile_offset[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) + + def get_segments_extend(): + end_segment_z_local = to_start_object_space @ get_z_basis(end_object) + segments_intersection = tool.Cad.intersect_edges( + (V(0, 0, 1), V(0, 0, 0)), (profile_offset + end_segment_z_local, profile_offset) + )[0] + + curent_start_offset = segments_intersection.length + required_start_offset = abs(radial_offset.z) + current_end_offset = (segments_intersection - profile_offset).length + required_end_offset = abs(radial_offset[lateral_axis]) + + start_extend = curent_start_offset - required_start_offset + end_extend = current_end_offset - required_end_offset + + return start_extend, end_extend + + 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, end_segment_extend = get_segments_extend() + + start_segment_extend_point = start_point + start_segment_sign * start_segment_extend * get_z_basis(start_object) + 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"} + + end_segment_extend_point = end_point + end_segment_sign * end_segment_extend * get_z_basis(end_object) + 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, + profile_offset / si_conversion, + ) + + bpy.ops.bim.create_shape_from_step_id(step_id=rep.id(), should_include_curves=True) + + # find the compatible fitting type + fitting_data = MEPGenerator().get_compatible_fitting_type( + [start_element, end_element], [start_port, end_port], "BEND" + ) + bend_type = fitting_data["fitting_type"] if fitting_data else None + start_port_match = fitting_data["start_port_match"] if fitting_data else True + if bend_type: + # TODO: handle the case without creating a representation in the first place? + ifcopenshell.api.run("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 = blenderbim.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") + tool.Model.replace_object_ifc_representation(body, obj, rep) + pset = ifcopenshell.api.run("pset.add_pset", tool.Ifc.get(), product=bend_type, name="BBIM_Fitting") + ifcopenshell.api.run( + "pset.edit_pset", + tool.Ifc.get(), + pset=pset, + properties={"Data": json.dumps(bend_data, default=list)}, + ) + + # NOTE: at this point we loose current blender objects selection + # create transition element + bpy.ops.bim.add_constr_type_instance(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 fitting 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 fitting type before + direction_match = tool.Cad.are_vectors_equal(get_z_basis(start_object), get_z_basis(fitting_obj)) + # if there are no mismatches or everything matches up we don't need to flip the transition + if start_port_match != direction_match: + fitting_obj.matrix_world = start_object.matrix_world @ Matrix.Rotation(radians(180), 4, "X") + fitting_obj.location = start_segment_extend_point if start_port_match else end_segment_extend_point + + # add ports and connect them + ports = tool.System.add_ports(fitting_obj, offset_end_port=start_object_rotation @ (radial_offset * V(1, 1, 0))) + if not start_port_match: + start_port, end_port = end_port, start_port + tool.Ifc.run("system.connect_port", port1=ports[0], port2=start_port, direction="NOTDEFINED") + tool.Ifc.run("system.connect_port", port1=ports[1], port2=end_port, direction="NOTDEFINED") + + self.report({"INFO"}, f"Success!.. kind of. The angle was {round(bend_data['angle'])}") + return {"FINISHED"} diff --git a/src/blenderbim/blenderbim/tool/cad.py b/src/blenderbim/blenderbim/tool/cad.py index 3443e6cca7..5a2c9190d9 100644 --- a/src/blenderbim/blenderbim/tool/cad.py +++ b/src/blenderbim/blenderbim/tool/cad.py @@ -35,6 +35,7 @@ import math import bmesh import mathutils.geometry from mathutils import Vector, Matrix, geometry +import itertools VTX_PRECISION = 1.0e-5 @@ -74,6 +75,8 @@ class Cad: """ > takes 2 edges, each as a tuple of two vectors < returns the potentially signed angle as degrees or radians + + NOTE: `signed` expects both edges to be 2D (just as `Vector.angle_signed`) """ if signed: a = (edge1[1] - edge1[0]).angle_signed(edge2[1] - edge2[0]) @@ -248,23 +251,33 @@ class Cad: return cls.are_edges_parallel((edge2[0], edge1[0]), edge2) @classmethod - def closest_points(cls, edge1, edge2) -> bool: + def closest_points(cls, edge1, edge2): + """ + closest end points between `edge1` and `edge2` + + ensures returned vectors are the exact objects + that were passed to the method with `edge1` and `edge2` + + < returns two tuples - two closest points and two other points + + first point of each tuple belongs to `edge1` and second to `edge2` """ - closest end points between `edge1` and `edge2` assuming `edge1` and `edge2` are collinear. + distance_squared = None + closest_points = None + for p1 in edge1: + for p2 in edge2: + cur_line = p2 - p1 + cur_distance_squared = cur_line.dot(cur_line) + if distance_squared is None or cur_distance_squared < distance_squared: + closest_points = (p1, p2) + distance_squared = cur_distance_squared - < returns two points, first one belongs to `edge1` and second to `edge2` - - """ - direction = (edge1[1] - edge1[0]).normalized() - - # Project points onto the line to get scalar values along the direction - points_values = [(p, p.dot(direction)) for p in (edge1 + edge2)] - sorted_points = sorted(points_values, key=lambda el: el[1]) - - edge1_point = next((p for p, v in sorted_points[1:3] if p in edge1), None) - edge2_point = next((p for p, v in sorted_points[1:3] if p in edge2), None) - return edge1_point, edge2_point + other_points = ( + edge1[0] if closest_points[0] == edge1[1] else edge1[1], + edge2[0] if closest_points[1] == edge2[1] else edge2[1], + ) + return closest_points, other_points @classmethod def find_intersecting_edges(cls, bm, pt, idx1, idx2): @@ -489,3 +502,15 @@ class Cad: def is_counter_clockwise_order(cls, A, B, C): """whether A-B-C located in counter-clockwise order in 2d space""" return (C.y - A.y) * (B.x - A.x) > (B.y - A.y) * (C.x - A.x) + + @classmethod + def sign(cls, value): + """ + returns: + 0 if cls.is_x(value, 0)) \n + 1 if value > 0 \n + -1 if value < 0 + """ + if cls.is_x(value, 0): + return 0 + return 1 if value > 0 else -1 diff --git a/src/blenderbim/blenderbim/tool/system.py b/src/blenderbim/blenderbim/tool/system.py index b274ff2929..13e25cf6af 100644 --- a/src/blenderbim/blenderbim/tool/system.py +++ b/src/blenderbim/blenderbim/tool/system.py @@ -42,7 +42,8 @@ class System(blenderbim.core.tool.System): blenderbim.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj) mep_element = tool.Ifc.get_entity(obj) - length = obj.dimensions.z + bbox = tool.Blender.get_object_bounding_box(obj) + length = bbox["min_z"] if tool.Cad.is_x(bbox["max_z"], 0) else bbox["max_z"] ports = [] if add_start_port: ports.append(add_port(mep_element, obj.matrix_world @ Matrix())) diff --git a/src/blenderbim/test/tool/test_cad.py b/src/blenderbim/test/tool/test_cad.py index 1aaa098fde..f359c806a9 100644 --- a/src/blenderbim/test/tool/test_cad.py +++ b/src/blenderbim/test/tool/test_cad.py @@ -56,3 +56,34 @@ class TestAreEdgesCollinear(NewFile): (V(0,1,0), V(1,0,1)) ) # fmt: on + + +class TestClosestPoints(NewFile): + def test_run(self): + # non collinear + edge1 = (V(0, 0, 0), V(1, 0, 0)) + edge2 = (V(2, 0, 1), V(2, 0, 2)) + assert subject.closest_points(edge1, edge2)[0] == (edge1[1], edge2[0]) + + # check other points + assert subject.closest_points(edge1, edge2)[1] == (edge1[0], edge2[1]) + + # collinear + edge1 = (V(0, 0, 0), V(1, 0, 0)) + edge2 = (V(3, 0, 0), V(2, 0, 0)) + assert subject.closest_points(edge1, edge2)[0] == (edge1[1], edge2[1]) + + # parallel + edge1 = (V(0, 0, 0), V(1, 0, 0)) + edge2 = (V(-5, 0, 0), V(-1, 0, 0)) + assert subject.closest_points(edge1, edge2)[0] == (edge1[0], edge2[1]) + + # overlapping + edge1 = (V(0, 0, 0), V(3, 0, 0)) + edge2 = (V(2, 0, 0), V(5, 0, 0)) + assert subject.closest_points(edge1, edge2)[0] == (edge1[1], edge2[0]) + + # edge as a point + edge1 = (V(0, 0, 0), V(0, 0, 0)) + edge2 = (V(1, 0, 1), V(2, 0, 2)) + assert subject.closest_points(edge1, edge2)[0] == (edge1[0], edge2[0]) diff --git a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py index e34aa289c7..0de74a7667 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py +++ b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py @@ -1,5 +1,5 @@ # IfcOpenShell - IFC toolkit and geometry engine -# Copyright (C) 2022 @Andrej730 +# Copyright (C) 2022, 2023 @Andrej730 # # This file is part of IfcOpenShell. # @@ -19,14 +19,21 @@ import collections import ifcopenshell import ifcopenshell.api -from math import cos, sin, pi, tan, radians, degrees, atan, sqrt +from math import cos, sin, pi, tan, radians, degrees, atan, sqrt, ceil from mathutils import Vector, Matrix from itertools import chain V = lambda *x: Vector([float(i) for i in x]) sign = lambda x: x and (1, -1)[x < 0] PRECISION = 1.0e-5 -is_x = lambda value, x: (x + PRECISION) > value > (x - PRECISION) + + +def is_x(value, x, si_conversion=None): + if si_conversion: + value = value * si_conversion + return (x + PRECISION) > value > (x - PRECISION) + + round_to_precision = lambda x, si_conversion: round(x * si_conversion, 5) / si_conversion round_vector_to_precision = lambda v, si_conversion: Vector([round_to_precision(i, si_conversion) for i in v]) @@ -101,7 +108,7 @@ class ShapeBuilder: if len(segment) == 3: ifc_segments.append(self.file.createIfcArcIndex(segment)) - # NOTE: IfcIndexPolyCurve support only consequtive segments + # NOTE: IfcIndexPolyCurve support only consecutive segments ifc_curve = self.file.createIfcIndexedPolyCurve(Points=ifc_points, Segments=ifc_segments) return ifc_curve @@ -534,6 +541,9 @@ class ShapeBuilder: Position and position axes are in world space, extrusion vector in placement space defined by position_x_axis/position_y_axis/position_z_axis + + NOTE: changing position also changes the resulting geometry origin. + """ # > profile_or_curve # > extrusion vector - as defined in coordinate system position_x_axis+position_z_axis @@ -575,9 +585,9 @@ class ShapeBuilder: disk_solid = self.file.createIfcSweptDiskSolid(Directrix=path_curve, Radius=radius) return disk_solid - def get_representation(self, context, items, representation_type:str = None): + def get_representation(self, context, items, representation_type: str = None): """Create IFC representation for the specified context and items. - + :param context: IfcGeometricRepresentationSubContext :param items: could be a list or single curve/IfcExtrudedAreaSolid :param representation_type: Explicitly specified RepresentationType, defaults to `None`. @@ -615,8 +625,11 @@ class ShapeBuilder: # UTILITIES def extrude_by_y_kwargs(self): - """shortcut for `ShapeBuilder.extrude` to extrude by y axis. - it assumes you have 2d profile in xz plane and trying to extrude it by y axis""" + """Shortcut to get kwargs for `ShapeBuilder.extrude` to extrude by Y axis. + + It assumes you have 2D profile in XZ plane and trying to extrude it by Y axis. + + Extruding by Y using other kwargs might break ValidExtrusionDirection.""" return { "position_x_axis": Vector((1, 0, 0)), "position_z_axis": Vector((0, -1, 0)), @@ -856,6 +869,53 @@ class ShapeBuilder: return face_set + def extrude_face_set( + self, points, magnitude: float, extrusion_vector=V(0, 0, 1).freeze(), offset=None, start_cap=True, end_cap=True + ): + """ + Method to extrude by creating face sets rather than creating IfcExtrudedAreaSolid. + + Useful if your representation is already using face sets and you need to avoid using SweptSolid + to assure CorrectItemsForType. + + :param points: list of points, assuming they form consecutive closed polyline. + :param magnitude: extrusion magnitude + :param type: float + :param extrusion_vector: extrusion direction, by default it's extruding by Z+ axis + :param type: Vector, optional + :param offset: offset from the points + :param type: Vector, optional + :param start_cap: if True, create start cap, by default it's True + :param type: bool, optional + :param end_cap: if True, create end cap, by default it's True + :param type: bool, optional + + :return: IfcPolygonalFaceSet + """ + + # prevent mutating arguments, deepcopy doesn't work + start_points = [p.copy() if not offset else (p + offset) for p in points] + extrusion_offset = magnitude * extrusion_vector + end_points = [p + extrusion_offset for p in start_points] + + points = start_points + end_points + faces = [] + n_verts = len(start_points) + last_vert_i = n_verts - 1 + for i in range(last_vert_i): + face = (i, i + 1, n_verts + i + 1, n_verts + i) + faces.append(face) + faces.append((last_vert_i, 0, n_verts + 0, n_verts + last_vert_i)) # close the loop + + if end_cap: + faces.append(tuple(range(n_verts, n_verts * 2))) + if start_cap: + faces.append(tuple(reversed(range(n_verts)))) + + face_set = self.polygonal_face_set(points, faces) + return face_set + + # TODO: move MEP to separate shape builder sub module def mep_transition_shape( self, start_segment, end_segment, start_length, end_length, angle=30.0, profile_offset=None ): @@ -899,32 +959,6 @@ class ShapeBuilder: return V(profile.Radius, profile.Radius, depth) return None - def get_profile_faceset(points, length, offset=None): - # prevent mutating arguments, deepcopy doesn't work - start_points = [p.copy() if not offset else (p + offset) for p in points] - end_points = [p.copy() for p in start_points] - for p in end_points: - p.z += length - - points = start_points + end_points - faces = [] - n_verts = len(start_points) - last_vert_i = n_verts - 1 - for i in range(last_vert_i): - face = (i, i + 1, n_verts + i + 1, n_verts + i) - faces.append(face) - faces.append((last_vert_i, 0, n_verts + 0, n_verts + last_vert_i)) # close the loop - - # if there is offset we put a cap at the end - # otherwise at the start - if offset: - faces.append(tuple(range(n_verts, n_verts * 2))) - else: - faces.append(tuple(reversed(range(n_verts)))) - - face_set = self.polygonal_face_set(points, faces) - return face_set - start_profile = get_profile(start_segment) end_profile = get_profile(end_segment) @@ -1004,8 +1038,10 @@ class ShapeBuilder: face = [i, next_i, next_i + n_segments, i + n_segments] faces.append(face) - transition_items.append(get_profile_faceset(first_profile_points, start_length)) - transition_items.append(get_profile_faceset(second_profile_points, end_length, end_extrusion_offset)) + transition_items.append(self.extrude_face_set(first_profile_points, start_length, end_cap=False)) + transition_items.append( + self.extrude_face_set(second_profile_points, end_length, end_extrusion_offset, start_cap=False) + ) first_profile_points = [p + start_offset for p in first_profile_points] second_profile_points = [p + end_extrusion_offset for p in second_profile_points] @@ -1032,8 +1068,10 @@ class ShapeBuilder: else: start_points, end_points = rect_points, circle_points - transition_items.append(get_profile_faceset(start_points, start_length)) - transition_items.append(get_profile_faceset(end_points, end_length, end_extrusion_offset)) + transition_items.append(self.extrude_face_set(start_points, start_length, end_cap=False)) + transition_items.append( + self.extrude_face_set(end_points, end_length, end_extrusion_offset, start_cap=False) + ) # offset verts if starting_with_circle: @@ -1237,3 +1275,97 @@ class ShapeBuilder: else: angle = degrees(atan(offset.x / h)) return angle + + def mep_bend_shape( + self, segment, start_length: float, end_length: float, angle: float, radius: float, profile_offset: Vector + ): + """ + + :param segment: IfcFlowSegment for a bend. + Note that for a bend start and end segments types should match. + + :param angle: bend angle, in radians + :param type: float + :param radius: bend radius + :param type: float + :param profile_offset: offset between start and end segments in local space of start segment + used mainly to determine the bend axes and their direction. + Values themselves are replaced by the radius. + :param type: Vector + + :return: tuple of Model/Body/MODEL_VIEW IfcRepresentation and transition shape data + """ + + def get_profile(element): + material = ifcopenshell.util.element.get_material(element, should_skip_usage=True) + if material and material.is_a("IfcMaterialProfileSet") and len(material.MaterialProfiles) == 1: + return material.MaterialProfiles[0].Profile + + def get_dim(profile, depth): + if profile.is_a("IfcRectangleProfileDef"): + return V(profile.XDim / 2, profile.YDim / 2, depth) + elif profile.is_a("IfcCircleProfileDef"): + return V(profile.Radius, profile.Radius, depth) + return None + + # TODO: test with 0 radius + si_conversion = ifcopenshell.util.unit.calculate_unit_scale(self.file) + profile = get_profile(segment) + profile_dim = get_dim(profile, start_length) + + rounded_offset = round_vector_to_precision(profile_offset, si_conversion) + lateral_axis = next(i for i in range(2) if not is_x(rounded_offset[i], 0)) + non_lateral_axis = 1 if lateral_axis == 0 else 0 + lateral_sign = sign(profile_offset[lateral_axis]) + z_sign = sign(profile_offset.z) + + rep_items = [] + + # bend circle center + O = V(0, 0, 0) + O[lateral_axis] = (radius + profile_dim[lateral_axis]) * lateral_sign + + theta = angle + + def get_circle_extrusion(): + # get as much segment_length segments as possible + segment_length = pi / 20 + num_segments = ceil(theta / segment_length) + theta_segments = [i * segment_length for i in range(num_segments)] + if not is_x(theta_segments[-1], theta): + theta_segments.append(theta) + + inner_points, outer_points = [], [] + r = radius + + for cur_theta in theta_segments: + cur_theta -= pi / 2 + inner = V(0, 0, 0) + # fmt: off + inner.z = z_sign * cos(cur_theta) * r + inner[lateral_axis] = lateral_sign * sin(cur_theta) * r + inner_points.append(inner) + + outer = V(0, 0, 0) + outer.z = z_sign * cos(cur_theta) * (r + 2 * profile_dim[lateral_axis]) + outer[lateral_axis] = lateral_sign * sin(cur_theta) * (r + 2 * profile_dim[lateral_axis]) + outer_points.append(outer) + # fmt: on + + points = inner_points + outer_points[::-1] + points = [p + O for p in points] + offset = V(0, 0, 0) + offset[non_lateral_axis] = -profile_dim[non_lateral_axis] + extrusion_vector = V(0, 0, 0) + extrusion_vector[non_lateral_axis] = 1 + extrusion = self.extrude_face_set( + points, magnitude=profile_dim[non_lateral_axis] * 2, offset=offset, extrusion_vector=extrusion_vector + ) + return extrusion + + rep_items.append(get_circle_extrusion()) + body = ifcopenshell.util.representation.get_context(self.file, "Model", "Body", "MODEL_VIEW") + rep = self.get_representation(body, rep_items) + + bend_data = {"start_length": start_length, "end_length": end_length, "radius": radius, "angle": degrees(theta)} + return rep, bend_data From ca4efa6e50749a58f75e90c5fe5ad68e5689dc81 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 4 Sep 2023 16:14:56 +0500 Subject: [PATCH 007/190] Remove operator's name from set_tab tooltip #3704 --- src/blenderbim/blenderbim/bim/operator.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/operator.py b/src/blenderbim/blenderbim/bim/operator.py index 08f591be6e..b4b70bae2a 100644 --- a/src/blenderbim/blenderbim/bim/operator.py +++ b/src/blenderbim/blenderbim/bim/operator.py @@ -42,13 +42,15 @@ from math import radians class SetTab(bpy.types.Operator): bl_idname = "bim.set_tab" - bl_label = "Set Current Tab" - bl_options = {"REGISTER", "UNDO"} + # NOTE: bl_label is set to empty string intentionally + # to avoid showing the operator's name in the tooltips, see #3704 + bl_label = "" + bl_options = {"REGISTER", "UNDO", "HIDDEN"} tab: bpy.props.StringProperty() @classmethod - def description(cls, context, properties): - return next((t[1] for t in blenderbim.bim.prop.get_tab(None, context) if t[0] == properties.tab), "") + def description(cls, context, operator): + return next((t[1] for t in blenderbim.bim.prop.get_tab(None, context) if t[0] == operator.tab), "") def execute(self, context): if context.area.spaces.active.search_filter: From 8b27c0e75581c3aac7815fbcb731ec75dddc2860 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 4 Sep 2023 16:33:02 +0500 Subject: [PATCH 008/190] hotfix for ca4efa6e5 --- src/blenderbim/blenderbim/bim/operator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blenderbim/blenderbim/bim/operator.py b/src/blenderbim/blenderbim/bim/operator.py index b4b70bae2a..9f36116ce2 100644 --- a/src/blenderbim/blenderbim/bim/operator.py +++ b/src/blenderbim/blenderbim/bim/operator.py @@ -45,7 +45,7 @@ class SetTab(bpy.types.Operator): # NOTE: bl_label is set to empty string intentionally # to avoid showing the operator's name in the tooltips, see #3704 bl_label = "" - bl_options = {"REGISTER", "UNDO", "HIDDEN"} + bl_options = {"REGISTER", "UNDO", "INTERNAL"} tab: bpy.props.StringProperty() @classmethod From 5582bb2d4cefaad6c3db7c1c7b9f8eefc9394a5c Mon Sep 17 00:00:00 2001 From: Dirk Olbrich Date: Mon, 4 Sep 2023 09:36:06 +0200 Subject: [PATCH 009/190] ifcmax: format and unify cmake --- src/ifcmax/CMakeLists.txt | 63 ++++++++++++++++++++++++++------------- 1 file changed, 43 insertions(+), 20 deletions(-) diff --git a/src/ifcmax/CMakeLists.txt b/src/ifcmax/CMakeLists.txt index 7db41dc2e1..0fa85e21e2 100644 --- a/src/ifcmax/CMakeLists.txt +++ b/src/ifcmax/CMakeLists.txt @@ -17,31 +17,54 @@ # # ################################################################################ foreach(max_year RANGE 2014 2030) + set(max_sdk "$ENV{ADSK_3DSMAX_SDK_${max_year}}") + if (NOT "${max_sdk}" STREQUAL "") -set(max_sdk "$ENV{ADSK_3DSMAX_SDK_${max_year}}") -if (NOT "${max_sdk}" STREQUAL "") + include_directories(${INCLUDE_DIRECTORIES} ${OCC_INCLUDE_DIR} ${OPENCOLLADA_INCLUDE_DIRS} ${ICU_INCLUDE_DIR} + ${Boost_INCLUDE_DIRS} ${max_sdk}/include + ) -INCLUDE_DIRECTORIES(${INCLUDE_DIRECTORIES} ${OCC_INCLUDE_DIR} ${OPENCOLLADA_INCLUDE_DIRS} ${ICU_INCLUDE_DIR} - ${Boost_INCLUDE_DIRS} ${max_sdk}/include -) + # All recent versions of 3ds Max (2014 and newer) are 64-bit only so assume lib/x64 directory + link_directories(${LINK_DIRECTORIES} ${IfcOpenShell_BINARY_DIR} ${OCC_LIBRARY_DIR} ${OPENCOLLADA_LIBRARY_DIR} + ${ICU_LIBRARY_DIR} ${Boost_LIBRARY_DIRS} ${max_sdk}/lib/x64/Release + ) -# All recent versions of 3ds Max (2014 and newer) are 64-bit only so assume lib/x64 directory -LINK_DIRECTORIES(${LINK_DIRECTORIES} ${IfcOpenShell_BINARY_DIR} ${OCC_LIBRARY_DIR} ${OPENCOLLADA_LIBRARY_DIR} - ${ICU_LIBRARY_DIR} ${Boost_LIBRARY_DIRS} ${max_sdk}/lib/x64/Release -) + add_library(IfcMax_${max_year} SHARED IfcMax.h IfcMax.cpp) -ADD_LIBRARY(IfcMax_${max_year} SHARED IfcMax.h IfcMax.cpp) + # TODO: find the minimal subset of 3dsmax libraries to reference + target_link_libraries(IfcMax_${max_year} ${IFCOPENSHELL_LIBRARIES} + bmm.lib + Comctl32.lib + core.lib + CustDlg.lib + edmodel.lib + expr.lib + flt.lib + geom.lib + gfx.lib + gup.lib + imageViewers.lib + ManipSys.lib + maxnet.lib + Maxscrpt.lib + maxutil.lib + MenuMan.lib + menus.lib + mesh.lib + MNMath.lib + Paramblk2.lib + particle.lib + Poly.lib + RenderUtil.lib + tessint.lib + viewfile.lib + zlibdll.lib + ${OPENCASCADE_LIBRARIES} + ) -# TODO: find the minimal subset of 3dsmax libraries to reference -TARGET_LINK_LIBRARIES(IfcMax_${max_year} ${IFCOPENSHELL_LIBRARIES} Comctl32.lib zlibdll.lib bmm.lib core.lib CustDlg.lib edmodel.lib expr.lib - flt.lib geom.lib gfx.lib gup.lib imageViewers.lib ManipSys.lib maxnet.lib Maxscrpt.lib - maxutil.lib MenuMan.lib menus.lib mesh.lib MNMath.lib Paramblk2.lib particle.lib Poly.lib RenderUtil.lib - tessint.lib viewfile.lib ${OPENCASCADE_LIBRARIES} -) + set_target_properties(IfcMax_${max_year} PROPERTIES SUFFIX ".dli") -SET_TARGET_PROPERTIES(IfcMax_${max_year} PROPERTIES SUFFIX ".dli") + install(TARGETS IfcMax_${max_year} RUNTIME DESTINATION ${BINDIR}) -INSTALL(TARGETS IfcMax_${max_year} RUNTIME DESTINATION ${BINDIR}) - -endif() + endif() endforeach() \ No newline at end of file From addacd9ce52e3209698d58a25932367673e2bdf3 Mon Sep 17 00:00:00 2001 From: Dirk Olbrich Date: Mon, 4 Sep 2023 14:37:59 +0200 Subject: [PATCH 010/190] cmake: move check fo 3ds max sdk to external file --- cmake/CMakeLists.txt | 61 +++++++++++++++------------------------ src/ifcmax/CMakeLists.txt | 23 +++++++++++++-- 2 files changed, 44 insertions(+), 40 deletions(-) diff --git a/cmake/CMakeLists.txt b/cmake/CMakeLists.txt index 0f428738bf..8e41559097 100644 --- a/cmake/CMakeLists.txt +++ b/cmake/CMakeLists.txt @@ -883,13 +883,13 @@ if(BUILD_IFCGEOM) file(GLOB IFCGEOM_CPP_FILES ../src/ifcgeom/*.cpp) set(IFCGEOM_FILES ${IFCGEOM_CPP_FILES} ${IFCGEOM_H_FILES}) -foreach(schema ${SCHEMA_VERSIONS}) - add_library(IfcGeom_ifc${schema} STATIC ${IFCGEOM_FILES}) - set_target_properties(IfcGeom_ifc${schema} PROPERTIES COMPILE_FLAGS "-DIFC_GEOM_EXPORTS -DIfcSchema=Ifc${schema}") - if (NOT WASM_BUILD) - target_link_libraries(IfcGeom_ifc${schema} IfcParse ${OPENCASCADE_LIBRARIES}) - endif() -endforeach() + foreach(schema ${SCHEMA_VERSIONS}) + add_library(IfcGeom_ifc${schema} STATIC ${IFCGEOM_FILES}) + set_target_properties(IfcGeom_ifc${schema} PROPERTIES COMPILE_FLAGS "-DIFC_GEOM_EXPORTS -DIfcSchema=Ifc${schema}") + if (NOT WASM_BUILD) + target_link_libraries(IfcGeom_ifc${schema} IfcParse ${OPENCASCADE_LIBRARIES}) + endif() + endforeach() # IfcGeom (schema agnostic) file(GLOB SCHEMA_AGNOSTIC_H_FILES ../src/ifcgeom_schema_agnostic/*.h) @@ -903,11 +903,11 @@ endforeach() find_package(Threads) endif() -if (WASM_BUILD) - target_link_libraries(IfcGeom ${IFCGEOM_SCHEMA_LIBRARIES} ${CMAKE_THREAD_LIBS_INIT}) -else() - target_link_libraries(IfcGeom IfcParse ${IFCGEOM_SCHEMA_LIBRARIES} ${CMAKE_THREAD_LIBS_INIT}) -endif() + if (WASM_BUILD) + target_link_libraries(IfcGeom ${IFCGEOM_SCHEMA_LIBRARIES} ${CMAKE_THREAD_LIBS_INIT}) + else() + target_link_libraries(IfcGeom IfcParse ${IFCGEOM_SCHEMA_LIBRARIES} ${CMAKE_THREAD_LIBS_INIT}) + endif() endif(BUILD_IFCGEOM) @@ -920,16 +920,16 @@ if(BUILD_CONVERT OR BUILD_IFCPYTHON) file(GLOB SERIALIZERS_S_CPP_FILES ../src/serializers/schema_dependent/*.cpp) set(SERIALIZERS_S_FILES ${SERIALIZERS_S_H_FILES} ${SERIALIZERS_S_CPP_FILES}) -foreach(schema ${SCHEMA_VERSIONS}) - add_library(Serializers_ifc${schema} STATIC ${SERIALIZERS_S_FILES}) - set_target_properties(Serializers_ifc${schema} PROPERTIES COMPILE_FLAGS "-DIFC_GEOM_EXPORTS -DIfcSchema=Ifc${schema}") - - if (WASM_BUILD) - target_link_libraries(Serializers_ifc${schema} ${HDF5_LIBRARIES}) - else() - target_link_libraries(Serializers_ifc${schema} IfcGeom ${OPENCASCADE_LIBRARIES} ${HDF5_LIBRARIES}) - endif() -endforeach() + foreach(schema ${SCHEMA_VERSIONS}) + add_library(Serializers_ifc${schema} STATIC ${SERIALIZERS_S_FILES}) + set_target_properties(Serializers_ifc${schema} PROPERTIES COMPILE_FLAGS "-DIFC_GEOM_EXPORTS -DIfcSchema=Ifc${schema}") + + if (WASM_BUILD) + target_link_libraries(Serializers_ifc${schema} ${HDF5_LIBRARIES}) + else() + target_link_libraries(Serializers_ifc${schema} IfcGeom ${OPENCASCADE_LIBRARIES} ${HDF5_LIBRARIES}) + endif() + endforeach() add_library(Serializers ${SERIALIZERS_FILES}) set_target_properties(Serializers PROPERTIES COMPILE_FLAGS "-DIFC_GEOM_EXPORTS" VERSION "${PROJECT_VERSION}" SOVERSION "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}") @@ -1027,21 +1027,8 @@ if(BUILD_EXAMPLES) add_subdirectory(../src/examples examples) endif() -if(NOT MINIMAL_BUILD AND BUILD_IFCMAX) - foreach(max_year RANGE 2014 2030) - set(max_sdk "$ENV{ADSK_3DSMAX_SDK_${max_year}}") - - if(NOT "${max_sdk}" STREQUAL "") - message(STATUS "Autodesk 3ds Max SDK found at ${max_sdk}") - set(HAS_MAX TRUE) - endif() - endforeach() - - if(HAS_MAX) - add_subdirectory(../src/ifcmax ifcmax) - else() - message(STATUS "Autodesk 3ds Max SDK not found, is required to build IFCMax.") - endif() +if(BUILD_IFCMAX) + add_subdirectory(../src/ifcmax ifcmax) endif() if(NOT MINIMAL_BUILD) diff --git a/src/ifcmax/CMakeLists.txt b/src/ifcmax/CMakeLists.txt index 0fa85e21e2..5ecadc882c 100644 --- a/src/ifcmax/CMakeLists.txt +++ b/src/ifcmax/CMakeLists.txt @@ -16,9 +16,24 @@ # along with this program. If not, see . # # # ################################################################################ + +# check for 3ds Max SDK foreach(max_year RANGE 2014 2030) set(max_sdk "$ENV{ADSK_3DSMAX_SDK_${max_year}}") - if (NOT "${max_sdk}" STREQUAL "") + + if(NOT "${max_sdk}" STREQUAL "") + message(STATUS "Autodesk 3ds Max SDK ${max_year} found at ${max_sdk}") + list(APPEND FOUND_MAX_YEARS ${max_year}) + list(APPEND FOUND_MAX_SDKS ${max_sdk}) + set(HAS_MAX TRUE) + endif() +endforeach() + +if(HAS_MAX) + # build libraray for each found 3ds Max SDK + foreach(max_year max_sdk IN ZIP_LISTS FOUND_MAX_YEARS FOUND_MAX_SDKS) + + message(STATUS "Building IFCMax library for Autodesk 3ds Max SDK ${max_year}") include_directories(${INCLUDE_DIRECTORIES} ${OCC_INCLUDE_DIR} ${OPENCOLLADA_INCLUDE_DIRS} ${ICU_INCLUDE_DIR} ${Boost_INCLUDE_DIRS} ${max_sdk}/include @@ -65,6 +80,8 @@ foreach(max_year RANGE 2014 2030) set_target_properties(IfcMax_${max_year} PROPERTIES SUFFIX ".dli") install(TARGETS IfcMax_${max_year} RUNTIME DESTINATION ${BINDIR}) + endforeach() +else() + message(STATUS "Autodesk 3ds Max SDK not found, is required to build IFCMax.") +endif() - endif() -endforeach() \ No newline at end of file From 0f3b416c3fe909890e5802002d648249c1ae214a Mon Sep 17 00:00:00 2001 From: Dirk Olbrich Date: Mon, 4 Sep 2023 20:54:29 +0200 Subject: [PATCH 011/190] cmake: reorder build options --- cmake/CMakeLists.txt | 87 ++++++++++++++++++++++---------------------- 1 file changed, 44 insertions(+), 43 deletions(-) diff --git a/cmake/CMakeLists.txt b/cmake/CMakeLists.txt index 8e41559097..6c0822ea90 100644 --- a/cmake/CMakeLists.txt +++ b/cmake/CMakeLists.txt @@ -36,35 +36,50 @@ endif() set(EXTRA_VERSION "-alpha.3") option(MINIMAL_BUILD "The build is to make a minimal version of IFC converter from OCCT into IFC." OFF) -option(COLLADA_SUPPORT "Build IfcConvert with COLLADA support (requires OpenCOLLADA)." ON) -option(IFCXML_SUPPORT "Build IfcParse with ifcXML support (requires libxml2)." ON) +option(WASM_BUILD "Build a WebAssembly binary." OFF) + option(ENABLE_BUILD_OPTIMIZATIONS "Enable certain compiler and linker optimizations on RelWithDebInfo and Release builds." OFF) -option(BUILD_PACKAGE "" OFF) -option(BUILD_IFCGEOM "Build IfcGeom." ON) -option(BUILD_DOCUMENTATION "Build IfcOpenShell Documentation." OFF) -option(BUILD_IFCPYTHON "Build IfcPython." ON) -option(BUILD_EXAMPLES "Build example applications." ON) -option(BUILD_GEOMSERVER "Build IfcGeomServer executable." ON) -option(BUILD_CONVERT "Build IfcConvert executable." ON) -option(HDF5_SUPPORT "Enable HDF5 support (requires HDF5, zlib)" ON) -option(USE_VLD "Use Visual Leak Detector for debugging memory leaks, MSVC-only." OFF) -option(USE_MMAP "Adds a command line options to parse IFC files from memory mapped files using Boost.Iostreams" OFF) option(BUILD_SHARED_LIBS "Build IfcParse and IfcGeom as shared libs (SO/DLL)." OFF) option(MSVC_PARALLEL_BUILD "Multi-threaded compilation in Microsoft Visual Studio (/MP)" OFF) - +option(USE_VLD "Use Visual Leak Detector for debugging memory leaks, MSVC-only." OFF) +option(USE_MMAP "Adds a command line options to parse IFC files from memory mapped files using Boost.Iostreams" OFF) option(NO_WARN "Disable all warnings" OFF) -option(WASM_BUILD OFF) -if(${BUILD_CONVERT}) - option(GLTF_SUPPORT "Build IfcConvert with glTF support (requires json.hpp)." OFF) - option(USD_SUPPORT "Build IfcConvert with USD support (requires pixar's USD library)." OFF) -endif() +option(BUILD_IFCGEOM "Build IfcGeom." ON) +option(BUILD_IFCPYTHON "Build IfcPython." ON) +option(BUILD_CONVERT "Build IfcConvert executable." ON) +option(BUILD_DOCUMENTATION "Build IfcOpenShell Documentation." OFF) +option(BUILD_EXAMPLES "Build example applications." ON) +option(BUILD_GEOMSERVER "Build IfcGeomServer executable." ON) +option(BUILD_IFCMAX "Build IfcMax, a 3ds Max plug-in, Windows-only." OFF) +option(BUILD_QTVIEWER "Build IfcOpenShell Qt GUI Viewer" OFF) # QtViewer requires Qt6 +option(BUILD_PACKAGE "" OFF) + +option(COLLADA_SUPPORT "Build IfcConvert with COLLADA support (requires OpenCOLLADA)." ON) +option(GLTF_SUPPORT "Build IfcConvert with glTF support (requires json.hpp)." OFF) +option(HDF5_SUPPORT "Enable HDF5 support (requires HDF5, zlib)" ON) +option(IFCXML_SUPPORT "Build IfcParse with ifcXML support (requires libxml2)." ON) +option(USD_SUPPORT "Build IfcConvert with USD support (requires pixar's USD library)." OFF) option(USERSPACE_PYTHON_PREFIX "Installs IfcPython for the current user only instead of system-wide." OFF) option(ADD_COMMIT_SHA "Add commit sha and branch in version number, warning results in many rebuilds, requires git" OFF) -option(BUILD_IFCMAX "Build IfcMax, a 3ds Max plug-in, Windows-only." OFF) -# QtViewer requires Qt6 -option(BUILD_QTVIEWER "Build IfcOpenShell Qt GUI Viewer" OFF) + +if(MINIMAL_BUILD) + message(STATUS "Setting options for minimal build") + set(BUILD_CONVERT OFF) + set(BUILD_GEOMSERVER OFF) + set(BUILD_IFCPYTHON OFF) + set(COLLADA_SUPPORT OFF) + set(GLTF_SUPPORT OFF) + set(HDF5_SUPPORT OFF) + set(IFCXML_SUPPORT OFF) + set(USD_SUPPORT OFF) +endif() + +if((BUILD_CONVERT OR BUILD_GEOMSERVER OR BUILD_IFCPYTHON) AND(NOT BUILD_IFCGEOM)) + message(STATUS "'IfcGeom' is required with current outputs") + set(BUILD_IFCGEOM ON) +endif() if(MSVC AND MSVC_PARALLEL_BUILD) add_definitions("/MP") @@ -80,50 +95,37 @@ endif() include(GNUInstallDirs) -if((BUILD_CONVERT OR BUILD_GEOMSERVER OR BUILD_IFCPYTHON) AND(NOT BUILD_IFCGEOM)) - message(STATUS "'IfcGeom' is required with current outputs") - set(BUILD_IFCGEOM ON) -endif() - -# Specify where to install files +# Specify paths to install files if(NOT BINDIR) set(BINDIR bin) endif() - if(NOT IS_ABSOLUTE ${BINDIR}) set(BINDIR ${CMAKE_INSTALL_BINDIR}) endif() - message(STATUS "BINDIR: ${BINDIR}") if(NOT INCLUDEDIR) set(INCLUDEDIR include) endif() - if(NOT IS_ABSOLUTE ${INCLUDEDIR}) set(INCLUDEDIR ${CMAKE_INSTALL_INCLUDEDIR}) endif() - message(STATUS "INCLUDEDIR: ${INCLUDEDIR}") if(NOT LIBDIR) set(LIBDIR lib) endif() - if(NOT IS_ABSOLUTE ${LIBDIR}) set(LIBDIR ${CMAKE_INSTALL_LIBDIR}) endif() - message(STATUS "LIBDIR: ${LIBDIR}") set(IFCOPENSHELL_LIBRARY_DIR "") # for *nix rpaths if(BUILD_SHARED_LIBS) add_definitions(-DIFC_SHARED_BUILD) - if(MSVC) message(WARNING "Building DLLs against the static VC run-time. This is not recommended if the DLLs are to be redistributed.") - # C4521: 'identifier' : class 'type' needs to have dll-interface to be used by clients of class 'type2' # There will be couple hundreds of these so suppress them away, https://msdn.microsoft.com/en-us/library/esew7y1w.aspx add_definitions(-wd4251) @@ -170,7 +172,7 @@ if(WASM_BUILD) set(CMAKE_FIND_ROOT_PATH "") endif() -if(NOT MINIMAL_BUILD AND GLTF_SUPPORT AND BUILD_CONVERT) +if(GLTF_SUPPORT AND BUILD_CONVERT) UNIFY_ENVVARS_AND_CACHE(JSON_INCLUDE_DIR) find_file(json_hpp "json.hpp" ${JSON_INCLUDE_DIR}/nlohmann) @@ -185,7 +187,7 @@ if(NOT MINIMAL_BUILD AND GLTF_SUPPORT AND BUILD_CONVERT) endif() # Add USD support to serializers -if(NOT MINIMAL_BUILD AND USD_SUPPORT) +if(USD_SUPPORT) UNIFY_ENVVARS_AND_CACHE(USD_INCLUDE_DIR) UNIFY_ENVVARS_AND_CACHE(USD_LIBRARY_DIR) @@ -303,7 +305,7 @@ if(NOT MINIMAL_BUILD) find_package(LibXml2 REQUIRED) endif() -if(NOT MINIMAL_BUILD AND IFCXML_SUPPORT) +if(IFCXML_SUPPORT) add_definitions(-DWITH_IFCXML) set(SWIG_DEFINES ${SWIG_DEFINES} -DWITH_IFCXML) endif() @@ -452,7 +454,7 @@ if(BUILD_IFCGEOM) endif(BUILD_IFCGEOM) -if(NOT MINIMAL_BUILD AND COLLADA_SUPPORT) +if(COLLADA_SUPPORT) # Find OpenCOLLADA if("${OPENCOLLADA_INCLUDE_DIR}" STREQUAL "") message(STATUS "No OpenCOLLADA include directory specified") @@ -536,7 +538,7 @@ if(NOT MINIMAL_BUILD AND COLLADA_SUPPORT) endif() endif() -if(NOT MINIMAL_BUILD AND HDF5_SUPPORT) +if(HDF5_SUPPORT) if("${HDF5_INCLUDE_DIR}" STREQUAL "") message(STATUS "No HDF5 include directory specified") else() @@ -876,7 +878,6 @@ else() target_link_libraries(IfcParse ${Boost_LIBRARIES} ${BCRYPT_LIBRARIES} ${LIBXML2_LIBRARIES}) endif() - if(BUILD_IFCGEOM) # IfcGeom file(GLOB IFCGEOM_H_FILES ../src/ifcgeom/*.h ../src/ifcgeom/*.i) @@ -962,7 +963,7 @@ if(BUILD_CONVERT) endif(BUILD_CONVERT) # IfcGeomServer -if(NOT MINIMAL_BUILD AND BUILD_GEOMSERVER) +if(BUILD_GEOMSERVER) file(GLOB CPP_FILES ../src/ifcgeomserver/*.cpp) file(GLOB H_FILES ../src/ifcgeomserver/*.h) set(SOURCE_FILES ${CPP_FILES} ${H_FILES}) @@ -1019,7 +1020,7 @@ if(BUILD_DOCUMENTATION) add_subdirectory(../docs docs) endif() -if(NOT MINIMAL_BUILD AND BUILD_IFCPYTHON) +if(BUILD_IFCPYTHON) add_subdirectory(../src/ifcwrap ifcwrap) endif() From c5024cf6be064b9c1289ac0e57b6523fc94cf15e Mon Sep 17 00:00:00 2001 From: Dirk Olbrich Date: Mon, 4 Sep 2023 21:38:35 +0200 Subject: [PATCH 012/190] cmake: ifcconvert is build in minimal --- cmake/CMakeLists.txt | 1 - 1 file changed, 1 deletion(-) diff --git a/cmake/CMakeLists.txt b/cmake/CMakeLists.txt index 6c0822ea90..05b9c9cfb5 100644 --- a/cmake/CMakeLists.txt +++ b/cmake/CMakeLists.txt @@ -66,7 +66,6 @@ option(ADD_COMMIT_SHA "Add commit sha and branch in version number, warning resu if(MINIMAL_BUILD) message(STATUS "Setting options for minimal build") - set(BUILD_CONVERT OFF) set(BUILD_GEOMSERVER OFF) set(BUILD_IFCPYTHON OFF) set(COLLADA_SUPPORT OFF) From 0a00e529c3fe1b4f2002ae2227ef3ac212a40a6a Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 5 Sep 2023 23:42:21 +1000 Subject: [PATCH 013/190] Improve hello world documentation to be more beginner friendly --- .../docs/ifcopenshell-python/hello_world.rst | 198 ++++++- .../images/ifc-concepts.svg | 340 ++++++++++++ .../ifcopenshell-python/images/ifc-graph.svg | 316 +++++++++++ .../ifcopenshell-python/images/ifc-tree.svg | 493 ++++++++++++++++++ 4 files changed, 1331 insertions(+), 16 deletions(-) create mode 100644 src/ifcopenshell-python/docs/ifcopenshell-python/images/ifc-concepts.svg create mode 100644 src/ifcopenshell-python/docs/ifcopenshell-python/images/ifc-graph.svg create mode 100644 src/ifcopenshell-python/docs/ifcopenshell-python/images/ifc-tree.svg diff --git a/src/ifcopenshell-python/docs/ifcopenshell-python/hello_world.rst b/src/ifcopenshell-python/docs/ifcopenshell-python/hello_world.rst index e77eab5f70..5997eaaada 100644 --- a/src/ifcopenshell-python/docs/ifcopenshell-python/hello_world.rst +++ b/src/ifcopenshell-python/docs/ifcopenshell-python/hello_world.rst @@ -4,19 +4,17 @@ Hello, world! What's inside an IFC? --------------------- -An IFC model can be considered as a collection of elements with relationships to -other elements in a graph-like database. Together, these elements and their -relationships describe the digital built environment. +An IFC model is a collection of elements (e.g. doors, windows, construction +tasks, materials, etc) with relationships to other elements in a graph-like +database. Together, these elements and their relationships describe the digital +built environment. -Each element has a type known as an **IFC Class**. These types define the +.. image:: images/ifc-graph.svg + +Each element has a type known as an **IFC Class**. These classes define the attributes that the element may store. For example, the **IfcWall Class** is allowed to store a **Name** and **Description** attribute. -There are many **IFC Classes** available, defined through an **Object Oriented** -hierarchy. For example, because all **IfcElement** classes can have a -**GlobalId** attribute, that means that because **IfcWall** is a subtype of -**IfcElement**, it can also have a **GlobalId** attribute. - This IFC database can be stored in many formats. The most common is the ``.ifc`` format, which stores data in plain text. If you open a ``.ifc`` file in a text editor, you'll see something like this: @@ -31,7 +29,9 @@ editor, you'll see something like this: In this example there are 5 elements in the graph. The element with the ID of **#1** has an **IFC Class** of **IfcProject**. This element has 9 -comma-separated attributes. +comma-separated attributes. IFC defines how many attributes each **IFC Class** +is allowed to have, attribute names, the order of attributes, data type, +optional or mandatory status (i.e. cardinality), and more. :: @@ -44,9 +44,11 @@ comma-separated attributes. By selecting elements by their **IFC Class**, and reading their attributes, you can navigate from one element to another. The relationships between elements are called **IFC Concepts** and create meaning in our industry. For example, if a -**IfcWall** element is related to an **IfcBuildingStorey** element in a -particular way, it might mean that the wall is located in the ground floor of -the building. +**IfcWall** element has an attribute that references an **IfcBuildingStorey** +element in a particular way, it might mean that the wall is located in the +ground floor of the building. + +.. image:: images/ifc-concepts.svg The official IFC documentation describes hundreds of **IFC Classes**, ranging from walls, door, to tasks, cost items, parametric materials, and structural @@ -54,12 +56,176 @@ analysis constraints. There are also hundreds of **IFC Concepts**, which may describe how a wall is in a storey, a construction task might occur one after another, or how an surface bounds a space for energy analysis. -IfcOpenShell can help you navigate these IFC elements, read their attributes, -and traverse relationships. Your journey begins here. +It takes time to learn the many **IFC Classes** and **IFC Concepts** available. +Once you do, you will be able to richly describe our built environment +digitally. IfcOpenShell can help you navigate these IFC elements, read their +attributes, and explore relationships. Your journey begins here. + +.. seealso:: + + If you are already familiar with IFC and just want to learn how to use + IfcOpenShell, you can jump to the `Core functionality crash course`_. + +Begin learning IFC +------------------ + +IFC has three versions published by ISO: **IFC2X3** from 2007, **IFC4** from +2017, and **IFC4X3** in draft form. Each version improves on the previous +version, and will have different **IFC Classes** with different attributes and +different **IFC Concepts**. + +You can access the official documentation here: + +- `Official IFC2X3 documentation homepage `__ +- `Official IFC4 documentation homepage `__ +- `Official IFC4X3 documentation homepage `__ +- `List of all IFC2X3 classes `__ +- `List of all IFC4 classes `__ +- `List of all IFC4X3 classes `__ + +.. tip:: + + It is recommended to use IFC4. However, the IFC4X3 documentation is a lot + more friendly to newcomers. + +The official ISO documentation is written for a technical audience and may be +overwhelming. This guide will take you slowly through the core concepts, and +leave you with the knowledge you need to discover more. + +Before digging into theory, let's explore an existing IFC model. You can +download this sample IFC for this guide. + +.. container:: blockbutton + + `Download sample IFC `__ + +If you open up the model with a text editor, you will see text similar to this: + +:: + + ISO-10303-21; + HEADER;FILE_DESCRIPTION(('ViewDefinition [, QuantityTakeOffAddOnView, SpaceBoundary2ndLevelAddOnView]'),'2;1'); + FILE_NAME('AC20-FZK-Haus.ifc','2016-12-21T17:54:06',('Architect'),(''),'','',''); + FILE_SCHEMA(('IFC4')); + ENDSEC; + + DATA; + #3= IFCORGANIZATION($,'Nicht definiert',$,$,$); + #12= IFCOWNERHISTORY(#7,#11,$,.ADDED.,$,$,$,1482339244); + #13= IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.); + #14= IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.); + + ... + + #62= IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.00000000000E-5,#59,#60); + #66= IFCPROJECT('0lY6P5Ur90TAQnnnI6wtnb',#12,'Projekt-FZK-Haus','Projekt FZK-House create by KHH Forschuungszentrum Karlsruhe',$,$,$,(#62,#374),#49); + #77= IFCPROPERTYSINGLEVALUE('GS_TimeStamp',$,IFCTIMESTAMP(9685146),$); + #85= IFCPROPERTYSET('1mnk_H9cG6eU2r9ped0WRu',#12,'GSPset_TimeStamp',$,(#77)); + + ... + + #15033= IFCSHAPEREPRESENTATION(#15026,'Axis','Curve2D',(#15031)); + #15037= IFCPRODUCTDEFINITIONSHAPE($,$,(#15016,#15024,#15033)); + #15042= IFCWALLSTANDARDCASE('2XPyKWY018sA1ygZKgQPtU',#12,'Wand-Int-ERDG-4',$,$,#14983,#15037,'BC6F0F70-6195-495E-A2-FC-239713029DB1',$); + #15046= IFCMATERIAL('Leichtbeton 102890359',$,$); + + ... + + #15231= IFCRELDEFINESBYPROPERTIES('3Q0nMR5elnJFWzAhgkZqe1',#12,$,$,(#15042),#15229); + #15234= IFCWALLTYPE('2AEMyYvIjlsz7LRzqYHy64',#12,'Leichtbeton 102890359 240',$,$,$,(#15244,#15248,#15250,#17288,#17290,#17292,#18637,#18639,#18641,#19015,#19017,#19019,#20770,#20772,#20774),'8A396F22-E52B-6FDB-D1D5-6FDD2247C184',$,.NOTDEFINED.); + #15237= IFCDIRECTION((1.,0.,0.)); + #15239= IFCDIRECTION((0.,0.,1.)); + + ... etc + +The first thing you should notice is the line that defines that this is an +**IFC4** version. This determines what **IFC Classes** and **IFC Concepts** are +available. + +:: + + FILE_SCHEMA(('IFC4')); + +You'll notice certain **IFC Class** keywords jump out at you: things like +**IFCSIUNIT** which defines the length unit of metres, or **IFCPROJECT** which +defines the project, or **IFCPROPERTYSINGLEVALUE** which defines a property of +something, or **IFCWALLSTANDARDCASE** which defines a wall, or **IFCMATERIAL** +which defines a material, and so on. + +Let's see how to fetch this data with code. Let's start with loading the model. +Import the IfcOpenShell module, then use the ``open`` function to load the +model into a variable called ``model``. The first piece of information we want +to check is what IFC schema version we are using. We assume the model you are +learning with is IFC4. We'll then fetch all entities that use the **IfcSlab** +class. + +.. code-block:: python + + import ifcopenshell + model = ifcopenshell.open('/path/to/your/model.ifc') + print(model.schema) # May return IFC2X3, IFC4, or IFC4X3. + print(model.by_type("IfcSlab")) # Will return a list of IFCSLAB entities, like below: + # [ + # #34509=IfcSlab('1pPHnf7cXCpPsNEnQf8_6B',#12,'Bodenplatte',$,$,#34464,#34505,'E4D9CD4B-CA43-4735-94-BD-1FD4376BD455',.BASESLAB.), + # #59290=IfcSlab('2RGlQk4xH47RHK93zcTzUL',#12,'Slab-033',$,$,#59253,#59286,'DA0A17AC-B773-47AC-99-C5-D390C73AD5CC',.FLOOR.), + # #59553=IfcSlab('07Enbsqm9C7AQC9iyBwfSD',#12,'Dach-1',$,$,#59508,#59549,'E142B455-80E4-4B96-83-EC-E1589CA998DB',.ROOF.), + # #59753=IfcSlab('2IxUUNUVPB6Ob$eicCfP2N',#12,'Dach-2',$,$,#59716,#59749,'BD6D9414-37DF-40A8-88-40-301A32A9A5B5',.ROOF.) + # ] + +.. tip:: + + Try changing ``model.by_type("IfcSlab")`` to fetch different types of + entities based on their **IFC Class**. + +An overview of all IFC classes +------------------------------ + +There are hundreds of **IFC Classes**. You don't need to know them all, but +we'll help describe the general breakdown so you know where to find the +appropriate class for what you're after. + +**IFC Classes** are defined using an **Object Oriented** tree hierarchy. Child +**IFC Classes** inherit the attributes defined by the parent **IFC Class**. +This means that **IFC Classes** with common attributes are grouped together in +the tree. + +For example, because all **IfcObject** classes can have a **GlobalId** +attribute, that means that because **IfcWall** is a subtype of **IfcObject**, +it can also have a **GlobalId** attribute. + +.. image:: images/ifc-tree.svg + +Important IFC concepts +---------------------- + +There are hundreds of **IFC Concepts** that allow you to describe relationships +between **IFC Classes**. In this guide, we'll focus on the five most common +**IFC Concepts** to get you started. + +Concept 1: the project context +------------------------------ + +Concept 2: spatial decomposition +-------------------------------- + +Concept 3: object typing +------------------------ + +Concept 4: attributes and property sets +--------------------------------------- + +Concept 5: material assignment +------------------------------ + +Self-learning IFC: how to learn more +------------------------------------ Core functionality crash course ------------------------------- +If you're reading this, we assume you already know IFC and just want to quickly +get started with IfcOpenShell. + This crash course guides you through basic code snippets that give you a general idea of the low-level functionality that IfcOpenShell-python provides. You'll need to have IfcOpenShell installed and a sample IFC model. To get the most out @@ -91,7 +257,7 @@ Let's see what IFC schema we are using: .. code-block:: python - print(model.schema) # May return IFC2X3 or IFC4 + print(model.schema) # May return IFC2X3, IFC4, or IFC4X3. Let's get the first piece of data in our IFC file: diff --git a/src/ifcopenshell-python/docs/ifcopenshell-python/images/ifc-concepts.svg b/src/ifcopenshell-python/docs/ifcopenshell-python/images/ifc-concepts.svg new file mode 100644 index 0000000000..b9ca548c0c --- /dev/null +++ b/src/ifcopenshell-python/docs/ifcopenshell-python/images/ifc-concepts.svg @@ -0,0 +1,340 @@ + + + + + + + + + + BEAM + + + + + + MATERIAL + + + THIS BEAM + IS MADE FROM + STEEL + + STOREY + + + + + + + + TASK + + + + IS LOCATED ON + IS BUILT ON + LEVEL 3 + ACTIVITY 3.14 + + + + TIME + + + + SCHEDULED ON + OCTOBER 2 + + + diff --git a/src/ifcopenshell-python/docs/ifcopenshell-python/images/ifc-graph.svg b/src/ifcopenshell-python/docs/ifcopenshell-python/images/ifc-graph.svg new file mode 100644 index 0000000000..aea6ba8e0d --- /dev/null +++ b/src/ifcopenshell-python/docs/ifcopenshell-python/images/ifc-graph.svg @@ -0,0 +1,316 @@ + + + + + + + + + + + WALL + WINDOW + SLAB + COLUMN + + + + + + + + + + + + + + + + + + + + + + + + MATERIAL + + COST ITEM + + TASK + + STRUCTURAL LOAD + + + + diff --git a/src/ifcopenshell-python/docs/ifcopenshell-python/images/ifc-tree.svg b/src/ifcopenshell-python/docs/ifcopenshell-python/images/ifc-tree.svg new file mode 100644 index 0000000000..f5247069df --- /dev/null +++ b/src/ifcopenshell-python/docs/ifcopenshell-python/images/ifc-tree.svg @@ -0,0 +1,493 @@ + + + + + + + + + + + + + + + + ROOT + - GLOBAL ID- NAME- DESCRIPTION + + + + OBJECTS + - GLOBAL ID- NAME- DESCRIPTION- TYPE... + + + + TYPES + ... + + + + PRODUCTS + - GLOBAL ID- NAME- DESCRIPTION- TYPE- GEOMETRY- LOCATION + + + + GROUPS + ... + + + + RESOURCES + ... + + + + + WALL + - GLOBAL ID- NAME- DESCRIPTION- TYPE- GEOMETRY- LOCATION- WALL TYPE... + + + + SLAB + - GLOBAL ID- NAME- DESCRIPTION- TYPE- GEOMETRY- LOCATION- SLAB TYPE... + + + + + + + + + + + + From 28eeba49bacd438e6ab16c775d654cc33df38813 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 6 Sep 2023 15:11:07 +1000 Subject: [PATCH 014/190] Fix #3712. Fix bug where grid axes couldn't be duplicated. --- src/blenderbim/blenderbim/tool/geometry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blenderbim/blenderbim/tool/geometry.py b/src/blenderbim/blenderbim/tool/geometry.py index 0544c9d330..b1ce914f4c 100644 --- a/src/blenderbim/blenderbim/tool/geometry.py +++ b/src/blenderbim/blenderbim/tool/geometry.py @@ -46,7 +46,7 @@ class Geometry(blenderbim.core.tool.Geometry): @classmethod def clear_cache(cls, element): cache = IfcStore.get_cache() - if cache: + if cache and hasattr(element, "GlobalId"): cache.remove(element.GlobalId) @classmethod From ba4610155f9ee5ff40b2c824a4f276ce7b3f1ad3 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 6 Sep 2023 16:34:39 +1000 Subject: [PATCH 015/190] Fix #3705. Fix ambiguity in using commas in unquoted strings in facet selector. --- src/ifcopenshell-python/ifcopenshell/util/selector.py | 2 +- src/ifcopenshell-python/test/util/test_selector.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ifcopenshell-python/ifcopenshell/util/selector.py b/src/ifcopenshell-python/ifcopenshell/util/selector.py index e88dfac47b..b0c2b2fb7e 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/selector.py +++ b/src/ifcopenshell-python/ifcopenshell/util/selector.py @@ -51,7 +51,7 @@ filter_elements_grammar = lark.Lark( ifc_class: /Ifc\\w+/ value: special | quoted_string | regex_string | unquoted_string - unquoted_string: /[^.=\\s]+/ + unquoted_string: /[^,.=\\s]+/ regex_string: "/" /[^\\/]+/ "/" quoted_string: ESCAPED_STRING diff --git a/src/ifcopenshell-python/test/util/test_selector.py b/src/ifcopenshell-python/test/util/test_selector.py index a63a8fdfb2..ebb2528f15 100644 --- a/src/ifcopenshell-python/test/util/test_selector.py +++ b/src/ifcopenshell-python/test/util/test_selector.py @@ -147,6 +147,9 @@ class TestFilterElements(test.bootstrap.IFC4): assert subject.filter_elements(self.file, 'IfcWall, Name="Foo\'s \\"quoted\\" name..."') == {element} assert subject.filter_elements(self.file, "IfcWall, Name=/Fo.*/") == {element} assert subject.filter_elements(self.file, "IfcWall, Description=NULL") == {element, element2} + element.Name = "Foo" + element.Description = "Foobar" + assert subject.filter_elements(self.file, "IfcWall, Name=Foo, Description=Foobar") == {element} def test_selecting_by_type(self): element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") From 33dc3a4b0689678d95147e4b8560a7a79be04f78 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 6 Sep 2023 15:44:15 +0500 Subject: [PATCH 016/190] Update status visibility automatically #3721 --- src/blenderbim/blenderbim/bim/module/sequence/operator.py | 6 +++++- src/blenderbim/blenderbim/bim/module/sequence/prop.py | 2 +- src/blenderbim/blenderbim/bim/module/sequence/ui.py | 2 +- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/sequence/operator.py b/src/blenderbim/blenderbim/bim/module/sequence/operator.py index ddcfc309e4..e66d492f3a 100644 --- a/src/blenderbim/blenderbim/bim/module/sequence/operator.py +++ b/src/blenderbim/blenderbim/bim/module/sequence/operator.py @@ -51,7 +51,7 @@ class EnableStatusFilters(bpy.types.Operator): pset = element.PartOfPset[0] if pset.Name.startswith("Pset_") and pset.Name.endswith("Common"): statuses.update(element.EnumerationValues) - elif pset.Name == "EPset_Status": # Our secret sauce + elif pset.Name == "EPset_Status": # Our secret sauce statuses.update(element.EnumerationValues) elif element.Name == "UserDefinedStatus": statuses.add(element.NominalValue) @@ -67,6 +67,7 @@ class EnableStatusFilters(bpy.types.Operator): class DisableStatusFilters(bpy.types.Operator): bl_idname = "bim.disable_status_filters" bl_label = "Disable Status Filters" + bl_description = "Deactivate status filters panel.\nCan be used to refresh the displayed statuses" def execute(self, context): props = context.scene.BIMStatusProperties @@ -77,6 +78,7 @@ class DisableStatusFilters(bpy.types.Operator): class ActivateStatusFilters(bpy.types.Operator): bl_idname = "bim.activate_status_filters" bl_label = "Activate Status Filters" + bl_description = "Filter and display objects based on currently selected IFC statuses" def execute(self, context): props = context.scene.BIMStatusProperties @@ -92,6 +94,7 @@ class ActivateStatusFilters(bpy.types.Operator): query = " + ".join(query) if not query: + self.report({"INFO"}, "No statuses selected.") return {"FINISHED"} visible_elements = ifcopenshell.util.selector.filter_elements(tool.Ifc.get(), query) @@ -107,6 +110,7 @@ class ActivateStatusFilters(bpy.types.Operator): class SelectStatusFilter(bpy.types.Operator): bl_idname = "bim.select_status_filter" bl_label = "Select Status Filter" + bl_description = "Select elements with currently selected status" name: bpy.props.StringProperty() def execute(self, context): diff --git a/src/blenderbim/blenderbim/bim/module/sequence/prop.py b/src/blenderbim/blenderbim/bim/module/sequence/prop.py index 4ea2f667d8..83c48d5a02 100644 --- a/src/blenderbim/blenderbim/bim/module/sequence/prop.py +++ b/src/blenderbim/blenderbim/bim/module/sequence/prop.py @@ -405,7 +405,7 @@ class ISODuration(PropertyGroup): class IFCStatus(PropertyGroup): name: StringProperty(name="Name") - is_visible: BoolProperty(name="Is Visible", default=True) + is_visible: BoolProperty(name="Is Visible", default=True, update=lambda x, y: bpy.ops.bim.activate_status_filters()) class BIMStatusProperties(PropertyGroup): diff --git a/src/blenderbim/blenderbim/bim/module/sequence/ui.py b/src/blenderbim/blenderbim/bim/module/sequence/ui.py index f28794e481..50542ffb19 100644 --- a/src/blenderbim/blenderbim/bim/module/sequence/ui.py +++ b/src/blenderbim/blenderbim/bim/module/sequence/ui.py @@ -52,7 +52,7 @@ class BIM_PT_status(Panel): return row = self.layout.row(align=True) - row.operator("bim.activate_status_filters", icon="TIME") + row.label(text="Statuses found in the project:") row.operator("bim.disable_status_filters", icon="CANCEL", text="") for status in self.props.statuses: From 6887210a1a4305a62ef98b86f22eea37229b4fc4 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 6 Sep 2023 17:28:37 +0500 Subject: [PATCH 017/190] remove the orphaned data created on object duplicate orphaned mesh still had ifc_definition_id of the original object and could have caused issues in theory --- .../blenderbim/bim/module/geometry/operator.py | 12 ++++++++++-- src/blenderbim/blenderbim/tool/blender.py | 5 +++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/geometry/operator.py b/src/blenderbim/blenderbim/bim/module/geometry/operator.py index 226162559f..2769e37934 100644 --- a/src/blenderbim/blenderbim/bim/module/geometry/operator.py +++ b/src/blenderbim/blenderbim/bim/module/geometry/operator.py @@ -689,8 +689,11 @@ class OverrideDuplicateMove(bpy.types.Operator): blenderbim.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj) new_obj = obj.copy() + temp_data = None if obj.data: - new_obj.data = obj.data.copy() + # assure root.copy_class won't replace the previous mesh globally + temp_data = obj.data.copy() + new_obj.data = temp_data if obj == context.active_object: self.new_active_obj = new_obj for collection in obj.users_collection: @@ -699,8 +702,13 @@ class OverrideDuplicateMove(bpy.types.Operator): new_obj.select_set(True) # clear object's collection so it will be able to have it's own new_obj.BIMObjectProperties.collection = None - # Copy the actual class + # copy the actual class new = blenderbim.core.root.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=new_obj) + + # clean up the orphaned mesh with ifc id of the original object to avoid confusion + if temp_data: + tool.Blender.remove_data_block(temp_data) + if new: array_pset = ifcopenshell.util.element.get_pset(new, "BBIM_Array") if array_pset: diff --git a/src/blenderbim/blenderbim/tool/blender.py b/src/blenderbim/blenderbim/tool/blender.py index 0ce77b6d1a..6f6bbd3628 100644 --- a/src/blenderbim/blenderbim/tool/blender.py +++ b/src/blenderbim/blenderbim/tool/blender.py @@ -387,6 +387,11 @@ class Blender: getattr(data_to, data_block_type).append(name) return {"data_block": getattr(data_to, data_block_type)[0], "msg": ""} + @classmethod + def remove_data_block(cls, data_block): + collection_name = repr(data_block).split(".", 2)[-1].split("[", 1)[0] + getattr(bpy.data, collection_name).remove(data_block) + ## BMESH UTILS ## @classmethod def apply_bmesh(cls, mesh, bm, obj=None): From 7935bd22c28fbae7f7ceac03b76ab3051d50e587 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Wed, 6 Sep 2023 13:49:32 -0500 Subject: [PATCH 018/190] Small fix for feet and inch formatting on Spreadsheet Import/Export --- src/ifcopenshell-python/ifcopenshell/util/unit.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/util/unit.py b/src/ifcopenshell-python/ifcopenshell/util/unit.py index e3dc8aa6d0..2e1d05b26c 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/unit.py +++ b/src/ifcopenshell-python/ifcopenshell/util/unit.py @@ -586,7 +586,7 @@ def format_length( frac = Fraction(nearest, precision) # If fraction is a whole number, format it accordingly - if frac.denominator == 1: + if frac.numerator == 0: if imperial_unit == "inch": return f"{round(inches)}\"" if suppress_zero_inches: @@ -595,13 +595,20 @@ def format_length( elif not suppress_zero_inches: if imperial_unit == "foot": return f"{round(value)}' - 0\"" - if frac.numerator > frac.denominator and not frac.denominator == 0: + if frac.numerator > frac.denominator: remainder = frac.numerator % frac.denominator whole = int((frac.numerator - remainder) / frac.denominator) if imperial_unit == "foot": - return f"{feet}' - {whole} {remainder}/{frac.denominator}\"" + if remainder == 0: + return f"{feet}' - {whole}\"" + elif remainder != 0: + return f"{feet}' - {whole} {remainder}/{frac.denominator}\"" elif imperial_unit == "inch": - return f"{whole} {remainder}/{frac.denominator}\"" + if remainder == 0: + return f"{whole}\"" + elif remainder != 0: + return f"{whole} {remainder}/{frac.denominator}\"" + elif unit_system == "metric": rounded_val = round(value / precision) * precision From 2c2a37cacb441be9ff9c5da212734964a4881d5c Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 7 Sep 2023 09:57:46 +1000 Subject: [PATCH 019/190] Fix #3722. Fix ifcopenshell pypi distribution to use modern manylinux tag for glibc 2.31. --- src/ifcopenshell-python/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ifcopenshell-python/Makefile b/src/ifcopenshell-python/Makefile index 9c47c7ae68..05442dacbb 100644 --- a/src/ifcopenshell-python/Makefile +++ b/src/ifcopenshell-python/Makefile @@ -38,7 +38,7 @@ PYNUMBER:=311 endif ifeq ($(PLATFORM), linux) -PLATFORMTAG:=manylinux1_x86_64 +PLATFORMTAG:=manylinux_2_31_x86_64 endif ifeq ($(PLATFORM), macos) PLATFORMTAG:=macosx_10_15_x86_64 From 6dd4e0f53acc4638725325314d82c8f7a7d5f032 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 7 Sep 2023 10:12:50 +1000 Subject: [PATCH 020/190] Revert "Small fix for feet and inch formatting on Spreadsheet Import/Export" This reverts commit 7935bd22c28fbae7f7ceac03b76ab3051d50e587. --- src/ifcopenshell-python/ifcopenshell/util/unit.py | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/util/unit.py b/src/ifcopenshell-python/ifcopenshell/util/unit.py index 2e1d05b26c..e3dc8aa6d0 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/unit.py +++ b/src/ifcopenshell-python/ifcopenshell/util/unit.py @@ -586,7 +586,7 @@ def format_length( frac = Fraction(nearest, precision) # If fraction is a whole number, format it accordingly - if frac.numerator == 0: + if frac.denominator == 1: if imperial_unit == "inch": return f"{round(inches)}\"" if suppress_zero_inches: @@ -595,20 +595,13 @@ def format_length( elif not suppress_zero_inches: if imperial_unit == "foot": return f"{round(value)}' - 0\"" - if frac.numerator > frac.denominator: + if frac.numerator > frac.denominator and not frac.denominator == 0: remainder = frac.numerator % frac.denominator whole = int((frac.numerator - remainder) / frac.denominator) if imperial_unit == "foot": - if remainder == 0: - return f"{feet}' - {whole}\"" - elif remainder != 0: - return f"{feet}' - {whole} {remainder}/{frac.denominator}\"" + return f"{feet}' - {whole} {remainder}/{frac.denominator}\"" elif imperial_unit == "inch": - if remainder == 0: - return f"{whole}\"" - elif remainder != 0: - return f"{whole} {remainder}/{frac.denominator}\"" - + return f"{whole} {remainder}/{frac.denominator}\"" elif unit_system == "metric": rounded_val = round(value / precision) * precision From e06acab39be2cd5e2615faa5330eaf2224492827 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 7 Sep 2023 10:13:00 +1000 Subject: [PATCH 021/190] Revert "Spreadsheet Import/Export: Fix feet and inch formatting" This reverts commit 921d412fc73268ef0c4d6c8a42620afe5e1d761b. --- .../ifcopenshell/util/unit.py | 25 ++++++------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/util/unit.py b/src/ifcopenshell-python/ifcopenshell/util/unit.py index e3dc8aa6d0..d9c3c4259b 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/unit.py +++ b/src/ifcopenshell-python/ifcopenshell/util/unit.py @@ -574,10 +574,9 @@ def format_length( if imperial_unit == "foot": feet = int(value) inches = (value - feet) * 12 - elif imperial_unit == "inch": - inches = value * 12 - + inches = value % 12 + feet = int(round((value - inches) / 12)) # Round to the nearest 1/N nearest = round(inches * precision) @@ -587,22 +586,14 @@ def format_length( # If fraction is a whole number, format it accordingly if frac.denominator == 1: - if imperial_unit == "inch": - return f"{round(inches)}\"" - if suppress_zero_inches: - if imperial_unit == "foot": - return f"{round(value)}'" - elif not suppress_zero_inches: - if imperial_unit == "foot": - return f"{round(value)}' - 0\"" - if frac.numerator > frac.denominator and not frac.denominator == 0: + if suppress_zero_inches and frac.numerator == 0: + return f"{feet}'" + return f"{feet}' - {frac.numerator}\"" + if frac.numerator > frac.denominator: remainder = frac.numerator % frac.denominator whole = int((frac.numerator - remainder) / frac.denominator) - if imperial_unit == "foot": - return f"{feet}' - {whole} {remainder}/{frac.denominator}\"" - elif imperial_unit == "inch": - return f"{whole} {remainder}/{frac.denominator}\"" - + return f"{feet}' - {whole} {remainder}/{frac.denominator}\"" + return f"{feet}' - {frac.numerator}/{frac.denominator}\"" elif unit_system == "metric": rounded_val = round(value / precision) * precision return f"{rounded_val:.{decimal_places}f}" From 3593b6b5c2f37dd2f8cff8f0383923139a5c11f7 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Wed, 6 Sep 2023 20:13:55 -0500 Subject: [PATCH 022/190] Update ifc_classes_suggestions.json --- .../util/schema/ifc_classes_suggestions.json | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/ifcopenshell-python/ifcopenshell/util/schema/ifc_classes_suggestions.json b/src/ifcopenshell-python/ifcopenshell/util/schema/ifc_classes_suggestions.json index 562e92fa49..878fef47dd 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/schema/ifc_classes_suggestions.json +++ b/src/ifcopenshell-python/ifcopenshell/util/schema/ifc_classes_suggestions.json @@ -55,6 +55,28 @@ "name": "Signage" } ], + "IfcGeographicElement": [ + { + "name": "Tree" + }, + { + "name": "Plants" + }, + { + "name": "Shrubs" + } + ], + "IfcGeographicElementType": [ + { + "name": "Tree" + }, + { + "name": "Plants" + }, + { + "name": "Shrubs" + } + ], "IfcPlate": [ { "name": "Glazing" @@ -114,4 +136,4 @@ "name": "Pane" } ] -} \ No newline at end of file +} From 306ee3dfa4e2700800ea08f3212543fc9b0f80b9 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 7 Sep 2023 12:18:32 +1000 Subject: [PATCH 023/190] You can now choose both input and output units for imperial formatting --- .../ifcopenshell/util/selector.py | 16 +++-- .../ifcopenshell/util/unit.py | 37 ++++++++---- .../test/util/test_selector.py | 3 +- .../test/util/test_unit.py | 59 +++++++++++++++++++ 4 files changed, 95 insertions(+), 20 deletions(-) create mode 100644 src/ifcopenshell-python/test/util/test_unit.py diff --git a/src/ifcopenshell-python/ifcopenshell/util/selector.py b/src/ifcopenshell-python/ifcopenshell/util/selector.py index b0c2b2fb7e..46fcd6c9e8 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/selector.py +++ b/src/ifcopenshell-python/ifcopenshell/util/selector.py @@ -120,7 +120,7 @@ format_grammar = lark.Lark( round: "round(" function "," NUMBER ")" format_length: metric_length | imperial_length metric_length: "metric_length(" function "," NUMBER "," NUMBER ")" - imperial_length: "imperial_length(" function "," NUMBER ["," ESCAPED_STRING] ")" + imperial_length: "imperial_length(" function "," NUMBER ["," ESCAPED_STRING "," ESCAPED_STRING] ")" lower: "lower(" function ")" upper: "upper(" function ")" title: "title(" function ")" @@ -195,17 +195,15 @@ class FormatTransformer(lark.Transformer): def imperial_length(self, args): if len(args) == 2: - imperial_unit = "foot" + input_unit = "foot" value, precision = args else: - value, precision, imperial_unit = args - if imperial_unit == "inch": - imperial_unit = "inch" - else: - imperial_unit = "foot" + value, precision, input_unit, output_unit = args + input_unit = "inch" if input_unit == "inch" else "foot" + output_unit = "inch" if output_unit == "inch" else "foot" return ifcopenshell.util.unit.format_length( - float(value), int(precision), unit_system="imperial", imperial_unit=imperial_unit + float(value), int(precision), unit_system="imperial", input_unit=input_unit, output_unit=output_unit ) @@ -821,7 +819,7 @@ class Selector: key = "LayerSetName" # This oddity in the IFC spec is annoying so we account for it. if isinstance(key, re.Pattern): - attribute = None # Should we support regex attributes? Probably not for now. + attribute = None # Should we support regex attributes? Probably not for now. else: attribute = getattr(value, key, None) diff --git a/src/ifcopenshell-python/ifcopenshell/util/unit.py b/src/ifcopenshell-python/ifcopenshell/util/unit.py index d9c3c4259b..43eae4fd18 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/unit.py +++ b/src/ifcopenshell-python/ifcopenshell/util/unit.py @@ -548,12 +548,18 @@ def calculate_unit_scale(ifc_file): def format_length( - value, precision, decimal_places=2, suppress_zero_inches=True, unit_system="imperial", imperial_unit="foot" + value, + precision, + decimal_places=2, + suppress_zero_inches=True, + unit_system="imperial", + input_unit="foot", + output_unit="foot", ): """Formats a length for readability and imperial formatting :param value: The value in meters if metric, or either decimal feet or - inches if imperial depending on imperial_unit. + inches if imperial depending on input_unit. :type value: float :param precision: How precise the format should be. I.e. round to nearest. For imperial, it is 1/Nth. E.g. 12 means to the nearest 1/12th of an @@ -566,15 +572,18 @@ def format_length( :type suppress_zero_inches: bool :param unit_system: Choose whether your value is "metric" or "imperial" :type unit_system: str - :param imperial_unit: If imperial, specify whether your value is "foot" or + :param input_unit: If imperial, specify whether your value is "foot" or "inch". - :type imperial_unit: str + :type input_unit: str + :param output_unit: If imperial, specify whether your value is "foot" to + format as both feet and inches, or "inch" if only inches should be + shown. """ if unit_system == "imperial": - if imperial_unit == "foot": + if input_unit == "foot": feet = int(value) inches = (value - feet) * 12 - elif imperial_unit == "inch": + elif input_unit == "inch": inches = value % 12 feet = int(round((value - inches) / 12)) @@ -587,13 +596,21 @@ def format_length( # If fraction is a whole number, format it accordingly if frac.denominator == 1: if suppress_zero_inches and frac.numerator == 0: - return f"{feet}'" - return f"{feet}' - {frac.numerator}\"" + if output_unit == "foot": + return f"{feet}'" + return f'{feet * 12}"' + if output_unit == "foot": + return f"{feet}' - {frac.numerator}\"" + return f'{(feet * 12) + frac.numerator}"' if frac.numerator > frac.denominator: remainder = frac.numerator % frac.denominator whole = int((frac.numerator - remainder) / frac.denominator) - return f"{feet}' - {whole} {remainder}/{frac.denominator}\"" - return f"{feet}' - {frac.numerator}/{frac.denominator}\"" + if output_unit == "foot": + return f"{feet}' - {whole} {remainder}/{frac.denominator}\"" + return f'{(feet * 12) + whole} {remainder}/{frac.denominator}"' + if output_unit == "foot": + return f"{feet}' - {frac.numerator}/{frac.denominator}\"" + return f'{feet * 12} {frac.numerator}/{frac.denominator}"' elif unit_system == "metric": rounded_val = round(value / precision) * precision return f"{rounded_val:.{decimal_places}f}" diff --git a/src/ifcopenshell-python/test/util/test_selector.py b/src/ifcopenshell-python/test/util/test_selector.py index ebb2528f15..4fd4972f75 100644 --- a/src/ifcopenshell-python/test/util/test_selector.py +++ b/src/ifcopenshell-python/test/util/test_selector.py @@ -45,7 +45,8 @@ class TestFormat(): assert subject.format('imperial_length(3.123, 1)') == "3' - 1\"" assert subject.format('imperial_length(3.123, 2)') == "3' - 1 1/2\"" assert subject.format('imperial_length(\"3.123\", 2)') == "3' - 1 1/2\"" - assert subject.format('imperial_length(\"123.123\", 2, \"inch\")') == "10' - 3\"" + assert subject.format('imperial_length(\"123.123\", 2, \"inch\", \"foot\")') == "10' - 3\"" + assert subject.format('imperial_length(\"123.123\", 2, \"inch\", \"inch\")') == "123\"" class TestGetElementValue(test.bootstrap.IFC4): diff --git a/src/ifcopenshell-python/test/util/test_unit.py b/src/ifcopenshell-python/test/util/test_unit.py new file mode 100644 index 0000000000..cdf028a222 --- /dev/null +++ b/src/ifcopenshell-python/test/util/test_unit.py @@ -0,0 +1,59 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2023 Dion Moult +# +# 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 +import ifcopenshell.util.unit as subject + + +class TestFormatLength(test.bootstrap.IFC4): + def test_run(self): + assert subject.format_length(1, 1, decimal_places=0, unit_system="metric") == "1" + assert subject.format_length(1, 1, decimal_places=2, unit_system="metric") == "1.00" + assert subject.format_length(3, 5, decimal_places=2, unit_system="metric") == "5.00" + assert subject.format_length(3.123, 0.01, decimal_places=2, unit_system="metric") == "3.12" + + assert subject.format_length(3, 1, unit_system="imperial", input_unit="foot") == "3'" + assert subject.format_length(3.5, 1, unit_system="imperial", input_unit="foot") == "3' - 6\"" + assert subject.format_length(3.123, 1, unit_system="imperial", input_unit="foot") == "3' - 1\"" + assert subject.format_length(3.123, 2, unit_system="imperial", input_unit="foot") == "3' - 1 1/2\"" + assert subject.format_length(3.123, 4, unit_system="imperial", input_unit="foot") == "3' - 1 1/2\"" + assert subject.format_length(3.123, 32, unit_system="imperial", input_unit="foot") == "3' - 1 15/32\"" + assert subject.format_length(24, 1, unit_system="imperial", input_unit="inch") == "2'" + assert subject.format_length(25.23, 1, unit_system="imperial", input_unit="inch") == "2' - 1\"" + assert subject.format_length(25.23, 4, unit_system="imperial", input_unit="inch") == "2' - 1 1/4\"" + + assert subject.format_length(3, 1, unit_system="imperial", input_unit="foot", output_unit="inch") == '36"' + assert subject.format_length(3.5, 1, unit_system="imperial", input_unit="foot", output_unit="inch") == '42"' + assert subject.format_length(3.123, 1, unit_system="imperial", input_unit="foot", output_unit="inch") == '37"' + assert ( + subject.format_length(3.123, 2, unit_system="imperial", input_unit="foot", output_unit="inch") == '37 1/2"' + ) + assert ( + subject.format_length(3.123, 4, unit_system="imperial", input_unit="foot", output_unit="inch") == '37 1/2"' + ) + assert ( + subject.format_length(3.123, 32, unit_system="imperial", input_unit="foot", output_unit="inch") + == '37 15/32"' + ) + assert subject.format_length(24, 1, unit_system="imperial", input_unit="inch", output_unit="inch") == '24"' + assert subject.format_length(25.23, 1, unit_system="imperial", input_unit="inch", output_unit="inch") == '25"' + assert ( + subject.format_length(25.23, 4, unit_system="imperial", input_unit="inch", output_unit="inch") == '25 1/4"' + ) From de275540437c37d4f9ca905c74c58ee9a5dba925 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 7 Sep 2023 13:02:45 +1000 Subject: [PATCH 024/190] New substr formatting function. --- src/ifcopenshell-python/ifcopenshell/util/selector.py | 6 +++++- src/ifcopenshell-python/test/util/test_selector.py | 2 ++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/ifcopenshell-python/ifcopenshell/util/selector.py b/src/ifcopenshell-python/ifcopenshell/util/selector.py index 46fcd6c9e8..ea41323999 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/selector.py +++ b/src/ifcopenshell-python/ifcopenshell/util/selector.py @@ -115,7 +115,7 @@ get_element_grammar = lark.Lark( format_grammar = lark.Lark( """start: function - function: round | format_length | lower | upper | title | concat | ESCAPED_STRING | NUMBER + function: round | format_length | lower | upper | title | concat | substr | ESCAPED_STRING | NUMBER round: "round(" function "," NUMBER ")" format_length: metric_length | imperial_length @@ -125,6 +125,7 @@ format_grammar = lark.Lark( upper: "upper(" function ")" title: "title(" function ")" concat: "concat(" function ("," function)* ")" + substr: "substr(" function "," SIGNED_INT "," SIGNED_INT ")" // Embed common.lark for packaging DIGIT: "0".."9" @@ -181,6 +182,9 @@ class FormatTransformer(lark.Transformer): def concat(self, args): return "".join(args) + def substr(self, args): + return str(args[0])[int(args[1]):int(args[2])] + def round(self, args): return str(round(float(args[0]) / float(args[1])) * float(args[1])) diff --git a/src/ifcopenshell-python/test/util/test_selector.py b/src/ifcopenshell-python/test/util/test_selector.py index 4fd4972f75..d5306a84b3 100644 --- a/src/ifcopenshell-python/test/util/test_selector.py +++ b/src/ifcopenshell-python/test/util/test_selector.py @@ -34,6 +34,8 @@ class TestFormat(): assert subject.format('title(\"fOo\")') == "Foo" assert subject.format('concat(\"fOo\", \"bar\")') == "fOobar" assert subject.format('upper(concat(\"fOo\", \"bar\"))') == "FOOBAR" + assert subject.format('substr(\"foobar\", 1, 2)') == "o" + assert subject.format('substr(\"foobar\", 1, -1)') == "ooba" def test_number_formatting(self): assert subject.format("round(123, 5)") == "125.0" From 7bca3bcac30dc8605283520fdaf1c0036bea4051 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 7 Sep 2023 13:05:03 +1000 Subject: [PATCH 025/190] Drawing text annotation now use the new format functions instead of executing arbitrary code. --- src/blenderbim/blenderbim/tool/drawing.py | 5 +++-- src/ifccsv/ifccsv.py | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/blenderbim/blenderbim/tool/drawing.py b/src/blenderbim/blenderbim/tool/drawing.py index bea490ba72..f9965d42b2 100644 --- a/src/blenderbim/blenderbim/tool/drawing.py +++ b/src/blenderbim/blenderbim/tool/drawing.py @@ -1391,8 +1391,9 @@ class Drawing(blenderbim.core.tool.Drawing): original_command = command for variable in re.findall("{{.*?}}", command): value = ifcopenshell.util.selector.get_element_value(product, variable[2:-2]) - command = command.replace(variable, repr(value)) - text = text.replace(original_command, str(eval(command[2:-2]))) + value = '"' + str(value).replace('"', '\\"') + '"' + command = command.replace(variable, value) + text = text.replace(original_command, ifcopenshell.util.selector.format(command[2:-2])) for variable in re.findall("{{.*?}}", text): value = ifcopenshell.util.selector.get_element_value(product, variable[2:-2]) diff --git a/src/ifccsv/ifccsv.py b/src/ifccsv/ifccsv.py index 1f72bf6c54..08ef8c6e5e 100755 --- a/src/ifccsv/ifccsv.py +++ b/src/ifccsv/ifccsv.py @@ -238,7 +238,7 @@ class IfcCsv: if row[index] == null: continue if not isinstance(row[index], str): - row[index] = '"' + str(row[index]) + '"' + row[index] = '"' + str(row[index]).replace('"', '\\"') + '"' row[index] = ifcopenshell.util.selector.format(format_query.replace("{{value}}", row[index])) def sort_results(self, sort, attributes, include_global_id): From 9a34b945680e7cc5179b91157e72bd8c07370ecc Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 7 Sep 2023 13:47:54 +1000 Subject: [PATCH 026/190] Fix #3682. You can now tag setout points with dynamic xyz / enh values. --- .../blenderbim/bim/data/assets/symbols.svg | 7 + .../bim/data/libraries/IFC4 Demo Library.ifc | 287 ++++++++++-------- .../bim/module/drawing/svgwriter.py | 7 +- src/blenderbim/blenderbim/tool/drawing.py | 2 +- .../ifcopenshell/util/geolocation.py | 28 ++ .../ifcopenshell/util/selector.py | 15 + 6 files changed, 209 insertions(+), 137 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/data/assets/symbols.svg b/src/blenderbim/blenderbim/bim/data/assets/symbols.svg index 5aca6a35eb..4ae888d5e5 100644 --- a/src/blenderbim/blenderbim/bim/data/assets/symbols.svg +++ b/src/blenderbim/blenderbim/bim/data/assets/symbols.svg @@ -47,6 +47,13 @@ + + + + + + + diff --git a/src/blenderbim/blenderbim/bim/data/libraries/IFC4 Demo Library.ifc b/src/blenderbim/blenderbim/bim/data/libraries/IFC4 Demo Library.ifc index 190b13d9a4..7bec97bf36 100644 --- a/src/blenderbim/blenderbim/bim/data/libraries/IFC4 Demo Library.ifc +++ b/src/blenderbim/blenderbim/bim/data/libraries/IFC4 Demo Library.ifc @@ -1,17 +1,17 @@ ISO-10303-21; HEADER; FILE_DESCRIPTION(('ViewDefinition[DesignTransferView]'),'2;1'); -FILE_NAME('/dev/null','2023-09-02T16:09:02+10:00',(),(),'IfcOpenShell v0.7.0-fbd8ea1ed','IfcOpenShell v0.7.0-fbd8ea1ed','Nobody'); +FILE_NAME('/dev/null','2023-09-07T13:40:06+10:00',(),(),'IfcOpenShell v0.7.0-fbd8ea1ed','IfcOpenShell v0.7.0-fbd8ea1ed','Nobody'); FILE_SCHEMA(('IFC4')); ENDSEC; DATA; -#1=IFCPROJECT('1UlLTVwDzDvR$QNEAxFx6F',$,'BlenderBIM Demo',$,$,$,$,(#12,#16),#7); -#2=IFCPROJECTLIBRARY('3QN02XEh1FQh0fii364VEi',$,'BlenderBIM Demo Library',$,$,$,$,$,$); -#3=IFCRELDECLARES('0aiEyU5lL7mAdB5xwdXbUN',$,$,$,#1,(#2)); +#1=IFCPROJECT('1arVsMni92iPUaaryGTUz2',$,'BlenderBIM Demo',$,$,$,$,(#12,#16),#7); +#2=IFCPROJECTLIBRARY('1FfDG5b21DuBDOO4eUE9rF',$,'BlenderBIM Demo Library',$,$,$,$,$,$); +#3=IFCRELDECLARES('3tfDIC3Tf9wP8EUOoFeycK',$,$,$,#1,(#2)); #4=IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.); #5=IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.); #6=IFCSIUNIT(*,.VOLUMEUNIT.,$,.CUBIC_METRE.); -#7=IFCUNITASSIGNMENT((#6,#4,#5)); +#7=IFCUNITASSIGNMENT((#5,#6,#4)); #8=IFCCARTESIANPOINT((0.,0.,0.)); #9=IFCDIRECTION((0.,0.,1.)); #10=IFCDIRECTION((1.,0.,0.)); @@ -25,82 +25,82 @@ DATA; #18=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Body','Plan',*,*,*,*,#16,$,.PLAN_VIEW.,$); #19=IFCGEOMETRICREPRESENTATIONSUBCONTEXT('Annotation','Plan',*,*,*,*,#16,$,.PLAN_VIEW.,$); #20=IFCMATERIAL('Unknown',$,$); -#21=IFCWALLTYPE('0Ir6HewafB2hnPRigxrUnM',$,'WAL50',$,$,$,$,$,$,.NOTDEFINED.); +#21=IFCWALLTYPE('2o8NqQQbHA5BeErrgwfr7d',$,'WAL50',$,$,$,$,$,$,.NOTDEFINED.); #22=IFCMATERIALLAYERSET((#24),$,$); -#23=IFCRELASSOCIATESMATERIAL('3S$YbWQCnDCA2tmoikSnrh',$,$,$,(#21),#22); +#23=IFCRELASSOCIATESMATERIAL('1Q$5DZKr98n82Eitz2dWey',$,$,$,(#21),#22); #24=IFCMATERIALLAYER(#20,0.05,$,$,$,$,$); -#25=IFCRELDECLARES('1eLCJqssP2kvQIdmPlRK19',$,$,$,#2,(#38,#82,#77,#42,#26,#48,#72,#59,#92,#30,#96,#63,#54,#87,#153,#34,#67,#21,#210)); -#26=IFCWALLTYPE('2oUFJayonDlBTcbJtxYFSv',$,'WAL100',$,$,$,$,$,$,.NOTDEFINED.); +#25=IFCRELDECLARES('1KXKUEn8z0g8lvyEsCTWD3',$,$,$,#2,(#38,#82,#77,#42,#26,#48,#72,#59,#92,#30,#96,#63,#21,#54,#87,#153,#34,#67,#210)); +#26=IFCWALLTYPE('3KlIOv_P9A79tkt8D_jq_m',$,'WAL100',$,$,$,$,$,$,.NOTDEFINED.); #27=IFCMATERIALLAYERSET((#29),$,$); -#28=IFCRELASSOCIATESMATERIAL('0n3vPKxufDgO48f4yfGtUd',$,$,$,(#26),#27); +#28=IFCRELASSOCIATESMATERIAL('32B9Wx8Z1FxhIN7m77MiHG',$,$,$,(#26),#27); #29=IFCMATERIALLAYER(#20,0.1,$,$,$,$,$); -#30=IFCWALLTYPE('0UDhWxnIXDMOxZ2yPDCUMP',$,'WAL200',$,$,$,$,$,$,.NOTDEFINED.); +#30=IFCWALLTYPE('2DqrSeel1AGw6h3b$ujafZ',$,'WAL200',$,$,$,$,$,$,.NOTDEFINED.); #31=IFCMATERIALLAYERSET((#33),$,$); -#32=IFCRELASSOCIATESMATERIAL('2Boxz3uaP0LOy6luKau2R0',$,$,$,(#30),#31); +#32=IFCRELASSOCIATESMATERIAL('2FeYUpt3D4tAvwgGmwXWZn',$,$,$,(#30),#31); #33=IFCMATERIALLAYER(#20,0.2,$,$,$,$,$); -#34=IFCWALLTYPE('20fPutWQrFyeMIiKxc$MkU',$,'WAL300',$,$,$,$,$,$,.NOTDEFINED.); +#34=IFCWALLTYPE('2jNdEOFIP1eQb3WZePlFGY',$,'WAL300',$,$,$,$,$,$,.NOTDEFINED.); #35=IFCMATERIALLAYERSET((#37),$,$); -#36=IFCRELASSOCIATESMATERIAL('2qAjlTTxjBkvdp80A9XdMZ',$,$,$,(#34),#35); +#36=IFCRELASSOCIATESMATERIAL('2bFeM6xevAwucuwMlauX77',$,$,$,(#34),#35); #37=IFCMATERIALLAYER(#20,0.3,$,$,$,$,$); -#38=IFCCOVERINGTYPE('100hdtcxzBdQATomAi0tv5',$,'COV10',$,$,$,$,$,$,.NOTDEFINED.); +#38=IFCCOVERINGTYPE('3310gCgH59w9tu$DfmiCEN',$,'COV10',$,$,$,$,$,$,.NOTDEFINED.); #39=IFCMATERIALLAYERSET((#41),$,$); -#40=IFCRELASSOCIATESMATERIAL('3tO1xRn9b06utMa1TZC$WW',$,$,$,(#38),#39); +#40=IFCRELASSOCIATESMATERIAL('3pTNmWd_jBoOWyHc8Yf4qI',$,$,$,(#38),#39); #41=IFCMATERIALLAYER(#20,0.01,$,$,$,$,$); -#42=IFCCOVERINGTYPE('1sO51oul95ABWezHr7P7Dk',$,'COV20',$,$,(#46),$,$,$,.NOTDEFINED.); +#42=IFCCOVERINGTYPE('2sksWFuPzCgeNk1ofXS6PZ',$,'COV20',$,$,(#46),$,$,$,.NOTDEFINED.); #43=IFCMATERIALLAYERSET((#45),$,$); -#44=IFCRELASSOCIATESMATERIAL('0w6lexCg19TuiqqTxo91zc',$,$,$,(#42),#43); +#44=IFCRELASSOCIATESMATERIAL('36rl5NddT1ux6ejkQAkT2z',$,$,$,(#42),#43); #45=IFCMATERIALLAYER(#20,0.02,$,$,$,$,$); -#46=IFCPROPERTYSET('3ytgFxH0571uBCG4mBNXI9',$,'EPset_Parametric',$,(#47)); +#46=IFCPROPERTYSET('2VZJ9xfdn1NeOY6uEmnnYW',$,'EPset_Parametric',$,(#47)); #47=IFCPROPERTYSINGLEVALUE('LayerSetDirection',$,IFCLABEL('AXIS2'),$); -#48=IFCCOVERINGTYPE('09GKC1aO56qwEPF9halcxJ',$,'COV30',$,$,(#52),$,$,$,.NOTDEFINED.); +#48=IFCCOVERINGTYPE('1uU6rxt95AC91lZ0jTRG6G',$,'COV30',$,$,(#52),$,$,$,.NOTDEFINED.); #49=IFCMATERIALLAYERSET((#51),$,$); -#50=IFCRELASSOCIATESMATERIAL('0rDrnpiX58DgQhcURj_WlB',$,$,$,(#48),#49); +#50=IFCRELASSOCIATESMATERIAL('2lFv4xQYr5bwtiSsMTVCgz',$,$,$,(#48),#49); #51=IFCMATERIALLAYER(#20,0.03,$,$,$,$,$); -#52=IFCPROPERTYSET('14u6OUyPL5y8O$QkEnB6Rr',$,'EPset_Parametric',$,(#53)); +#52=IFCPROPERTYSET('0HnrRhcd16Yfbgtci4sqCG',$,'EPset_Parametric',$,(#53)); #53=IFCPROPERTYSINGLEVALUE('LayerSetDirection',$,IFCLABEL('AXIS3'),$); -#54=IFCRAMPTYPE('2G4JrZ99j2B9SkIwWDk0sC',$,'RAM200',$,$,$,$,$,$,.NOTDEFINED.); +#54=IFCRAMPTYPE('3nonXCVn58jhVV08N8c97B',$,'RAM200',$,$,$,$,$,$,.NOTDEFINED.); #55=IFCMATERIALLAYERSET((#57),$,$); -#56=IFCRELASSOCIATESMATERIAL('2nNlFzNPj0_h6_NwdVJvW$',$,$,$,(#54),#55); +#56=IFCRELASSOCIATESMATERIAL('2NdIrmbUP6bh4x$p_RY31C',$,$,$,(#54),#55); #57=IFCMATERIALLAYER(#20,0.2,$,$,$,$,$); #58=IFCCIRCLEPROFILEDEF(.AREA.,$,$,0.3); -#59=IFCPILETYPE('3uIc5iHjP5Lel3X6KXyN8W',$,'P1',$,$,$,$,$,$,.NOTDEFINED.); +#59=IFCPILETYPE('2uuf5kq5TDDfO9jGN7XIHh',$,'P1',$,$,$,$,$,$,.NOTDEFINED.); #60=IFCMATERIALPROFILESET($,$,(#62),$); -#61=IFCRELASSOCIATESMATERIAL('13buLvs1r5vAyhgbabb2g1',$,$,$,(#59),#60); +#61=IFCRELASSOCIATESMATERIAL('3hi821Ffj8YR9yFjp226LU',$,$,$,(#59),#60); #62=IFCMATERIALPROFILE($,$,#20,#58,$,$); -#63=IFCSLABTYPE('2CbahfxvnFpPoHyMBpGTQm',$,'FLR150',$,$,$,$,$,$,.NOTDEFINED.); +#63=IFCSLABTYPE('2janmz3nH4P9IHaOVvGY_B',$,'FLR150',$,$,$,$,$,$,.NOTDEFINED.); #64=IFCMATERIALLAYERSET((#66),$,$); -#65=IFCRELASSOCIATESMATERIAL('2Irb7$9xL8YOXW99sF0SOp',$,$,$,(#63),#64); +#65=IFCRELASSOCIATESMATERIAL('0og5Zg4ib67g6XWgOgqBWy',$,$,$,(#63),#64); #66=IFCMATERIALLAYER(#20,0.2,$,$,$,$,$); -#67=IFCSLABTYPE('04dx2Vg4vEp9ZOKGI1ifWb',$,'FLR250',$,$,$,$,$,$,.NOTDEFINED.); +#67=IFCSLABTYPE('26UKpEaTb9fh4qrqi3Ymzj',$,'FLR250',$,$,$,$,$,$,.NOTDEFINED.); #68=IFCMATERIALLAYERSET((#70),$,$); -#69=IFCRELASSOCIATESMATERIAL('04tl3Me6T6xOb$WEvv_dL0',$,$,$,(#67),#68); +#69=IFCRELASSOCIATESMATERIAL('1BeBXPqen9TQEE42SxzhjF',$,$,$,(#67),#68); #70=IFCMATERIALLAYER(#20,0.3,$,$,$,$,$); #71=IFCRECTANGLEPROFILEDEF(.AREA.,'500x600',$,0.5,0.6); -#72=IFCCOLUMNTYPE('13rk_6wmDApeVQWfrArKbl',$,'C1',$,$,$,$,$,$,.NOTDEFINED.); +#72=IFCCOLUMNTYPE('1KnWzBBoLCNfJ3_J79LT7d',$,'C1',$,$,$,$,$,$,.NOTDEFINED.); #73=IFCMATERIALPROFILESET($,$,(#75),$); -#74=IFCRELASSOCIATESMATERIAL('0X3OUScT9E1BjOvJWiUrv3',$,$,$,(#72),#73); +#74=IFCRELASSOCIATESMATERIAL('1W8OWQjEL05gH5hEpve6Lh',$,$,$,(#72),#73); #75=IFCMATERIALPROFILE($,$,#20,#71,$,$); #76=IFCCIRCLEHOLLOWPROFILEDEF(.AREA.,'500.0x5.0 CHS',$,0.25,0.005); -#77=IFCCOLUMNTYPE('0IaS5T1Uv6DQu$BJ5I3bks',$,'C2',$,$,$,$,$,$,.NOTDEFINED.); +#77=IFCCOLUMNTYPE('33jPFERfL9bfTeWfTL5kZ7',$,'C2',$,$,$,$,$,$,.NOTDEFINED.); #78=IFCMATERIALPROFILESET($,$,(#80),$); -#79=IFCRELASSOCIATESMATERIAL('1HWcDe7a17WwPeKCHnckid',$,$,$,(#77),#78); +#79=IFCRELASSOCIATESMATERIAL('3czOrQw2v9BfS0PeQCrzWq',$,$,$,(#77),#78); #80=IFCMATERIALPROFILE($,$,#20,#76,$,$); #81=IFCRECTANGLEHOLLOWPROFILEDEF(.AREA.,'150x75x2.0 RHS',$,0.075,0.15,0.002,0.005,0.005); -#82=IFCCOLUMNTYPE('13p6$J2Jv84RFI5QAp6FTQ',$,'C3',$,$,$,$,$,$,.NOTDEFINED.); +#82=IFCCOLUMNTYPE('21YcNdKNj2$95U55yNx1Nw',$,'C3',$,$,$,$,$,$,.NOTDEFINED.); #83=IFCMATERIALPROFILESET($,$,(#85),$); -#84=IFCRELASSOCIATESMATERIAL('1GWCzHY657$9kUJrD0AEir',$,$,$,(#82),#83); +#84=IFCRELASSOCIATESMATERIAL('3n6Cru3xr6jvDptU9u7QEF',$,$,$,(#82),#83); #85=IFCMATERIALPROFILE($,$,#20,#81,$,$); #86=IFCISHAPEPROFILEDEF(.AREA.,'DEMO-I',$,0.1,0.2,0.005,0.01,0.005,$,$); -#87=IFCBEAMTYPE('1Y_PtOyJP9YfZkU59r$vs1',$,'B1',$,$,$,$,$,$,.NOTDEFINED.); +#87=IFCBEAMTYPE('2CsGpD$6nDCxWv9jXTtvq9',$,'B1',$,$,$,$,$,$,.NOTDEFINED.); #88=IFCMATERIALPROFILESET($,$,(#90),$); -#89=IFCRELASSOCIATESMATERIAL('2CfmfKJ0X45xijxsDUHZ85',$,$,$,(#87),#88); +#89=IFCRELASSOCIATESMATERIAL('2LJExc74j9rgBSBx51HDPK',$,$,$,(#87),#88); #90=IFCMATERIALPROFILE($,$,#20,#86,$,$); #91=IFCCSHAPEPROFILEDEF(.AREA.,'DEMO-C',$,0.2,0.1,0.0015,0.03,0.005); -#92=IFCBEAMTYPE('2qogXGNrb9EANilnZjnGjq',$,'B2',$,$,$,$,$,$,.NOTDEFINED.); +#92=IFCBEAMTYPE('32pHN2b0P0awGw$U8PpneO',$,'B2',$,$,$,$,$,$,.NOTDEFINED.); #93=IFCMATERIALPROFILESET($,$,(#95),$); -#94=IFCRELASSOCIATESMATERIAL('3$1pGIrVLA2RTOmJIsbelu',$,$,$,(#92),#93); +#94=IFCRELASSOCIATESMATERIAL('2wg8R1Drb2xhQ2Y_pRSh3c',$,$,$,(#92),#93); #95=IFCMATERIALPROFILE($,$,#20,#91,$,$); -#96=IFCWINDOWTYPE('1Dc9pAExL3fxL9jScEo2EF',$,'WT01',$,$,$,(#135,#152),$,$,.NOTDEFINED.,.NOTDEFINED.,$,$); +#96=IFCWINDOWTYPE('1Gg9HfZEr69u8SdWfDs2J3',$,'WT01',$,$,$,(#135,#152),$,$,.NOTDEFINED.,.NOTDEFINED.,$,$); #97=IFCINDEXEDPOLYGONALFACE((13,17,18,14)); #98=IFCINDEXEDPOLYGONALFACE((5,6,3,4)); #99=IFCINDEXEDPOLYGONALFACE((7,8,2,1)); @@ -157,7 +157,7 @@ DATA; #150=IFCDIRECTION((0.,0.,1.)); #151=IFCAXIS2PLACEMENT3D(#148,#150,#149); #152=IFCREPRESENTATIONMAP(#151,#147); -#153=IFCDOORTYPE('05Z1DhJjj11wczPRkvXSPE',$,'DT01',$,$,$,(#196,#209),$,$,.NOTDEFINED.,.NOTDEFINED.,$,$); +#153=IFCDOORTYPE('0NBUmPKyT9WecsIeYJrEqg',$,'DT01',$,$,$,(#196,#209),$,$,.NOTDEFINED.,.NOTDEFINED.,$,$); #154=IFCINDEXEDPOLYGONALFACE((17,16,15,14)); #155=IFCINDEXEDPOLYGONALFACE((2,3,29,28)); #156=IFCINDEXEDPOLYGONALFACE((27,28,29,30,32,31)); @@ -214,7 +214,7 @@ DATA; #207=IFCDIRECTION((0.,0.,1.)); #208=IFCAXIS2PLACEMENT3D(#205,#207,#206); #209=IFCREPRESENTATIONMAP(#208,#204); -#210=IFCFURNITURETYPE('3ZMmSB_rfBnhrBckPTFKvP',$,'BUN01',$,$,$,(#931,#952),$,$,.NOTDEFINED.,.NOTDEFINED.); +#210=IFCFURNITURETYPE('3Kyc6IyarAUw3_8fkNtXIg',$,'BUN01',$,$,$,(#931,#952),$,$,.NOTDEFINED.,.NOTDEFINED.); #211=IFCINDEXEDPOLYGONALFACE((187,278,44)); #212=IFCINDEXEDPOLYGONALFACE((21,52,60)); #213=IFCINDEXEDPOLYGONALFACE((91,100,31)); @@ -957,140 +957,161 @@ DATA; #950=IFCDIRECTION((0.,0.,1.)); #951=IFCAXIS2PLACEMENT3D(#948,#950,#949); #952=IFCREPRESENTATIONMAP(#951,#947); -#953=IFCTYPEPRODUCT('1hmWolVQb9buX2GhHwaVFB',$,'SETOUT-POINT',$,'IfcAnnotation/SYMBOL',(#954),$,$); -#954=IFCPROPERTYSET('2YNCFr62b8vhuhHGspQl4$',$,'EPset_Annotation',$,(#955)); +#953=IFCTYPEPRODUCT('0TBMBnD_b66QLUWKD9HLsc',$,'SETOUT-POINT',$,'IfcAnnotation/SYMBOL',(#954),$,$); +#954=IFCPROPERTYSET('2dQzX_K4r1Xet6dz9zBlgs',$,'EPset_Annotation',$,(#955)); #955=IFCPROPERTYSINGLEVALUE('Symbol',$,IFCLABEL('setout-point'),$); -#956=IFCTYPEPRODUCT('3H4oE1a_9AwQE8OFsCjdfh',$,'CONTROL-POINT',$,'IfcAnnotation/SYMBOL',(#957),$,$); -#957=IFCPROPERTYSET('0ARWSOH$z3kQDRkpEPHWCt',$,'EPset_Annotation',$,(#958)); +#956=IFCTYPEPRODUCT('0vo_7PU3H9ygTnrIipqPRI',$,'CONTROL-POINT',$,'IfcAnnotation/SYMBOL',(#957),$,$); +#957=IFCPROPERTYSET('3APQOw$FP1ivxp08UxHsl6',$,'EPset_Annotation',$,(#958)); #958=IFCPROPERTYSINGLEVALUE('Symbol',$,IFCLABEL('control-point'),$); -#959=IFCTYPEPRODUCT('3YDPnyrWn2wRWgRzb0V7Kx',$,'TRAVERSE-POINT',$,'IfcAnnotation/SYMBOL',(#960),$,$); -#960=IFCPROPERTYSET('1v$MzHU1b82hDqHdtz4e5y',$,'EPset_Annotation',$,(#961)); +#959=IFCTYPEPRODUCT('32V27G3$T1OO3TbXg6948F',$,'TRAVERSE-POINT',$,'IfcAnnotation/SYMBOL',(#960),$,$); +#960=IFCPROPERTYSET('0cTSYXMc9B$PTXVDrhBYYW',$,'EPset_Annotation',$,(#961)); #961=IFCPROPERTYSINGLEVALUE('Symbol',$,IFCLABEL('traverse-point'),$); -#962=IFCTYPEPRODUCT('31Em8$VNL0Mh$9EZ3pXjpL',$,'DASHED',$,'IfcAnnotation/LINEWORK',(#963),$,$); -#963=IFCPROPERTYSET('0K84$piRb31PmkAwZfP5kd',$,'EPset_Annotation',$,(#964)); +#962=IFCTYPEPRODUCT('2PXIC7Bg914gJhRh2XeGnN',$,'DASHED',$,'IfcAnnotation/LINEWORK',(#963),$,$); +#963=IFCPROPERTYSET('0RiYsOxp529gXnCkw44wF8',$,'EPset_Annotation',$,(#964)); #964=IFCPROPERTYSINGLEVALUE('Classes',$,IFCLABEL('dashed'),$); -#965=IFCTYPEPRODUCT('3P0K0gDxP4NgwnuLGqlyW6',$,'FINE',$,'IfcAnnotation/LINEWORK',(#966),$,$); -#966=IFCPROPERTYSET('179vwKcVv9uet4_kI4NT9G',$,'EPset_Annotation',$,(#967)); +#965=IFCTYPEPRODUCT('1Ou1kA3Vb4HhuNBvM0uGe0',$,'FINE',$,'IfcAnnotation/LINEWORK',(#966),$,$); +#966=IFCPROPERTYSET('2gzly9D0L1qPK2MExeXgRO',$,'EPset_Annotation',$,(#967)); #967=IFCPROPERTYSINGLEVALUE('Classes',$,IFCLABEL('fine'),$); -#968=IFCTYPEPRODUCT('0Rh30iszn41xBCgQJl2w2c',$,'THIN',$,'IfcAnnotation/LINEWORK',(#969),$,$); -#969=IFCPROPERTYSET('0bZV_Tby121uj5O3n08l1l',$,'EPset_Annotation',$,(#970)); +#968=IFCTYPEPRODUCT('3lx$KQPRbEZwbQ7Xdfm5gw',$,'THIN',$,'IfcAnnotation/LINEWORK',(#969),$,$); +#969=IFCPROPERTYSET('0vsVpqs6zArvA2bfBtvQew',$,'EPset_Annotation',$,(#970)); #970=IFCPROPERTYSINGLEVALUE('Classes',$,IFCLABEL('thin'),$); -#971=IFCTYPEPRODUCT('3ti_6xztr7qPp9dASz9vP4',$,'MEDIUM',$,'IfcAnnotation/LINEWORK',(#972),$,$); -#972=IFCPROPERTYSET('19ZhF3bxL07evL8gi27Kei',$,'EPset_Annotation',$,(#973)); +#971=IFCTYPEPRODUCT('00j$y97p903w2HOb35lAQ2',$,'MEDIUM',$,'IfcAnnotation/LINEWORK',(#972),$,$); +#972=IFCPROPERTYSET('240rEBDOn8PAvfnm_43s55',$,'EPset_Annotation',$,(#973)); #973=IFCPROPERTYSINGLEVALUE('Classes',$,IFCLABEL('medium'),$); -#974=IFCTYPEPRODUCT('2hlcxjzxv8zf0gh1md23xY',$,'THICK',$,'IfcAnnotation/LINEWORK',(#975),$,$); -#975=IFCPROPERTYSET('0wRtpbInzE2AkqX8RIreQr',$,'EPset_Annotation',$,(#976)); +#974=IFCTYPEPRODUCT('2tVdFGorj6dfrL4uA1kyW8',$,'THICK',$,'IfcAnnotation/LINEWORK',(#975),$,$); +#975=IFCPROPERTYSET('2IEGHncr1D$R8fKA9zCEJP',$,'EPset_Annotation',$,(#976)); #976=IFCPROPERTYSINGLEVALUE('Classes',$,IFCLABEL('thick'),$); -#977=IFCTYPEPRODUCT('1pefU$PA1ENw7kZG9T3mjr',$,'STRONG',$,'IfcAnnotation/LINEWORK',(#978),$,$); -#978=IFCPROPERTYSET('1RpJZBysn8shqevvAquPQ8',$,'EPset_Annotation',$,(#979)); +#977=IFCTYPEPRODUCT('1T5C$$ONTBB8A9a7vv0Gn6',$,'STRONG',$,'IfcAnnotation/LINEWORK',(#978),$,$); +#978=IFCPROPERTYSET('1gRomAS1L2WguA51ZI0E40',$,'EPset_Annotation',$,(#979)); #979=IFCPROPERTYSINGLEVALUE('Classes',$,IFCLABEL('strong'),$); -#980=IFCTYPEPRODUCT('1HKC8zCIv5_ef3dEfa$L5N',$,'DOOR-TAG',$,'IfcAnnotation/TEXT',(#981),(#1000),$); -#981=IFCPROPERTYSET('0aC8JrqFX6HhKUhjdLw4mN',$,'EPset_Annotation',$,(#982)); -#982=IFCPROPERTYSINGLEVALUE('Symbol',$,IFCLABEL('door-tag'),$); +#980=IFCTYPEPRODUCT('2LmKYrAEr2K8EwJyCAsyc4',$,'SETOUT-TAG',$,'IfcAnnotation/TEXT',(#981),(#1000),$); +#981=IFCPROPERTYSET('0nF9du8qDAF9aldqjCUsbX',$,'EPset_Annotation',$,(#982)); +#982=IFCPROPERTYSINGLEVALUE('Symbol',$,IFCLABEL('setout-tag'),$); #983=IFCCARTESIANPOINT((0.,0.,0.)); #984=IFCDIRECTION((0.,0.,1.)); #985=IFCDIRECTION((1.,0.,0.)); #986=IFCAXIS2PLACEMENT3D(#983,#984,#985); #987=IFCPLANAREXTENT(1000.,1000.); -#988=IFCTEXTLITERALWITHEXTENT('{{type.Name}}',#986,.RIGHT.,#987,'center'); +#988=IFCTEXTLITERALWITHEXTENT('E ``round({{easting}}, 0.001)``',#986,.RIGHT.,#987,'center'); #989=IFCCARTESIANPOINT((0.,0.,0.)); #990=IFCDIRECTION((0.,0.,1.)); #991=IFCDIRECTION((1.,0.,0.)); #992=IFCAXIS2PLACEMENT3D(#989,#990,#991); #993=IFCPLANAREXTENT(1000.,1000.); -#994=IFCTEXTLITERALWITHEXTENT('{{Name}}',#992,.RIGHT.,#993,'center'); +#994=IFCTEXTLITERALWITHEXTENT('N ``round({{northing}}, 0.001)``',#992,.RIGHT.,#993,'center'); #995=IFCSHAPEREPRESENTATION(#19,'Annotation','Annotation2D',(#988,#994)); #996=IFCCARTESIANPOINT((0.,0.,0.)); #997=IFCDIRECTION((1.,0.,0.)); #998=IFCDIRECTION((0.,0.,1.)); #999=IFCAXIS2PLACEMENT3D(#996,#998,#997); #1000=IFCREPRESENTATIONMAP(#999,#995); -#1001=IFCTYPEPRODUCT('2Jy_wl$FX8h9mgYT1$Y9oc',$,'WINDOW-TAG',$,'IfcAnnotation/TEXT',(#1002),(#1015),$); -#1002=IFCPROPERTYSET('2ncCyO$LPCzv7kqXZK_Omh',$,'EPset_Annotation',$,(#1003)); -#1003=IFCPROPERTYSINGLEVALUE('Symbol',$,IFCLABEL('window-tag'),$); +#1001=IFCTYPEPRODUCT('1cdhVmqEPF6e13_TFOdlQw',$,'DOOR-TAG',$,'IfcAnnotation/TEXT',(#1002),(#1021),$); +#1002=IFCPROPERTYSET('1MUPGQolnDEgp0NxcMN56E',$,'EPset_Annotation',$,(#1003)); +#1003=IFCPROPERTYSINGLEVALUE('Symbol',$,IFCLABEL('door-tag'),$); #1004=IFCCARTESIANPOINT((0.,0.,0.)); #1005=IFCDIRECTION((0.,0.,1.)); #1006=IFCDIRECTION((1.,0.,0.)); #1007=IFCAXIS2PLACEMENT3D(#1004,#1005,#1006); #1008=IFCPLANAREXTENT(1000.,1000.); -#1009=IFCTEXTLITERALWITHEXTENT('{{Name}}',#1007,.RIGHT.,#1008,'center'); -#1010=IFCSHAPEREPRESENTATION(#19,'Annotation','Annotation2D',(#1009)); -#1011=IFCCARTESIANPOINT((0.,0.,0.)); +#1009=IFCTEXTLITERALWITHEXTENT('{{type.Name}}',#1007,.RIGHT.,#1008,'center'); +#1010=IFCCARTESIANPOINT((0.,0.,0.)); +#1011=IFCDIRECTION((0.,0.,1.)); #1012=IFCDIRECTION((1.,0.,0.)); -#1013=IFCDIRECTION((0.,0.,1.)); -#1014=IFCAXIS2PLACEMENT3D(#1011,#1013,#1012); -#1015=IFCREPRESENTATIONMAP(#1014,#1010); -#1016=IFCTYPEPRODUCT('1LTLrGzxH4qhuWGNeTlTRi',$,'SPACE-TAG',$,'IfcAnnotation/TEXT',(#1017),(#1042),$); -#1017=IFCPROPERTYSET('2JpTIsSZf3zQUWK48RLIcL',$,'EPset_Annotation',$,(#1018)); -#1018=IFCPROPERTYSINGLEVALUE('Symbol',$,IFCLABEL('space-tag'),$); -#1019=IFCCARTESIANPOINT((0.,0.,0.)); -#1020=IFCDIRECTION((0.,0.,1.)); -#1021=IFCDIRECTION((1.,0.,0.)); -#1022=IFCAXIS2PLACEMENT3D(#1019,#1020,#1021); -#1023=IFCPLANAREXTENT(1000.,1000.); -#1024=IFCTEXTLITERALWITHEXTENT('{{Name}}',#1022,.RIGHT.,#1023,'center'); +#1013=IFCAXIS2PLACEMENT3D(#1010,#1011,#1012); +#1014=IFCPLANAREXTENT(1000.,1000.); +#1015=IFCTEXTLITERALWITHEXTENT('{{Name}}',#1013,.RIGHT.,#1014,'center'); +#1016=IFCSHAPEREPRESENTATION(#19,'Annotation','Annotation2D',(#1009,#1015)); +#1017=IFCCARTESIANPOINT((0.,0.,0.)); +#1018=IFCDIRECTION((1.,0.,0.)); +#1019=IFCDIRECTION((0.,0.,1.)); +#1020=IFCAXIS2PLACEMENT3D(#1017,#1019,#1018); +#1021=IFCREPRESENTATIONMAP(#1020,#1016); +#1022=IFCTYPEPRODUCT('0OuDi3gRH2CxW9mrtE0vXw',$,'WINDOW-TAG',$,'IfcAnnotation/TEXT',(#1023),(#1036),$); +#1023=IFCPROPERTYSET('2X7dAo_1P5ruRnlKA4kIl6',$,'EPset_Annotation',$,(#1024)); +#1024=IFCPROPERTYSINGLEVALUE('Symbol',$,IFCLABEL('window-tag'),$); #1025=IFCCARTESIANPOINT((0.,0.,0.)); #1026=IFCDIRECTION((0.,0.,1.)); #1027=IFCDIRECTION((1.,0.,0.)); #1028=IFCAXIS2PLACEMENT3D(#1025,#1026,#1027); #1029=IFCPLANAREXTENT(1000.,1000.); -#1030=IFCTEXTLITERALWITHEXTENT('{{Description}}',#1028,.RIGHT.,#1029,'center'); -#1031=IFCCARTESIANPOINT((0.,0.,0.)); -#1032=IFCDIRECTION((0.,0.,1.)); +#1030=IFCTEXTLITERALWITHEXTENT('{{Name}}',#1028,.RIGHT.,#1029,'center'); +#1031=IFCSHAPEREPRESENTATION(#19,'Annotation','Annotation2D',(#1030)); +#1032=IFCCARTESIANPOINT((0.,0.,0.)); #1033=IFCDIRECTION((1.,0.,0.)); -#1034=IFCAXIS2PLACEMENT3D(#1031,#1032,#1033); -#1035=IFCPLANAREXTENT(1000.,1000.); -#1036=IFCTEXTLITERALWITHEXTENT('``round({{Qto_SpaceBaseQuantities.NetFloorArea}} or 0., 2)``',#1034,.RIGHT.,#1035,'center'); -#1037=IFCSHAPEREPRESENTATION(#19,'Annotation','Annotation2D',(#1024,#1030,#1036)); -#1038=IFCCARTESIANPOINT((0.,0.,0.)); -#1039=IFCDIRECTION((1.,0.,0.)); -#1040=IFCDIRECTION((0.,0.,1.)); -#1041=IFCAXIS2PLACEMENT3D(#1038,#1040,#1039); -#1042=IFCREPRESENTATIONMAP(#1041,#1037); -#1043=IFCTYPEPRODUCT('3Or$DhaVf6ygGBUpRb2PMs',$,'MATERIAL-TAG',$,'IfcAnnotation/TEXT',(#1044),(#1057),$); -#1044=IFCPROPERTYSET('2YfvIc6dPDdwWjERHZZlof',$,'EPset_Annotation',$,(#1045)); -#1045=IFCPROPERTYSINGLEVALUE('Symbol',$,IFCLABEL('rectangle-tag'),$); +#1034=IFCDIRECTION((0.,0.,1.)); +#1035=IFCAXIS2PLACEMENT3D(#1032,#1034,#1033); +#1036=IFCREPRESENTATIONMAP(#1035,#1031); +#1037=IFCTYPEPRODUCT('3WEV_9wQn6AQTESgjW36PH',$,'SPACE-TAG',$,'IfcAnnotation/TEXT',(#1038),(#1063),$); +#1038=IFCPROPERTYSET('02FAX8dIzAlunWD5ak$6sU',$,'EPset_Annotation',$,(#1039)); +#1039=IFCPROPERTYSINGLEVALUE('Symbol',$,IFCLABEL('space-tag'),$); +#1040=IFCCARTESIANPOINT((0.,0.,0.)); +#1041=IFCDIRECTION((0.,0.,1.)); +#1042=IFCDIRECTION((1.,0.,0.)); +#1043=IFCAXIS2PLACEMENT3D(#1040,#1041,#1042); +#1044=IFCPLANAREXTENT(1000.,1000.); +#1045=IFCTEXTLITERALWITHEXTENT('{{Name}}',#1043,.RIGHT.,#1044,'center'); #1046=IFCCARTESIANPOINT((0.,0.,0.)); #1047=IFCDIRECTION((0.,0.,1.)); #1048=IFCDIRECTION((1.,0.,0.)); #1049=IFCAXIS2PLACEMENT3D(#1046,#1047,#1048); #1050=IFCPLANAREXTENT(1000.,1000.); -#1051=IFCTEXTLITERALWITHEXTENT('{{material.Name}}',#1049,.RIGHT.,#1050,'center'); -#1052=IFCSHAPEREPRESENTATION(#19,'Annotation','Annotation2D',(#1051)); -#1053=IFCCARTESIANPOINT((0.,0.,0.)); +#1051=IFCTEXTLITERALWITHEXTENT('{{Description}}',#1049,.RIGHT.,#1050,'center'); +#1052=IFCCARTESIANPOINT((0.,0.,0.)); +#1053=IFCDIRECTION((0.,0.,1.)); #1054=IFCDIRECTION((1.,0.,0.)); -#1055=IFCDIRECTION((0.,0.,1.)); -#1056=IFCAXIS2PLACEMENT3D(#1053,#1055,#1054); -#1057=IFCREPRESENTATIONMAP(#1056,#1052); -#1058=IFCTYPEPRODUCT('01Hhk_DbjDROhyr91Hp$aV',$,'TYPE-TAG',$,'IfcAnnotation/TEXT',(#1059),(#1072),$); -#1059=IFCPROPERTYSET('0LyRD7lHr17uJo7XNzCvZq',$,'EPset_Annotation',$,(#1060)); -#1060=IFCPROPERTYSINGLEVALUE('Symbol',$,IFCLABEL('capsule-tag'),$); -#1061=IFCCARTESIANPOINT((0.,0.,0.)); -#1062=IFCDIRECTION((0.,0.,1.)); -#1063=IFCDIRECTION((1.,0.,0.)); -#1064=IFCAXIS2PLACEMENT3D(#1061,#1062,#1063); -#1065=IFCPLANAREXTENT(1000.,1000.); -#1066=IFCTEXTLITERALWITHEXTENT('{{type.Name}}',#1064,.RIGHT.,#1065,'center'); -#1067=IFCSHAPEREPRESENTATION(#19,'Annotation','Annotation2D',(#1066)); -#1068=IFCCARTESIANPOINT((0.,0.,0.)); +#1055=IFCAXIS2PLACEMENT3D(#1052,#1053,#1054); +#1056=IFCPLANAREXTENT(1000.,1000.); +#1057=IFCTEXTLITERALWITHEXTENT('``round({{Qto_SpaceBaseQuantities.NetFloorArea}}, 0.01)``',#1055,.RIGHT.,#1056,'center'); +#1058=IFCSHAPEREPRESENTATION(#19,'Annotation','Annotation2D',(#1045,#1051,#1057)); +#1059=IFCCARTESIANPOINT((0.,0.,0.)); +#1060=IFCDIRECTION((1.,0.,0.)); +#1061=IFCDIRECTION((0.,0.,1.)); +#1062=IFCAXIS2PLACEMENT3D(#1059,#1061,#1060); +#1063=IFCREPRESENTATIONMAP(#1062,#1058); +#1064=IFCTYPEPRODUCT('1evqvQLLL1zxdsIWqEn0lK',$,'MATERIAL-TAG',$,'IfcAnnotation/TEXT',(#1065),(#1078),$); +#1065=IFCPROPERTYSET('2KPJlGyer0UBmphfR7952k',$,'EPset_Annotation',$,(#1066)); +#1066=IFCPROPERTYSINGLEVALUE('Symbol',$,IFCLABEL('rectangle-tag'),$); +#1067=IFCCARTESIANPOINT((0.,0.,0.)); +#1068=IFCDIRECTION((0.,0.,1.)); #1069=IFCDIRECTION((1.,0.,0.)); -#1070=IFCDIRECTION((0.,0.,1.)); -#1071=IFCAXIS2PLACEMENT3D(#1068,#1070,#1069); -#1072=IFCREPRESENTATIONMAP(#1071,#1067); -#1073=IFCTYPEPRODUCT('0a8MHfTUzA5AV8H6p1bqC9',$,'NAME-TAG',$,'IfcAnnotation/TEXT',(#1074),(#1087),$); -#1074=IFCPROPERTYSET('2EboL9u1j68Q_Mp8dBa7ux',$,'EPset_Annotation',$,(#1075)); -#1075=IFCPROPERTYSINGLEVALUE('Symbol',$,IFCLABEL('capsule-tag'),$); -#1076=IFCCARTESIANPOINT((0.,0.,0.)); -#1077=IFCDIRECTION((0.,0.,1.)); -#1078=IFCDIRECTION((1.,0.,0.)); -#1079=IFCAXIS2PLACEMENT3D(#1076,#1077,#1078); -#1080=IFCPLANAREXTENT(1000.,1000.); -#1081=IFCTEXTLITERALWITHEXTENT('{{Name}}',#1079,.RIGHT.,#1080,'center'); -#1082=IFCSHAPEREPRESENTATION(#19,'Annotation','Annotation2D',(#1081)); -#1083=IFCCARTESIANPOINT((0.,0.,0.)); +#1070=IFCAXIS2PLACEMENT3D(#1067,#1068,#1069); +#1071=IFCPLANAREXTENT(1000.,1000.); +#1072=IFCTEXTLITERALWITHEXTENT('{{material.Name}}',#1070,.RIGHT.,#1071,'center'); +#1073=IFCSHAPEREPRESENTATION(#19,'Annotation','Annotation2D',(#1072)); +#1074=IFCCARTESIANPOINT((0.,0.,0.)); +#1075=IFCDIRECTION((1.,0.,0.)); +#1076=IFCDIRECTION((0.,0.,1.)); +#1077=IFCAXIS2PLACEMENT3D(#1074,#1076,#1075); +#1078=IFCREPRESENTATIONMAP(#1077,#1073); +#1079=IFCTYPEPRODUCT('3Nem6d4xX87O3deyWDi3AW',$,'TYPE-TAG',$,'IfcAnnotation/TEXT',(#1080),(#1093),$); +#1080=IFCPROPERTYSET('15bspgnA9CEuFox6xFjoTL',$,'EPset_Annotation',$,(#1081)); +#1081=IFCPROPERTYSINGLEVALUE('Symbol',$,IFCLABEL('capsule-tag'),$); +#1082=IFCCARTESIANPOINT((0.,0.,0.)); +#1083=IFCDIRECTION((0.,0.,1.)); #1084=IFCDIRECTION((1.,0.,0.)); -#1085=IFCDIRECTION((0.,0.,1.)); -#1086=IFCAXIS2PLACEMENT3D(#1083,#1085,#1084); -#1087=IFCREPRESENTATIONMAP(#1086,#1082); +#1085=IFCAXIS2PLACEMENT3D(#1082,#1083,#1084); +#1086=IFCPLANAREXTENT(1000.,1000.); +#1087=IFCTEXTLITERALWITHEXTENT('{{type.Name}}',#1085,.RIGHT.,#1086,'center'); +#1088=IFCSHAPEREPRESENTATION(#19,'Annotation','Annotation2D',(#1087)); +#1089=IFCCARTESIANPOINT((0.,0.,0.)); +#1090=IFCDIRECTION((1.,0.,0.)); +#1091=IFCDIRECTION((0.,0.,1.)); +#1092=IFCAXIS2PLACEMENT3D(#1089,#1091,#1090); +#1093=IFCREPRESENTATIONMAP(#1092,#1088); +#1094=IFCTYPEPRODUCT('0klFX9AjnEnPBkdIURv8XD',$,'NAME-TAG',$,'IfcAnnotation/TEXT',(#1095),(#1108),$); +#1095=IFCPROPERTYSET('1d53tifbv2rwcoFFJosiHf',$,'EPset_Annotation',$,(#1096)); +#1096=IFCPROPERTYSINGLEVALUE('Symbol',$,IFCLABEL('capsule-tag'),$); +#1097=IFCCARTESIANPOINT((0.,0.,0.)); +#1098=IFCDIRECTION((0.,0.,1.)); +#1099=IFCDIRECTION((1.,0.,0.)); +#1100=IFCAXIS2PLACEMENT3D(#1097,#1098,#1099); +#1101=IFCPLANAREXTENT(1000.,1000.); +#1102=IFCTEXTLITERALWITHEXTENT('{{Name}}',#1100,.RIGHT.,#1101,'center'); +#1103=IFCSHAPEREPRESENTATION(#19,'Annotation','Annotation2D',(#1102)); +#1104=IFCCARTESIANPOINT((0.,0.,0.)); +#1105=IFCDIRECTION((1.,0.,0.)); +#1106=IFCDIRECTION((0.,0.,1.)); +#1107=IFCAXIS2PLACEMENT3D(#1104,#1106,#1105); +#1108=IFCREPRESENTATIONMAP(#1107,#1103); ENDSEC; END-ISO-10303-21; diff --git a/src/blenderbim/blenderbim/bim/module/drawing/svgwriter.py b/src/blenderbim/blenderbim/bim/module/drawing/svgwriter.py index 23cbf1ab0f..0ded5f35e2 100644 --- a/src/blenderbim/blenderbim/bim/module/drawing/svgwriter.py +++ b/src/blenderbim/blenderbim/bim/module/drawing/svgwriter.py @@ -831,7 +831,9 @@ class SvgWriter: symbol_xml.attrib.pop("id") # NOTE: zip makes sure that we iterate over the shortest list for field, text_literal in zip(template_text_fields, text_literals): - field.text = tool.Drawing.replace_text_literal_variables(text_literal.Literal, product) + field.text = tool.Drawing.replace_text_literal_variables( + text_literal.Literal, product or element + ) field.attrib["class"] = classes_str if fill_bg: @@ -849,7 +851,7 @@ class SvgWriter: line_number = 0 for text_literal in text_literals: - text = tool.Drawing.replace_text_literal_variables(text_literal.Literal, product) + text = tool.Drawing.replace_text_literal_variables(text_literal.Literal, product or element) text_tags = self.create_text_tag( text, text_position_svg, @@ -883,7 +885,6 @@ class SvgWriter: symbol_position_svg = point * self.svg_scale self.svg.add(self.svg.use(f"#{svg_id}", insert=symbol_position_svg)) - def draw_point_annotation(self, obj, classes): x_offset = self.raw_width / 2 y_offset = self.raw_height / 2 diff --git a/src/blenderbim/blenderbim/tool/drawing.py b/src/blenderbim/blenderbim/tool/drawing.py index f9965d42b2..516f5c879a 100644 --- a/src/blenderbim/blenderbim/tool/drawing.py +++ b/src/blenderbim/blenderbim/tool/drawing.py @@ -895,7 +895,7 @@ class Drawing(blenderbim.core.tool.Drawing): literals = cls.get_text_literal(obj, return_list=True) cls.import_text_attributes(obj) for i, literal in enumerate(literals): - product = cls.get_assigned_product(tool.Ifc.get_entity(obj)) + product = cls.get_assigned_product(tool.Ifc.get_entity(obj)) or tool.Ifc.get_entity(obj) props.literals[i].value = cls.replace_text_literal_variables(literal.Literal, product) @classmethod diff --git a/src/ifcopenshell-python/ifcopenshell/util/geolocation.py b/src/ifcopenshell-python/ifcopenshell/util/geolocation.py index ca98fb7a96..d4737ac016 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/geolocation.py +++ b/src/ifcopenshell-python/ifcopenshell/util/geolocation.py @@ -75,6 +75,34 @@ def xyz2enh_ifc4x3( return (eastings, northings, height) +def auto_xyz2enh(ifc_file, x, y, z): + try: + conversion = ifc_file.by_type("IfcMapConversion") + except: + return (x, y, z) + if not conversion: + return (x, y, z) + conversion = conversion[0] + e = conversion.Eastings or 0 + n = conversion.Northings or 0 + h = conversion.OrthogonalHeight or 0 + xaa = conversion.XAxisAbscissa or 0 + xao = conversion.XAxisOrdinate or 0 + scale = conversion.Scale or 0 + map_unit = conversion.TargetCRS.MapUnit + if map_unit: + # Warning! This definition has changed in IFC4X3 such that map_unit no + # longer affects unit conversion, only the Scale attribute affects unit + # conversion. TODO: consolidate once IFC4X3 confirmed. + project_unit = ifcopenshell.util.unit.get_project_unit(ifc_file, "LENGTHUNIT") + map_prefix = getattr(map_unit, "Prefix", None) + project_prefix = getattr(project_unit, "Prefix", None) + e = ifcopenshell.util.unit.convert(e, map_prefix, map_unit.Name, project_prefix, project_unit.Name) + n = ifcopenshell.util.unit.convert(n, map_prefix, map_unit.Name, project_prefix, project_unit.Name) + h = ifcopenshell.util.unit.convert(h, map_prefix, map_unit.Name, project_prefix, project_unit.Name) + return xyz2enh(x, y, z, e, n, h, xaa, xao, scale) + + def auto_z2e(ifc_file, z): """Convert a Z coordinate to an elevation using model georeferencing data diff --git a/src/ifcopenshell-python/ifcopenshell/util/selector.py b/src/ifcopenshell-python/ifcopenshell/util/selector.py index ea41323999..de4902e39e 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/selector.py +++ b/src/ifcopenshell-python/ifcopenshell/util/selector.py @@ -22,6 +22,8 @@ import ifcopenshell.util import ifcopenshell.util.fm import ifcopenshell.util.unit import ifcopenshell.util.element +import ifcopenshell.util.placement +import ifcopenshell.util.geolocation import ifcopenshell.util.classification @@ -293,6 +295,8 @@ def set_element_value(ifc_file, element, query, value): return ifcopenshell.util.schema.reassign_class(ifc_file, element, value) elif key == "id": return + elif key in ("x", "y", "z", "easting", "northing", "elevation") and hasattr(element, "ObjectPlacement"): + return elif isinstance(element, ifcopenshell.entity_instance): if key == "Name" and element.is_a("IfcMaterialLayerSet"): key = "LayerSetName" # This oddity in the IFC spec is annoying so we account for it. @@ -818,6 +822,17 @@ class Selector: value = ifcopenshell.util.element.get_predefined_type(value) elif key == "id": value = value.id() + elif key in ("x", "y", "z", "easting", "northing", "elevation") and hasattr(value, "ObjectPlacement"): + if getattr(value, "ObjectPlacement", None): + matrix = ifcopenshell.util.placement.get_local_placement(value.ObjectPlacement) + xyz = matrix[:,3][:3] + if key in ("x", "y", "z"): + value = xyz["xyz".index(key)] + else: + enh = ifcopenshell.util.geolocation.auto_xyz2enh(element.wrapped_data.file, *xyz) + value = enh[("easting", "northing", "elevation").index(key)] + else: + value = None elif isinstance(value, ifcopenshell.entity_instance): if key == "Name" and value.is_a("IfcMaterialLayerSet"): key = "LayerSetName" # This oddity in the IFC spec is annoying so we account for it. From 0fb9f584cb68a00624b309c175f059b2b9db7a07 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 7 Sep 2023 17:38:33 +1000 Subject: [PATCH 027/190] Big clean up to IfcFM. It's almost usable! --- src/ifcfm/ifcfm.py | 455 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 455 insertions(+) create mode 100644 src/ifcfm/ifcfm.py diff --git a/src/ifcfm/ifcfm.py b/src/ifcfm/ifcfm.py new file mode 100644 index 0000000000..0662f42b0d --- /dev/null +++ b/src/ifcfm/ifcfm.py @@ -0,0 +1,455 @@ +# IfcFM - IFC for facility management +# Copyright (C) 2023 Dion Moult +# +# This file is part of IfcFM. +# +# IfcFM 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. +# +# IfcFM 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 IfcFM. If not, see . + +import os +import re +import csv +import datetime +import ifcopenshell +import ifcopenshell.util.fm +import ifcopenshell.util.date +import ifcopenshell.util.schema +import ifcopenshell.util.system +import ifcopenshell.util.placement +import ifcopenshell.util.classification + +try: + from xlsxwriter import Workbook +except: + pass # No XLSX support + +try: + from odf.opendocument import OpenDocumentSpreadsheet + from odf.style import Style, TableCellProperties + from odf.table import Table, TableRow, TableCell + from odf.text import P +except: + pass # No ODF support + + +class Parser: + def __init__(self, preset="BASIC"): + self.file = None + self.categories = {} + self.get_category_elements = {} + self.get_element_data = {} + self.get_custom_element_data = {} + self.duplicate_keys = [] + + if preset == "BASIC": + self.get_category_elements = { + "Actors": get_actors, + "Facilities": get_facilities, + "Storeys": get_storeys, + "Spaces": get_spaces, + "Zones": get_zones, + "Types": get_types, + "Elements": get_elements, + "Systems": get_systems, + } + self.get_element_data = { + "Actors": get_actor_data, + "Facilities": get_facility_data, + "Storeys": get_storey_data, + "Spaces": get_space_data, + "Zones": get_zone_data, + "Types": get_type_data, + "Elements": get_element_data, + "Systems": get_system_data, + } + + def parse(self, ifc_file): + for category_name, get_category_elements in self.get_category_elements.items(): + self.categories.setdefault(category_name, {}) + for element in get_category_elements(ifc_file): + get_element_data = self.get_element_data[category_name] + + if isinstance(get_element_data, dict): + data = {} + for key, query in get_element_data.items(): + data[key] = ifcopenshell.util.selector.get_element_value(element, query) + else: + data = get_element_data(ifc_file, element) or {} + + get_custom_element_data = self.get_custom_element_data.get(category_name, lambda x, y: None) + if isinstance(get_element_data, dict): + custom_data = {} + for key, query in get_custom_element_data.items(): + custom_data[key] = ifcopenshell.util.selector.get_element_value(element, query) + else: + custom_data = get_custom_element_data(ifc_file, element) or {} + + data.update(custom_data) + + if data: + key = data["key"] + del data["key"] + if key in self.categories[category_name]: + self.duplicate_keys.append((self.categories[category_name][key], data)) + self.categories[category_name][key] = data + + +class Writer: + def __init__(self, parser, colours=None, preset="BASIC"): + self.parser = parser + if colours: + self.colours = colours + else: + self.colours = { + "h": "dddddd", # Header data + "p": "dc8774", # Primary identification data + "s": "b8dd73", # Secondary asset data + "r": "eda786", # Internal reference + "e": "96c7d0", # External / autogenerated data + "o": "ddb873", # Conditional / optional data + "n": "eeeeee", # Other data + "b": "000000", # Not in scope + } + if preset == "BASIC": + self.config = { + "Actors": {"colours": "ppssssssss", "sort": [{"name": "Name", "order": "ASC"}]}, + "Facilities": {"colours": "prppppsssseeee", "sort": [{"name": "Name", "order": "ASC"}]}, + "Storeys": { + "colours": "prppeees", + "sort": [{"name": "Elevation", "order": "ASC"}, {"name": "Name", "order": "ASC"}], + }, + "Spaces": { + "colours": "prpprpeess", + "sort": [{"name": "LevelName", "order": "ASC"}, {"name": "Name", "order": "ASC"}], + }, + "Zones": {"colours": "prpree", "sort": [{"name": "Name", "order": "ASC"}]}, + "Types": { + "colours": "prpppeeee", + "sort": [{"name": "ModelObject", "order": "ASC"}, {"name": "Name", "order": "ASC"}], + }, + "Elements": { + "colours": "prprrreee", + "sort": [{"name": "TypeName", "order": "ASC"}, {"name": "Name", "order": "ASC"}], + }, + "Systems": {"colours": "pprppee", "sort": [{"name": "Name", "order": "ASC"}]}, + } + + def write(self, null="N/A", empty="-", bool_true="YES", bool_false="NO"): + self.categories = {} + for category, data in self.parser.categories.items(): + if not data: + self.categories[category] = {"headers": [], "rows": []} + continue + + headers = list(data[list(data.keys())[0]].keys()) + rows = [] + for row in data.values(): + processed_row = [] + for value in row.values(): + if value is None: + value = null + elif value == "": + value = empty + elif value is True: + value = bool_true + elif value is False: + value = bool_false + processed_row.append(value) + rows.append(processed_row) + + sort = self.config.get(category, {}).get("sort", None) + if sort: + + def natural_sort(value): + if isinstance(value, str): + convert = lambda text: int(text) if text.isdigit() else text.lower() + return [convert(c) for c in re.split("([0-9]+)", value)] + return value + + # Sort least important keys first, then more important keys. + # https://stackoverflow.com/questions/11476371/sort-by-multiple-keys-using-different-orderings + for sort_data in reversed(sort): + i = headers.index(sort_data["name"]) + reverse = sort_data["order"] == "DESC" + rows = sorted(rows, key=lambda x: natural_sort(x[i]), reverse=reverse) + self.categories[category] = {"headers": headers, "rows": rows} + + def write_csv(self, output, delimiter=","): + filename = None + if len(self.categories.keys()) == 1 and "." in os.path.basename(output): + filename = output + for category, data in self.categories.items(): + category_filename = filename or os.path.join(output, f"{category}.csv") + with open(category_filename, "w", newline="", encoding="utf-8") as f: + writer = csv.writer(f, delimiter=delimiter) + writer.writerow(data["headers"]) + for row in data["rows"]: + writer.writerow(row) + + def write_ods(self, output): + doc = OpenDocumentSpreadsheet() + + for key, value in self.colours.items(): + style = Style(name=key, family="table-cell") + style.addElement(TableCellProperties(backgroundcolor="#" + value)) + doc.automaticstyles.addElement(style) + + for category, data in self.categories.items(): + colours = self.config.get(category, {}).get("colours", []) + + table = Table(name=category) + tr = TableRow() + for header in data["headers"]: + tc = TableCell(valuetype="string", stylename="h") + tc.addElement(P(text=header)) + tr.addElement(tc) + table.addElement(tr) + for row in data["rows"]: + tr = TableRow() + c = 0 + for col in row: + if c >= len(colours): + cell_format = "n" + else: + cell_format = colours[c] + tc = TableCell(valuetype="string", stylename=cell_format) + tc.addElement(P(text=str(col))) + tr.addElement(tc) + c += 1 + table.addElement(tr) + doc.spreadsheet.addElement(table) + + if len(output) > 4 and output[-4:].lower() == ".ods": + output = output[0:-4] + + doc.save(output, True) + + +def get_actors(ifc_file): + return ifc_file.by_type("IfcActor") + + +def get_facilities(ifc_file): + return ifc_file.by_type("IfcBuilding") + + +def get_storeys(ifc_file): + return ifc_file.by_type("IfcBuildingStorey") + + +def get_spaces(ifc_file): + return ifc_file.by_type("IfcSpace") + + +def get_zones(ifc_file): + zones = [] + for zone in ifc_file.by_type("IfcZone"): + for rel in zone.IsGroupedBy: + zones.extend([(zone, space) for space in rel.RelatedObjects]) + return zones + + +def get_types(ifc_file): + return ifcopenshell.util.fm.get_fmhem_types(ifc_file) + + +def get_elements(ifc_file): + elements = set() + for element_type in ifcopenshell.util.fm.get_fmhem_types(ifc_file): + elements.update(ifcopenshell.util.element.get_types(element_type)) + return elements + + +def get_systems(ifc_file): + return ifc_file.by_type("IfcSystem") + + +def get_actor_data(ifc_file, element): + return { + "key": element.TheActor.Name, + "Name": element.TheActor.Name, + "Category": get_classification(element), + "Email": get_actor_address(element, "ElectronicMailAddresses"), + "Phone": get_actor_address(element, "TelephoneNumbers"), + "CompanyURL": get_actor_address(element, "WWWHomePageURL"), + "Department": get_actor_address(element, "InternalLocation"), + "Address1": get_actor_address(element, "AddressLines"), + "Address2": get_actor_address(element, "Town"), + "StateRegion": get_actor_address(element, "Region"), + "PostalCode": get_actor_address(element, "PostalCode"), + "Country": get_actor_address(element, "Country"), + } + + +def get_facility_data(ifc_file, element): + return { + "key": element.Name, + "Name": element.Name, + "AuthorOrganizationName": get_owner_name(element), + "AuthorDate": get_owner_creation_date(ifc_file.by_type("IfcProject")[0]), + "Category": get_classification(element), + "ProjectName": ifc_file.by_type("IfcProject")[0].Name, + "SiteName": getattr(get_facility_parent(element, "IfcSite"), "Name", None), + "LinearUnits": "millimeters", + "AreaUnits": "square meters", + "AreaMeasurement": "BIM Software", + "Phase": ifc_file.by_type("IfcProject")[0].Phase, + "ModelSoftware": get_owner_application(element), + "ModelProjectID": ifc_file.by_type("IfcProject")[0].GlobalId, + "ModelSiteID": getattr(get_facility_parent(element, "IfcSite"), "GlobalId", None), + "ModelBuildingID": element.GlobalId, + } + + +def get_storey_data(ifc_file, element): + return { + "key": element.Name, + "Name": element.Name, + "AuthorOrganizationName": get_owner_name(element), + "AuthorDate": get_owner_creation_date(element), + "Category": "Level", + "ModelSoftware": get_owner_application(element), + "ModelObject": element.is_a(), + "ModelID": element.GlobalId, + "Elevation": ifcopenshell.util.placement.get_storey_elevation(element), + } + + +def get_space_data(ifc_file, element): + psets = ifcopenshell.util.element.get_psets(element) + return { + "key": element.Name, + "Name": element.Name, + "AuthorOrganizationName": get_owner_name(element), + "AuthorDate": get_owner_creation_date(element), + "Category": get_classification(element), + "LevelName": getattr(get_facility_parent(element, "IfcBuildingStorey"), "Name", None), + "Description": element.LongName, + "ModelSoftware": get_owner_application(element), + "ModelID": element.GlobalId, + "AreaGross": get_property(psets, "Qto_SpaceBaseQuantities", "GrossFloorArea", decimals=2), + "AreaNet": get_property(psets, "Qto_SpaceBaseQuantities", "NetFloorArea", decimals=2), + } + + +def get_zone_data(ifc_file, element): + zone, space = element + return { + "key": (element.Name or "Unnamed") + (space.Name or "Unnamed"), + "Name": zone.Name, + "AuthorOrganizationName": get_owner_name(zone), + "AuthorDate": get_owner_creation_date(zone), + "SpaceName": space.Name, + "ModelSoftware": get_owner_application(zone), + "ModelID": zone.GlobalId, + } + + +def get_type_data(ifc_file, element): + return { + "key": element.Name, + "Name": element.Name, + "AuthorOrganizationName": get_owner_name(element), + "AuthorDate": get_owner_creation_date(element), + "Category": get_classification(element), + "Description": element.Description, + "ModelSoftware": get_owner_application(element), + "ModelObject": "{}[{}]".format(element.is_a(), ifcopenshell.util.element.get_predefined_type(element)), + "ModelTag": element.Tag, + "ModelID": element.GlobalId, + } + + +def get_element_data(ifc_file, element): + space = ifcopenshell.util.element.get_container(element) + space_name = space.Name if space.is_a("IfcSpace") else None + systems = ifcopenshell.util.system.get_element_systems(element) + system = systems[0].Name if systems else None + return { + "key": element.Name, + "Name": element.Name, + "AuthorOrganizationName": get_owner_name(element), + "AuthorDate": get_owner_creation_date(element), + "TypeName": ifcopenshell.util.element.get_type(element).Name, + "SpaceName": space_name, + "SystemName": system, + "ModelSoftware": get_owner_application(element), + "ModelObject": "{}[{}]".format(element.is_a(), ifcopenshell.util.element.get_predefined_type(element)), + "ModelID": element.GlobalId, + } + + +def get_system_data(ifc_file, element): + return { + "key": element.Name, + "Name": element.Name, + "Description": element.Description, + "AuthorOrganizationName": get_owner_name(element), + "AuthorDate": get_owner_creation_date(element), + "Category": get_classification(element), + "ModelSoftware": get_owner_application(element), + "ModelID": element.GlobalId, + } + + +def get_owner_name(element): + if not getattr(element, "OwnerHistory", None): + return + return element.OwnerHistory.OwningUser.TheOrganization.Name + + +def get_owner_creation_date(element): + if not getattr(element, "OwnerHistory", None): + return + return ifcopenshell.util.date.ifc2datetime(element.OwnerHistory.CreationDate).isoformat() + + +def get_owner_application(element): + if not getattr(element, "OwnerHistory", None): + return + return element.OwnerHistory.OwningApplication.ApplicationFullName + + +def get_facility_parent(element, ifc_class): + parent = ifcopenshell.util.element.get_aggregate(element) + while parent: + if parent.is_a(ifc_class): + return parent + if parent.is_a("IfcProject"): + return + parent = ifcopenshell.util.element.get_aggregate(parent) + + +def get_classification(element): + references = list(ifcopenshell.util.classification.get_references(element)) + if references: + if hasattr(references[0], "Identification"): + return "{}:{}".format(references[0].Identification, references[0].Name) + return "{}:{}".format(references[0].ItemReference, references[0].Name) + + +def get_actor_address(element, name): + for address in element.TheActor.Addresses or []: + if hasattr(address, name) and getattr(address, name, None): + result = getattr(address, name) + if isinstance(result, tuple): + return result[0] + return result + + +def get_property(psets, pset_name, prop_name, decimals=None): + if pset_name in psets: + result = psets[pset_name].get(prop_name, None) + if decimals is None or result is None: + return result + return round(result, decimals) From 6ad51ebd18a53cfc8e4febb6f6c13ef668582807 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 7 Sep 2023 18:11:55 +1000 Subject: [PATCH 028/190] Update documentation with new selector keys. --- src/blenderbim/docs/devs/writing_docs.rst | 15 +++++++++++++++ src/blenderbim/scripts/generate_demo_library.py | 3 ++- .../docs/ifcopenshell-python/hello_world.rst | 2 +- .../docs/ifcopenshell-python/selector_syntax.rst | 6 ++++++ 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/blenderbim/docs/devs/writing_docs.rst b/src/blenderbim/docs/devs/writing_docs.rst index 0d14e89bee..b33206447d 100644 --- a/src/blenderbim/docs/devs/writing_docs.rst +++ b/src/blenderbim/docs/devs/writing_docs.rst @@ -13,6 +13,11 @@ All documentation is written in ReStructured Text and is available in the `BlenderBIM Add-on docs directory `_. +You can link to `external websites `_. You can also +link to sections on the same page, like `Writing technical documentation`_. + +:doc:`Code examples` + The following colours and annotation styles should be used for annotating images. All stroke widths are 3px with a corner radius of 3px. Horizontal underlines are 5px with a corner radius of 2px. The dark green is ``39b54a`` and @@ -43,6 +48,16 @@ download. `Visit critical link `__ +You can use bulleted lists: + +- Like. +- This. + +Or ordered lists: + +1. Like. +2. This. + .. note:: Instead of writing "Note that XYZ ..." you should use notes sparingly to diff --git a/src/blenderbim/scripts/generate_demo_library.py b/src/blenderbim/scripts/generate_demo_library.py index 7f5775a39f..3990bdaab9 100644 --- a/src/blenderbim/scripts/generate_demo_library.py +++ b/src/blenderbim/scripts/generate_demo_library.py @@ -155,9 +155,10 @@ class LibraryGenerator: self.create_line_type("MEDIUM", "medium") self.create_line_type("THICK", "thick") self.create_line_type("STRONG", "strong") + self.create_text_type("SETOUT-TAG", "setout-tag", ["E ``round({{easting}}, 0.001)``", "N ``round({{northing}}, 0.001)``"]) self.create_text_type("DOOR-TAG", "door-tag", ["{{type.Name}}", "{{Name}}"]) self.create_text_type("WINDOW-TAG", "window-tag", ["{{Name}}"]) - self.create_text_type("SPACE-TAG", "space-tag", ["{{Name}}", "{{Description}}", "``round({{Qto_SpaceBaseQuantities.NetFloorArea}} or 0., 2)``"]) + self.create_text_type("SPACE-TAG", "space-tag", ["{{Name}}", "{{Description}}", "``round({{Qto_SpaceBaseQuantities.NetFloorArea}}, 0.01)``"]) self.create_text_type("MATERIAL-TAG", "rectangle-tag", ["{{material.Name}}"]) self.create_text_type("TYPE-TAG", "capsule-tag", ["{{type.Name}}"]) self.create_text_type("NAME-TAG", "capsule-tag", ["{{Name}}"]) diff --git a/src/ifcopenshell-python/docs/ifcopenshell-python/hello_world.rst b/src/ifcopenshell-python/docs/ifcopenshell-python/hello_world.rst index 5997eaaada..05289bbbd0 100644 --- a/src/ifcopenshell-python/docs/ifcopenshell-python/hello_world.rst +++ b/src/ifcopenshell-python/docs/ifcopenshell-python/hello_world.rst @@ -45,7 +45,7 @@ By selecting elements by their **IFC Class**, and reading their attributes, you can navigate from one element to another. The relationships between elements are called **IFC Concepts** and create meaning in our industry. For example, if a **IfcWall** element has an attribute that references an **IfcBuildingStorey** -element in a particular way, it might mean that the wall is located in the +element in a particular way, it will mean that the wall is located in the ground floor of the building. .. image:: images/ifc-concepts.svg diff --git a/src/ifcopenshell-python/docs/ifcopenshell-python/selector_syntax.rst b/src/ifcopenshell-python/docs/ifcopenshell-python/selector_syntax.rst index e3775e8d02..f01963a2da 100644 --- a/src/ifcopenshell-python/docs/ifcopenshell-python/selector_syntax.rst +++ b/src/ifcopenshell-python/docs/ifcopenshell-python/selector_syntax.rst @@ -184,6 +184,12 @@ Valid keys are: "``material`` or ``mat``", "Gets the assigned material, which may be a material set." "``item`` or ``i``", "If the previous key returns a material set, gets the relevant material set items" "``materials`` or ``mats``", "Gets a list of IfcMaterials assigned directly or indirectly (such as via a material set) to the element" + "``x``", "Gets the X coordinate of the element's placement" + "``y``", "Gets the Y coordinate of the element's placement" + "``z``", "Gets the Z coordinate of the element's placement" + "``easting``", "Gets the map easting of the element's placement" + "``northing``", "Gets the map northing of the element's placement" + "``elevation``", "Gets the map elevation of the element's placement" "``count``", "If the previous key returns multiple things, count that list. Otherwise, return 1." "``{{number}}``", "If the previous key returns multiple things, fetch the ``{{number}}`` index (e.g. 0, 1, 2, 3, etc) item in that list." From e2224386bfb2a5716ee0314b76f9d3722eed484b Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 7 Sep 2023 18:15:12 +1000 Subject: [PATCH 029/190] You can now customise the display of empty strings in IfcCSV. --- .../blenderbim/bim/module/csv/operator.py | 2 ++ .../blenderbim/bim/module/csv/prop.py | 1 + .../blenderbim/bim/module/csv/ui.py | 2 ++ src/ifccsv/ifccsv.py | 33 +++++++++++-------- src/ifcopenshell-python/docs/ifccsv.rst | 21 +++++++++--- 5 files changed, 40 insertions(+), 19 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/csv/operator.py b/src/blenderbim/blenderbim/bim/module/csv/operator.py index daa85fbab9..dceeb0a568 100644 --- a/src/blenderbim/blenderbim/bim/module/csv/operator.py +++ b/src/blenderbim/blenderbim/bim/module/csv/operator.py @@ -205,6 +205,7 @@ class ExportIfcCsv(bpy.types.Operator): delimiter=sep, include_global_id=props.include_global_id, null=props.null_value, + empty=props.empty_value, bool_true=props.true_value, bool_false=props.false_value, sort=sort, @@ -244,6 +245,7 @@ class ImportIfcCsv(bpy.types.Operator): attributes=attributes, delimiter=sep, null=props.null_value, + empty=props.empty_value, bool_true=props.true_value, bool_false=props.false_value, ) diff --git a/src/blenderbim/blenderbim/bim/module/csv/prop.py b/src/blenderbim/blenderbim/bim/module/csv/prop.py index db1ae07071..c04fc18ee6 100644 --- a/src/blenderbim/blenderbim/bim/module/csv/prop.py +++ b/src/blenderbim/blenderbim/bim/module/csv/prop.py @@ -69,6 +69,7 @@ class CsvProperties(PropertyGroup): should_preserve_existing: BoolProperty(default=False, name="Preserve Existing") include_global_id: BoolProperty(default=True, name="Include GlobalId") null_value: StringProperty(default="N/A", name="Null Value") + empty_value: StringProperty(default="-", name="Empty String Value") true_value: StringProperty(default="YES", name="True Value") false_value: StringProperty(default="NO", name="False Value") csv_delimiter: EnumProperty( diff --git a/src/blenderbim/blenderbim/bim/module/csv/ui.py b/src/blenderbim/blenderbim/bim/module/csv/ui.py index 425bb2cb8d..49e096c2bc 100644 --- a/src/blenderbim/blenderbim/bim/module/csv/ui.py +++ b/src/blenderbim/blenderbim/bim/module/csv/ui.py @@ -74,6 +74,8 @@ class BIM_PT_ifccsv(Panel): row = layout.row() row.prop(props, "null_value") row = layout.row() + row.prop(props, "empty_value") + row = layout.row() row.prop(props, "true_value") row = layout.row() row.prop(props, "false_value") diff --git a/src/ifccsv/ifccsv.py b/src/ifccsv/ifccsv.py index 08ef8c6e5e..e1bbcfbd5d 100755 --- a/src/ifccsv/ifccsv.py +++ b/src/ifccsv/ifccsv.py @@ -70,6 +70,7 @@ class IfcCsv: include_global_id=True, delimiter=",", null="-", + empty="", bool_true="YES", bool_false="NO", sort=None, @@ -96,6 +97,8 @@ class IfcCsv: value = ifcopenshell.util.selector.get_element_value(element, attribute) if value is None: value = null + elif value == "": + value = empty elif value is True: value = bool_true elif value is False: @@ -368,17 +371,17 @@ class IfcCsv: results.update([p.Name for p in element.Quantities]) return ["{}.{}".format(pset_qto_name, n) for n in results] - def Import(self, ifc_file, table, attributes=None, delimiter=",", null="-", bool_true="YES", bool_false="NO"): + def Import(self, ifc_file, table, attributes=None, delimiter=",", null="-", empty="", bool_true="YES", bool_false="NO"): ext = table.split(".")[-1].lower() if ext == "csv": - self.import_csv(ifc_file, table, attributes, delimiter, null, bool_true, bool_false) + self.import_csv(ifc_file, table, attributes, delimiter, null, empty, bool_true, bool_false) elif ext == "ods": - self.import_ods(ifc_file, table, attributes, null, bool_true, bool_false) + self.import_ods(ifc_file, table, attributes, null, empty, bool_true, bool_false) elif ext == "xlsx": - self.import_xlsx(ifc_file, table, attributes, null, bool_true, bool_false) + self.import_xlsx(ifc_file, table, attributes, null, empty, bool_true, bool_false) - def import_csv(self, ifc_file, table, attributes=None, delimiter=",", null="-", bool_true="YES", bool_false="NO"): + def import_csv(self, ifc_file, table, attributes=None, delimiter=",", null="-", empty="", bool_true="YES", bool_false="NO"): with open(table, newline="", encoding="utf-8") as f: reader = csv.reader(f, delimiter=delimiter) headers = [] @@ -390,17 +393,17 @@ class IfcCsv: elif len(attributes) == len(headers) - 1: attributes.insert(0, "") # The GlobalId column continue - self.process_row(ifc_file, row, headers, attributes, null, bool_true, bool_false) + self.process_row(ifc_file, row, headers, attributes, null, empty, bool_true, bool_false) - def import_xlsx(self, ifc_file, table, attributes, null, bool_true, bool_false): + def import_xlsx(self, ifc_file, table, attributes, null, empty, bool_true, bool_false): df = pd.read_excel(table) - self.import_pd(ifc_file, df, attributes, null, bool_true, bool_false) + self.import_pd(ifc_file, df, attributes, null, empty, bool_true, bool_false) - def import_ods(self, ifc_file, table, attributes, null, bool_true, bool_false): + def import_ods(self, ifc_file, table, attributes, null, empty, bool_true, bool_false): df = pd.read_excel(table, engine="odf") - self.import_pd(ifc_file, df, attributes, null, bool_true, bool_false) + self.import_pd(ifc_file, df, attributes, null, empty, bool_true, bool_false) - def import_pd(self, ifc_file, df, attributes=None, null="-", bool_true="YES", bool_false="NO"): + def import_pd(self, ifc_file, df, attributes=None, null="-", empty="", bool_true="YES", bool_false="NO"): headers = df.columns.tolist() if not attributes: @@ -409,9 +412,9 @@ class IfcCsv: attributes.insert(0, "") # The GlobalId column for _, row in df.iterrows(): - self.process_row(ifc_file, row.tolist(), headers, attributes, null, bool_true, bool_false) + self.process_row(ifc_file, row.tolist(), headers, attributes, null, empty, bool_true, bool_false) - def process_row(self, ifc_file, row, headers, attributes, null, bool_true, bool_false): + def process_row(self, ifc_file, row, headers, attributes, null, empty, bool_true, bool_false): try: element = ifc_file.by_guid(row[0]) except: @@ -422,6 +425,8 @@ class IfcCsv: continue # Skip GlobalId if value == null: value = None + elif value == empty: + value = "" elif value == bool_true: value = True elif value == bool_false: @@ -449,7 +454,7 @@ if __name__ == "__main__": help="Specify attributes that are part of the extract, using the IfcQuery syntax such as 'class', 'Name' or 'Pset_Foo.Bar'", ) parser.add_argument( - "-h", "--headers", nargs="+", help="Specify human readable headers that correlate to each attribute." + "--headers", nargs="+", help="Specify human readable headers that correlate to each attribute." ) parser.add_argument("--sort", nargs="+", help="Specify one or more attributes to sort by.") parser.add_argument("--order", nargs="+", help="Choose the sort order from ASC or DESC for each sorted attribute.") diff --git a/src/ifcopenshell-python/docs/ifccsv.rst b/src/ifcopenshell-python/docs/ifccsv.rst index 0e89bf9622..54ef0a4b6a 100644 --- a/src/ifcopenshell-python/docs/ifccsv.rst +++ b/src/ifcopenshell-python/docs/ifccsv.rst @@ -66,7 +66,8 @@ utility: :: $ python -m ifccsv -h - usage: ifccsv.py [-h] -i IFC [-s SPREADSHEET] [-f FORMAT] [-d DELIMITER] [-n NULL] [-q QUERY] [-a ARGUMENTS [ARGUMENTS ...]] [--export] [--import] + usage: ifccsv.py [-h] -i IFC [-s SPREADSHEET] [-f FORMAT] [-d DELIMITER] [-n NULL] [--bool_true BOOL_TRUE] [--bool_false BOOL_FALSE] [-q QUERY] [-a ATTRIBUTES [ATTRIBUTES ...]] [--headers HEADERS [HEADERS ...]] [--sort SORT [SORT ...]] + [--order ORDER [ORDER ...]] [--export] [--import] Exports IFC data to and from CSV @@ -80,12 +81,22 @@ utility: -d DELIMITER, --delimiter DELIMITER The delimiter in CSV. Defaults to a comma. -n NULL, --null NULL How to represent null values. Defaults to a hyphen. + --bool_true BOOL_TRUE + How to represent true values. Defaults to YES. + --bool_false BOOL_FALSE + How to represent false values. Defaults to NO. -q QUERY, --query QUERY Specify a IFC query selector, such as "IfcWall" - -a ARGUMENTS [ARGUMENTS ...], --arguments ARGUMENTS [ARGUMENTS ...] - Specify attributes that are part of the extract, using the IfcQuery syntax such as 'type', 'Name' or 'Pset_Foo.Bar' - --export Export from IFC to CSV - --import Import from CSV to IFC + -a ATTRIBUTES [ATTRIBUTES ...], --attributes ATTRIBUTES [ATTRIBUTES ...] + Specify attributes that are part of the extract, using the IfcQuery syntax such as 'class', 'Name' or 'Pset_Foo.Bar' + --headers HEADERS [HEADERS ...] + Specify human readable headers that correlate to each attribute. + --sort SORT [SORT ...] + Specify one or more attributes to sort by. + --order ORDER [ORDER ...] + Choose the sort order from ASC or DESC for each sorted attribute. + --export Export from IFC to the desired format. + --import Import from the autodetected format to IFC. $ python -m ifccsv -i model.ifc -s out.csv -f csv -q .IfcProduct -a "Name" "Description" --export $ cat out.csv From ae1ff3f6f7c74f34e3121fcd146491491634b754 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 7 Sep 2023 22:09:26 +1000 Subject: [PATCH 030/190] Fix bug where unedited mesh objects were still tagged as edited which led to unnecessary processing prior to drawing generation or saving. --- src/blenderbim/blenderbim/bim/handler.py | 18 ------------------ src/blenderbim/blenderbim/bim/ifc.py | 2 -- .../blenderbim/bim/module/geometry/operator.py | 3 +++ src/blenderbim/blenderbim/tool/ifc.py | 1 - .../ifcopenshell/util/selector.py | 7 ++++--- 5 files changed, 7 insertions(+), 24 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/handler.py b/src/blenderbim/blenderbim/bim/handler.py index c8a2882475..cc27d81e32 100644 --- a/src/blenderbim/blenderbim/bim/handler.py +++ b/src/blenderbim/blenderbim/bim/handler.py @@ -36,24 +36,6 @@ cwd = os.path.dirname(os.path.realpath(__file__)) global_subscription_owner = object() -def mode_callback(obj, data): - objects = bpy.context.selected_objects - if bpy.context.active_object: - objects += [bpy.context.active_object] - for obj in objects: - if ( - obj.mode != "EDIT" - or not obj.data - or not isinstance(obj.data, (bpy.types.Mesh, bpy.types.Curve, bpy.types.TextCurve)) - or not obj.BIMObjectProperties.ifc_definition_id - ): - continue - if obj.data.BIMMeshProperties.ifc_definition_id: - tool.Ifc.edit(obj) - elif IfcStore.get_file().by_id(obj.BIMObjectProperties.ifc_definition_id).is_a("IfcGridAxis"): - tool.Ifc.edit(obj) - - def name_callback(obj, data): try: obj.name diff --git a/src/blenderbim/blenderbim/bim/ifc.py b/src/blenderbim/blenderbim/bim/ifc.py index c35a942aaa..312527beb4 100644 --- a/src/blenderbim/blenderbim/bim/ifc.py +++ b/src/blenderbim/blenderbim/bim/ifc.py @@ -220,7 +220,6 @@ class IfcStore: if isinstance(obj, bpy.types.Material): blenderbim.bim.handler.subscribe_to(obj, "diffuse_color", blenderbim.bim.handler.color_callback) elif isinstance(obj, bpy.types.Object): - blenderbim.bim.handler.subscribe_to(obj, "mode", blenderbim.bim.handler.mode_callback) blenderbim.bim.handler.subscribe_to( obj, "active_material_index", blenderbim.bim.handler.active_material_index_callback ) @@ -249,7 +248,6 @@ class IfcStore: if isinstance(obj, bpy.types.Material): blenderbim.bim.handler.subscribe_to(obj, "diffuse_color", blenderbim.bim.handler.color_callback) elif isinstance(obj, bpy.types.Object): - blenderbim.bim.handler.subscribe_to(obj, "mode", blenderbim.bim.handler.mode_callback) blenderbim.bim.handler.subscribe_to( obj, "active_material_index", blenderbim.bim.handler.active_material_index_callback ) diff --git a/src/blenderbim/blenderbim/bim/module/geometry/operator.py b/src/blenderbim/blenderbim/bim/module/geometry/operator.py index 2769e37934..21e998bc67 100644 --- a/src/blenderbim/blenderbim/bim/module/geometry/operator.py +++ b/src/blenderbim/blenderbim/bim/module/geometry/operator.py @@ -1415,6 +1415,7 @@ class OverrideModeSetEdit(bpy.types.Operator): continue if tool.Geometry.is_meshlike(representation): + tool.Ifc.edit(obj) if getattr(element, "HasOpenings", None): # Mesh elements with openings must disable openings # so that you can edit the original topology. @@ -1559,6 +1560,8 @@ class OverrideModeSetObject(bpy.types.Operator): self.edited_objs.append(obj) elif getattr(element, "HasOpenings", None): self.unchanged_objs_with_openings.append(obj) + else: + tool.Ifc.finish_edit(obj) if self.edited_objs: return context.window_manager.invoke_props_dialog(self) diff --git a/src/blenderbim/blenderbim/tool/ifc.py b/src/blenderbim/blenderbim/tool/ifc.py index 247e3a5c66..9c975e92d8 100644 --- a/src/blenderbim/blenderbim/tool/ifc.py +++ b/src/blenderbim/blenderbim/tool/ifc.py @@ -120,7 +120,6 @@ class Ifc(blenderbim.core.tool.Ifc): IfcStore.guid_map[global_id] = obj blenderbim.bim.handler.subscribe_to(obj, "name", blenderbim.bim.handler.name_callback) - blenderbim.bim.handler.subscribe_to(obj, "mode", blenderbim.bim.handler.mode_callback) blenderbim.bim.handler.subscribe_to( obj, "active_material_index", blenderbim.bim.handler.active_material_index_callback ) diff --git a/src/ifcopenshell-python/ifcopenshell/util/selector.py b/src/ifcopenshell-python/ifcopenshell/util/selector.py index de4902e39e..7216eb5e67 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/selector.py +++ b/src/ifcopenshell-python/ifcopenshell/util/selector.py @@ -185,10 +185,11 @@ class FormatTransformer(lark.Transformer): return "".join(args) def substr(self, args): - return str(args[0])[int(args[1]):int(args[2])] + return str(args[0])[int(args[1]) : int(args[2])] def round(self, args): - return str(round(float(args[0]) / float(args[1])) * float(args[1])) + value = args[0] or 0.0 + return str(round(float(value) / float(args[1])) * float(args[1])) def format_length(self, args): return args[0] @@ -825,7 +826,7 @@ class Selector: elif key in ("x", "y", "z", "easting", "northing", "elevation") and hasattr(value, "ObjectPlacement"): if getattr(value, "ObjectPlacement", None): matrix = ifcopenshell.util.placement.get_local_placement(value.ObjectPlacement) - xyz = matrix[:,3][:3] + xyz = matrix[:, 3][:3] if key in ("x", "y", "z"): value = xyz["xyz".index(key)] else: From c90c43832432983a53b693dcf0ab6338681cec9b Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 7 Sep 2023 22:15:44 +1000 Subject: [PATCH 031/190] ResetAbsoluteCoordinates IfcPatch recipe can now choose whether to reset placements, geometry, or both points. --- .../recipes/ResetAbsoluteCoordinates.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/ifcpatch/ifcpatch/recipes/ResetAbsoluteCoordinates.py b/src/ifcpatch/ifcpatch/recipes/ResetAbsoluteCoordinates.py index a44b41211f..37fd94e2c6 100644 --- a/src/ifcpatch/ifcpatch/recipes/ResetAbsoluteCoordinates.py +++ b/src/ifcpatch/ifcpatch/recipes/ResetAbsoluteCoordinates.py @@ -18,7 +18,7 @@ class Patcher: - def __init__(self, src, file, logger, a=None, b=None, c=None, d=None): + def __init__(self, src, file, logger, mode="geometry", a=None, b=None, c=None, d=None): """Reset any large coordinates to smaller coordinates based on a threshold If you find large coordinates in your model, the large coordinates may @@ -59,6 +59,13 @@ class Patcher: numbers are treated as the X, Y, Z offset to apply (a, b, c). The fourth (d) will be treated as the threshold. + :param mode: Choose from "geometry", "placement", or "both". Choosing + "geometry" will only replace cartesian points used in shape + representations. Choosing "placement" will only replace cartesian + points used in object placements. Choosing "both" will replace all + cartesian points regardless of use (useful if the model has both + large placement offsets and large geometry offsets). + :type mode: str :param a: The first parameter :type a: float,optional :param b: The second parameter @@ -87,6 +94,7 @@ class Patcher: self.src = src self.file = file self.logger = logger + self.mode = mode self.args = [x for x in [a, b, c, d] if x is not None] def patch(self): @@ -128,8 +136,12 @@ class Patcher: for point in self.file.by_type("IfcCartesianPoint"): if len(point.Coordinates) == 2 or not self.is_point_far_away(point): continue - if point.id() in placement_coord_ids: - continue + if self.mode == "geometry": + if point.id() in placement_coord_ids: + continue + elif self.mode == "placement": + if point.id() not in placement_coord_ids: + continue if not offset_point: offset_point = (-point.Coordinates[0], -point.Coordinates[1], -point.Coordinates[2]) self.logger.info(f"Resetting absolute coordinates by {point}") From f1c3a90bc5d387bf9b632c3ef656bf5b451e970a Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Thu, 7 Sep 2023 14:23:37 -0500 Subject: [PATCH 032/190] Update installation.rst --- src/blenderbim/docs/users/installation.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/blenderbim/docs/users/installation.rst b/src/blenderbim/docs/users/installation.rst index 713f494a4c..f53194601e 100644 --- a/src/blenderbim/docs/users/installation.rst +++ b/src/blenderbim/docs/users/installation.rst @@ -134,9 +134,11 @@ FAQ Check which BlenderBIM Add-on build you are using. The zip will have either ``py37``, ``py39``, or ``py310`` in the name. See the instructions in the - **Unstable installation** section to check that you have installed the + `Unstable installation <../devs/installation.html#unstable-installation>`__ section to check that you have installed the correct version. + + 2. **I am on Ubuntu and get an error similar to "ImportError: /lib/x86_64-linux-gnu/libm.so.6: version GLIBC_2.29 not found"** From cc8f103465c6ca057fe83fda172459e2e3148c89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Perdig=C3=A3o?= Date: Thu, 7 Sep 2023 21:06:08 -0300 Subject: [PATCH 033/190] fix rotation from refresh aggregate operator --- src/blenderbim/blenderbim/bim/module/geometry/operator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blenderbim/blenderbim/bim/module/geometry/operator.py b/src/blenderbim/blenderbim/bim/module/geometry/operator.py index 21e998bc67..3aae8f68de 100644 --- a/src/blenderbim/blenderbim/bim/module/geometry/operator.py +++ b/src/blenderbim/blenderbim/bim/module/geometry/operator.py @@ -1213,7 +1213,7 @@ class RefreshAggregate(bpy.types.Operator): new_obj = tool.Ifc.get_object(new[0]) - matrix_diff = new_obj.matrix_world @ original_matrix + matrix_diff = Matrix.inverted(original_matrix) @ new_obj.matrix_world new_matrix = selected_matrix @ matrix_diff new_obj.matrix_world = new_matrix From e8977979558cfca4e5f13832fa1730d71abfac97 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 8 Sep 2023 11:15:01 +1000 Subject: [PATCH 034/190] For convenience, rounding to an integer value now also casts to integer. --- src/ifcopenshell-python/ifcopenshell/util/selector.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ifcopenshell-python/ifcopenshell/util/selector.py b/src/ifcopenshell-python/ifcopenshell/util/selector.py index 7216eb5e67..502fe8333b 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/selector.py +++ b/src/ifcopenshell-python/ifcopenshell/util/selector.py @@ -189,7 +189,11 @@ class FormatTransformer(lark.Transformer): def round(self, args): value = args[0] or 0.0 - return str(round(float(value) / float(args[1])) * float(args[1])) + nearest = float(args[1]) + result = round(float(value) / nearest) * nearest + if nearest % 1 == 0: + return str(int(result)) + return str(result) def format_length(self, args): return args[0] From 2843d1e13ebd54d039c3755102391131f07cc84a Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 8 Sep 2023 12:06:47 +1000 Subject: [PATCH 035/190] Enable Sphinx auto section labels. Minor doc link fix. --- src/blenderbim/docs/conf.py | 5 ++++- src/blenderbim/docs/devs/writing_docs.rst | 11 +++++++---- src/blenderbim/docs/users/installation.rst | 9 ++++----- src/ifcopenshell-python/docs/conf.py | 5 ++++- 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/blenderbim/docs/conf.py b/src/blenderbim/docs/conf.py index e3c6526151..27073e246d 100644 --- a/src/blenderbim/docs/conf.py +++ b/src/blenderbim/docs/conf.py @@ -48,7 +48,10 @@ release = "0.0.220504" # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. -extensions = ["sphinx.ext.autodoc"] +extensions = ["sphinx.ext.autodoc", "sphinx.ext.autosectionlabel"] + +# Auto add document prefixes to help guarantee uniqueness of automatic section references. +autosectionlabel_prefix_document = True # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] diff --git a/src/blenderbim/docs/devs/writing_docs.rst b/src/blenderbim/docs/devs/writing_docs.rst index b33206447d..d16f7805a1 100644 --- a/src/blenderbim/docs/devs/writing_docs.rst +++ b/src/blenderbim/docs/devs/writing_docs.rst @@ -13,10 +13,13 @@ All documentation is written in ReStructured Text and is available in the `BlenderBIM Add-on docs directory `_. -You can link to `external websites `_. You can also -link to sections on the same page, like `Writing technical documentation`_. - -:doc:`Code examples` +You can link to `external websites +`_. +You can also link to sections on the same page, like `Writing technical +documentation`_. You can link to other pages, like :doc:`Hello +World` or sections within other pages, like +:ref:`devs/installation:unstable installation`. We have ``autosectionlabel`` +enabled so it is not necessary to manually create labels. The following colours and annotation styles should be used for annotating images. All stroke widths are 3px with a corner radius of 3px. Horizontal diff --git a/src/blenderbim/docs/users/installation.rst b/src/blenderbim/docs/users/installation.rst index f53194601e..c50b4d3267 100644 --- a/src/blenderbim/docs/users/installation.rst +++ b/src/blenderbim/docs/users/installation.rst @@ -133,11 +133,10 @@ FAQ 1. **I get an error similar to "ImportError: IfcOpenShell not built for 'linux/64bit/python3.7'"** Check which BlenderBIM Add-on build you are using. The zip will have either - ``py37``, ``py39``, or ``py310`` in the name. See the instructions in the - `Unstable installation <../devs/installation.html#unstable-installation>`__ section to check that you have installed the - correct version. - - + ``py39`` or ``py310`` in the name. If you are using a Mac, also make sure + you are using the M1 version if you have a newer Mac. See the instructions + in the :ref:`devs/installation:unstable installation` section to check that + you have installed the correct version. 2. **I am on Ubuntu and get an error similar to "ImportError: /lib/x86_64-linux-gnu/libm.so.6: version GLIBC_2.29 not found"** diff --git a/src/ifcopenshell-python/docs/conf.py b/src/ifcopenshell-python/docs/conf.py index 7335a4c80a..b766df476c 100644 --- a/src/ifcopenshell-python/docs/conf.py +++ b/src/ifcopenshell-python/docs/conf.py @@ -54,7 +54,10 @@ release = "0.7.0" # - No subnav making it really hard to navigate # - Kinda hacky setup https://stackoverflow.com/questions/2701998/sphinx-autodoc-is-not-automatic-enough # - I couldn't customise the template to show submodules above members which makes API discovery hard for users -extensions = ["autoapi.extension"] +extensions = ["autoapi.extension", "sphinx.ext.autosectionlabel"] + +# Auto add document prefixes to help guarantee uniqueness of automatic section references. +autosectionlabel_prefix_document = True # We'll add the toctree entry ourselves to distinguish between C++ and Python autoapi_add_toctree_entry = True From 763eaf83da7272e147c157c41e1a6c7ba1567b6a Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 8 Sep 2023 12:20:52 +1000 Subject: [PATCH 036/190] Fix #3733. Fix round function precision error. --- src/ifcopenshell-python/ifcopenshell/util/selector.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/util/selector.py b/src/ifcopenshell-python/ifcopenshell/util/selector.py index 502fe8333b..b568e6b476 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/selector.py +++ b/src/ifcopenshell-python/ifcopenshell/util/selector.py @@ -25,6 +25,7 @@ import ifcopenshell.util.element import ifcopenshell.util.placement import ifcopenshell.util.geolocation import ifcopenshell.util.classification +from decimal import Decimal filter_elements_grammar = lark.Lark( @@ -188,9 +189,9 @@ class FormatTransformer(lark.Transformer): return str(args[0])[int(args[1]) : int(args[2])] def round(self, args): - value = args[0] or 0.0 - nearest = float(args[1]) - result = round(float(value) / nearest) * nearest + value = Decimal(args[0] or 0.0) + nearest = Decimal(args[1]) + result = round(value / nearest) * nearest if nearest % 1 == 0: return str(int(result)) return str(result) From b7a481ec79063e92c8bcb1dadd265056d4069463 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 8 Sep 2023 13:16:17 +1000 Subject: [PATCH 037/190] IfcFM now uses Openpyxl and supports xlsx. Reorder columns to be more logical and group similar data types together (at least for me). --- src/ifcfm/ifcfm.py | 89 +++++++++++++++++++++++++++++++++------------- 1 file changed, 64 insertions(+), 25 deletions(-) diff --git a/src/ifcfm/ifcfm.py b/src/ifcfm/ifcfm.py index 0662f42b0d..9a4da9649f 100644 --- a/src/ifcfm/ifcfm.py +++ b/src/ifcfm/ifcfm.py @@ -29,7 +29,8 @@ import ifcopenshell.util.placement import ifcopenshell.util.classification try: - from xlsxwriter import Workbook + from openpyxl import Workbook + from openpyxl.styles import PatternFill except: pass # No XLSX support @@ -123,25 +124,25 @@ class Writer: if preset == "BASIC": self.config = { "Actors": {"colours": "ppssssssss", "sort": [{"name": "Name", "order": "ASC"}]}, - "Facilities": {"colours": "prppppsssseeee", "sort": [{"name": "Name", "order": "ASC"}]}, + "Facilities": {"colours": "ppppreeeeessss", "sort": [{"name": "Name", "order": "ASC"}]}, "Storeys": { - "colours": "prppeees", + "colours": "ppreeees", "sort": [{"name": "Elevation", "order": "ASC"}, {"name": "Name", "order": "ASC"}], }, "Spaces": { - "colours": "prpprpeess", + "colours": "ppprreeess", "sort": [{"name": "LevelName", "order": "ASC"}, {"name": "Name", "order": "ASC"}], }, - "Zones": {"colours": "prpree", "sort": [{"name": "Name", "order": "ASC"}]}, + "Zones": {"colours": "prreee", "sort": [{"name": "Name", "order": "ASC"}]}, "Types": { - "colours": "prpppeeee", + "colours": "pppreeeee", "sort": [{"name": "ModelObject", "order": "ASC"}, {"name": "Name", "order": "ASC"}], }, "Elements": { - "colours": "prprrreee", + "colours": "prrrreeee", "sort": [{"name": "TypeName", "order": "ASC"}, {"name": "Name", "order": "ASC"}], }, - "Systems": {"colours": "pprppee", "sort": [{"name": "Name", "order": "ASC"}]}, + "Systems": {"colours": "pppreee", "sort": [{"name": "Name", "order": "ASC"}]}, } def write(self, null="N/A", empty="-", bool_true="YES", bool_false="NO"): @@ -234,6 +235,44 @@ class Writer: doc.save(output, True) + def write_xlsx(self, output): + workbook = Workbook() + + cell_formats = {} + for key, value in self.colours.items(): + fill = PatternFill(start_color=value, end_color=value, fill_type="solid") + cell_formats[key] = fill + + for category, data in self.categories.items(): + colours = self.config.get(category, {}).get("colours", []) + + if category in workbook.sheetnames: + worksheet = workbook[category] + else: + worksheet = workbook.create_sheet(category) + + r = 1 # Openpyxl uses 1-based indexing + c = 1 + for header in data["headers"]: + cell = worksheet.cell(row=r, column=c, value=header) + cell.fill = cell_formats["h"] + c += 1 + + r += 1 + for row in data["rows"]: + c = 1 + for col in row: + if c > len(colours): # Adjusted the comparison + cell_format = "n" + else: + cell_format = colours[c - 1] # Adjusted the indexing + cell = worksheet.cell(row=r, column=c, value=col) + cell.fill = cell_formats[cell_format] + c += 1 + r += 1 + + workbook.save(output) + def get_actors(ifc_file): return ifc_file.by_type("IfcActor") @@ -295,19 +334,19 @@ def get_facility_data(ifc_file, element): return { "key": element.Name, "Name": element.Name, - "AuthorOrganizationName": get_owner_name(element), - "AuthorDate": get_owner_creation_date(ifc_file.by_type("IfcProject")[0]), - "Category": get_classification(element), "ProjectName": ifc_file.by_type("IfcProject")[0].Name, "SiteName": getattr(get_facility_parent(element, "IfcSite"), "Name", None), - "LinearUnits": "millimeters", - "AreaUnits": "square meters", - "AreaMeasurement": "BIM Software", - "Phase": ifc_file.by_type("IfcProject")[0].Phase, + "Category": get_classification(element), + "AuthorOrganizationName": get_owner_name(element), + "AuthorDate": get_owner_creation_date(ifc_file.by_type("IfcProject")[0]), "ModelSoftware": get_owner_application(element), "ModelProjectID": ifc_file.by_type("IfcProject")[0].GlobalId, "ModelSiteID": getattr(get_facility_parent(element, "IfcSite"), "GlobalId", None), "ModelBuildingID": element.GlobalId, + "LinearUnits": "millimeters", + "AreaUnits": "square meters", + "AreaMeasurement": "BIM Software", + "Phase": ifc_file.by_type("IfcProject")[0].Phase, } @@ -315,9 +354,9 @@ def get_storey_data(ifc_file, element): return { "key": element.Name, "Name": element.Name, + "Category": "Level", "AuthorOrganizationName": get_owner_name(element), "AuthorDate": get_owner_creation_date(element), - "Category": "Level", "ModelSoftware": get_owner_application(element), "ModelObject": element.is_a(), "ModelID": element.GlobalId, @@ -330,11 +369,11 @@ def get_space_data(ifc_file, element): return { "key": element.Name, "Name": element.Name, - "AuthorOrganizationName": get_owner_name(element), - "AuthorDate": get_owner_creation_date(element), + "Description": element.LongName, "Category": get_classification(element), "LevelName": getattr(get_facility_parent(element, "IfcBuildingStorey"), "Name", None), - "Description": element.LongName, + "AuthorOrganizationName": get_owner_name(element), + "AuthorDate": get_owner_creation_date(element), "ModelSoftware": get_owner_application(element), "ModelID": element.GlobalId, "AreaGross": get_property(psets, "Qto_SpaceBaseQuantities", "GrossFloorArea", decimals=2), @@ -347,9 +386,9 @@ def get_zone_data(ifc_file, element): return { "key": (element.Name or "Unnamed") + (space.Name or "Unnamed"), "Name": zone.Name, + "SpaceName": space.Name, "AuthorOrganizationName": get_owner_name(zone), "AuthorDate": get_owner_creation_date(zone), - "SpaceName": space.Name, "ModelSoftware": get_owner_application(zone), "ModelID": zone.GlobalId, } @@ -359,10 +398,10 @@ def get_type_data(ifc_file, element): return { "key": element.Name, "Name": element.Name, + "Description": element.Description, + "Category": get_classification(element), "AuthorOrganizationName": get_owner_name(element), "AuthorDate": get_owner_creation_date(element), - "Category": get_classification(element), - "Description": element.Description, "ModelSoftware": get_owner_application(element), "ModelObject": "{}[{}]".format(element.is_a(), ifcopenshell.util.element.get_predefined_type(element)), "ModelTag": element.Tag, @@ -378,11 +417,11 @@ def get_element_data(ifc_file, element): return { "key": element.Name, "Name": element.Name, - "AuthorOrganizationName": get_owner_name(element), - "AuthorDate": get_owner_creation_date(element), "TypeName": ifcopenshell.util.element.get_type(element).Name, "SpaceName": space_name, "SystemName": system, + "AuthorOrganizationName": get_owner_name(element), + "AuthorDate": get_owner_creation_date(element), "ModelSoftware": get_owner_application(element), "ModelObject": "{}[{}]".format(element.is_a(), ifcopenshell.util.element.get_predefined_type(element)), "ModelID": element.GlobalId, @@ -394,9 +433,9 @@ def get_system_data(ifc_file, element): "key": element.Name, "Name": element.Name, "Description": element.Description, + "Category": get_classification(element), "AuthorOrganizationName": get_owner_name(element), "AuthorDate": get_owner_creation_date(element), - "Category": get_classification(element), "ModelSoftware": get_owner_application(element), "ModelID": element.GlobalId, } From aae659c5fd5986b23903e5b4545a0bbe6e0010ed Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 8 Sep 2023 15:52:44 +1000 Subject: [PATCH 038/190] Fix bug when casting to tessellation for invalid IFCs which don't use material sets properly. --- src/blenderbim/blenderbim/bim/module/geometry/operator.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/blenderbim/blenderbim/bim/module/geometry/operator.py b/src/blenderbim/blenderbim/bim/module/geometry/operator.py index 21e998bc67..d3ddc4222f 100644 --- a/src/blenderbim/blenderbim/bim/module/geometry/operator.py +++ b/src/blenderbim/blenderbim/bim/module/geometry/operator.py @@ -272,7 +272,8 @@ class UpdateRepresentation(bpy.types.Operator, Operator): if self.ifc_representation_class == "IfcTessellatedFaceSet": # We are explicitly casting to a tessellation, so remove all parametric materials. element_type = ifcopenshell.util.element.get_type(product) - ifcopenshell.api.run("material.unassign_material", tool.Ifc.get(), product=element_type) + if element_type: # Some invalid IFCs use material sets without a type. + ifcopenshell.api.run("material.unassign_material", tool.Ifc.get(), product=element_type) ifcopenshell.api.run("material.unassign_material", tool.Ifc.get(), product=product) else: # These objects are parametrically based on an axis and should not be modified as a mesh From 0ea38384ccef848d67cc3cfdc6e46a602b5deda6 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 8 Sep 2023 17:44:33 +1000 Subject: [PATCH 039/190] Minor fix after 6887210 --- src/blenderbim/blenderbim/bim/module/geometry/operator.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/geometry/operator.py b/src/blenderbim/blenderbim/bim/module/geometry/operator.py index d3ddc4222f..04f63b3578 100644 --- a/src/blenderbim/blenderbim/bim/module/geometry/operator.py +++ b/src/blenderbim/blenderbim/bim/module/geometry/operator.py @@ -707,7 +707,7 @@ class OverrideDuplicateMove(bpy.types.Operator): new = blenderbim.core.root.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=new_obj) # clean up the orphaned mesh with ifc id of the original object to avoid confusion - if temp_data: + if new and temp_data: tool.Blender.remove_data_block(temp_data) if new: @@ -1435,7 +1435,9 @@ class OverrideModeSetEdit(bpy.types.Operator): else: obj.select_set(False) continue - if len(selected_objs) > 1 and (not context.selected_objects or len(context.selected_objects) != len(selected_objs)): + if len(selected_objs) > 1 and ( + not context.selected_objects or len(context.selected_objects) != len(selected_objs) + ): # We are trying to edit at least one non-mesh-like object : Display a hint to the user self.report({"INFO"}, "Only mesh-compatible representations may be edited concurrently in edit mode.") From 09620e060fff6f97cbf0a501660f222620d72975 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Thu, 7 Sep 2023 18:36:19 +0500 Subject: [PATCH 040/190] some DumbWallJoiner.join() refactor --- .../blenderbim/bim/module/model/wall.py | 83 +++++++++++-------- .../blenderbim/bim/module/model/workspace.py | 7 +- 2 files changed, 55 insertions(+), 35 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/model/wall.py b/src/blenderbim/blenderbim/bim/module/model/wall.py index a10dce6b4c..639189b236 100644 --- a/src/blenderbim/blenderbim/bim/module/model/wall.py +++ b/src/blenderbim/blenderbim/bim/module/model/wall.py @@ -1119,8 +1119,13 @@ class DumbWallJoiner: other = tool.Ifc.get_object(rel.RelatedElement) if connection not in ["ATPATH", "NOTDEFINED"]: self.join( - obj, other, connection, rel.RelatedConnectionType, is_relating=True, description=rel.Description - ) + obj, + other, + connection, + rel.RelatedConnectionType, + is_relating=True, + description=rel.Description + ) for rel in element.ConnectedFrom: if rel.is_a("IfcRelConnectsPathElements"): connection = rel.RelatedConnectionType @@ -1299,7 +1304,7 @@ class DumbWallJoiner: # The user has moved the wall into an invalid position that cannot connect at the desired end return False - self.axis[1 if connection1 == "ATEND" else 0] = intersect + self.axis = proposed_axis # Work out body @@ -1309,55 +1314,65 @@ class DumbWallJoiner: tp1 = wall1.matrix_world @ Vector(wall1.bound_box[1]) # Axis lines on bottom, for reference, base, and side axes - bra1 = (Vector((*axis1["reference"][0], bp1[2])), Vector((*axis1["reference"][1], bp1[2]))) - bba1 = (Vector((*axis1["base"][0], bp1[2])), Vector((*axis1["base"][1], bp1[2]))) - bsa1 = (Vector((*axis1["side"][0], bp1[2])), Vector((*axis1["side"][1], bp1[2]))) - bba2 = (Vector((*axis2["base"][0], bp2[2])), Vector((*axis2["base"][1], bp2[2]))) - bsa2 = (Vector((*axis2["side"][0], bp2[2])), Vector((*axis2["side"][1], bp2[2]))) + def to_3d_axis(axis, z): + return (Vector((*axis[0], z)), Vector((*axis[1], z))) + + bra1 = to_3d_axis(axis1["reference"], bp1.z) + bba1 = to_3d_axis(axis1["base"], bp1.z) + bsa1 = to_3d_axis(axis1["side"], bp1.z) + bba2 = to_3d_axis(axis2["base"], bp2.z) + bsa2 = to_3d_axis(axis2["side"], bp2.z) # Intersecting the walls sides defined by planes gives 4 lines of intersection # Line point, and line direction - lp1, ld1 = mathutils.geometry.intersect_plane_plane(bba1[0], normal1, bba2[0], normal2) - lp2, ld2 = mathutils.geometry.intersect_plane_plane(bba1[0], normal1, bsa2[0], normal2) - lp3, ld3 = mathutils.geometry.intersect_plane_plane(bsa1[0], normal1, bba2[0], normal2) - lp4, ld4 = mathutils.geometry.intersect_plane_plane(bsa1[0], normal1, bsa2[0], normal2) + lpb1, ldb1 = mathutils.geometry.intersect_plane_plane(bba1[0], normal1, bba2[0], normal2) + lpb2, ldb2 = mathutils.geometry.intersect_plane_plane(bba1[0], normal1, bsa2[0], normal2) + lps1, lds1 = mathutils.geometry.intersect_plane_plane(bsa1[0], normal1, bba2[0], normal2) + lps2, lds2 = mathutils.geometry.intersect_plane_plane(bsa1[0], normal1, bsa2[0], normal2) # Intersecting the 4 lines gives the 8 possible verts of intersection # 4 on bottom, and 4 on top. 4 on our base line, 4 on our side line. - bb1 = mathutils.geometry.intersect_line_plane(lp1, lp1 + ld1, bp1, Vector((0, 0, 1))) - bb2 = mathutils.geometry.intersect_line_plane(lp2, lp2 + ld2, bp1, Vector((0, 0, 1))) - bs1 = mathutils.geometry.intersect_line_plane(lp3, lp3 + ld3, bp1, Vector((0, 0, 1))) - bs2 = mathutils.geometry.intersect_line_plane(lp4, lp4 + ld4, bp1, Vector((0, 0, 1))) - tb1 = mathutils.geometry.intersect_line_plane(lp1, lp1 + ld1, tp1, Vector((0, 0, 1))) - tb2 = mathutils.geometry.intersect_line_plane(lp2, lp2 + ld2, tp1, Vector((0, 0, 1))) - ts1 = mathutils.geometry.intersect_line_plane(lp3, lp3 + ld3, tp1, Vector((0, 0, 1))) - ts2 = mathutils.geometry.intersect_line_plane(lp4, lp4 + ld4, tp1, Vector((0, 0, 1))) + # Diagram: https://i.imgur.com/jwWx2Ox.png + # NOTE: bb/bs always equal lpb/lps? + bb1 = mathutils.geometry.intersect_line_plane(lpb1, lpb1 + ldb1, bp1, Vector((0, 0, 1))) + bb2 = mathutils.geometry.intersect_line_plane(lpb2, lpb2 + ldb2, bp1, Vector((0, 0, 1))) + bs1 = mathutils.geometry.intersect_line_plane(lps1, lps1 + lds1, bp1, Vector((0, 0, 1))) + bs2 = mathutils.geometry.intersect_line_plane(lps2, lps2 + lds2, bp1, Vector((0, 0, 1))) + + # similar to bb/bs but also have local z offset + tb1 = mathutils.geometry.intersect_line_plane(lpb1, lpb1 + ldb1, tp1, Vector((0, 0, 1))) + tb2 = mathutils.geometry.intersect_line_plane(lpb2, lpb2 + ldb2, tp1, Vector((0, 0, 1))) + ts1 = mathutils.geometry.intersect_line_plane(lps1, lps1 + lds1, tp1, Vector((0, 0, 1))) + ts2 = mathutils.geometry.intersect_line_plane(lps2, lps2 + lds2, tp1, Vector((0, 0, 1))) # Let's distinguish the 8 points by whether they are nearer or further away from the other end # These 8 points will be used to find the final body position and clippings. - i = 0 if connection1 == "ATEND" else 1 - j = 1 if connection1 == "ATEND" else 0 - bbn = tool.Cad.closest_vector(axis1["base"][i].to_3d(), (bb1, bb2)) - bbf = bb2 if bbn == bb1 else bb1 - bsn = tool.Cad.closest_vector(axis1["side"][i].to_3d(), (bs1, bs2)) - bsf = bs2 if bsn == bs1 else bs1 - tbn = tool.Cad.closest_vector(axis1["base"][i].to_3d(), (tb1, tb2)) - tbf = tb2 if tbn == tb1 else tb1 - tsn = tool.Cad.closest_vector(axis1["side"][i].to_3d(), (ts1, ts2)) - tsf = ts2 if tsn == ts1 else ts1 + connected_at_end = connection1 == "ATEND" + i = 0 if connected_at_end else 1 + def get_closest_and_furthest_vectors(point_2d, vectors): + closest = tool.Cad.closest_vector(point_2d.to_3d(), vectors) + farthest = vectors[1] if closest == vectors[0] else vectors[0] + return closest, farthest + + bbn, bbf = get_closest_and_furthest_vectors(axis1["base"][i], (bb1, bb2)) + bsn, bsf = get_closest_and_furthest_vectors(axis1["side"][i], (bs1, bs2)) + tbn, tbf = get_closest_and_furthest_vectors(axis1["base"][i], (tb1, tb2)) + tsn, tsf = get_closest_and_furthest_vectors(axis1["side"][i], (ts1, ts2)) + + j = 1 if connected_at_end else 0 if description == "MITRE": # Mitre joints are an unofficial convention bsf_ = tool.Cad.point_on_edge(bsf, bba1) tbf_ = tool.Cad.point_on_edge(tbf, bba1) tsf_ = tool.Cad.point_on_edge(tsf, bba1) - new_body = tool.Cad.furthest_vector(bba1[i], (bbf, bsf_)).copy() - new_body = tool.Cad.furthest_vector(bba1[i], (new_body, tbf_)).copy() + new_body = tool.Cad.furthest_vector(bba1[i], (bbf, bsf_)) + new_body = tool.Cad.furthest_vector(bba1[i], (new_body, tbf_)) new_body = tool.Cad.furthest_vector(bba1[i], (new_body, tsf_)).copy() self.body[j] = tool.Cad.point_on_edge(new_body, bra1).to_2d() if connection1 == connection2: - if (connection1 == "ATEND" and angle > 0) or (connection1 != "ATEND" and angle < 0): + if (connected_at_end and angle > 0) or (not connected_at_end and angle < 0): pt = bbf.to_2d().to_3d() x_axis = bsn - bbf y_axis = tbf - bbf @@ -1366,7 +1381,7 @@ class DumbWallJoiner: x_axis = bsf - bbn y_axis = tbn - bbn else: - if (connection1 == "ATEND" and angle < 0) or (connection1 != "ATEND" and angle > 0): + if (connected_at_end and angle < 0) or (not connected_at_end and angle > 0): pt = bbf.to_2d().to_3d() x_axis = bsn - bbf y_axis = tbf - bbf diff --git a/src/blenderbim/blenderbim/bim/module/model/workspace.py b/src/blenderbim/blenderbim/bim/module/model/workspace.py index 998de01b1b..282f311d3c 100644 --- a/src/blenderbim/blenderbim/bim/module/model/workspace.py +++ b/src/blenderbim/blenderbim/bim/module/model/workspace.py @@ -300,7 +300,12 @@ class BimToolUI: add_layout_hotkey_operator(cls.layout, "Extend", "S_E", "") add_layout_hotkey_operator(cls.layout, "Butt", "S_T", "") - add_layout_hotkey_operator(cls.layout, "Mitre", "S_Y", "") + add_layout_hotkey_operator( + cls.layout, + "Mitre", + "S_Y", + "Join two intersecting walls using a mitre joint.\nOther selected wall is connected to the active", + ) add_layout_hotkey_operator(cls.layout, "Merge", "S_M", bpy.ops.bim.merge_wall.__doc__) add_layout_hotkey_operator(cls.layout, "Flip", "S_F", bpy.ops.bim.flip_wall.__doc__) From ffbb91216fc711bab197f8e50f08a7e388cc3645 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 8 Sep 2023 15:45:28 +0500 Subject: [PATCH 041/190] Fixed issue using mitre with small angles between walls (#3721) Normalization is unrelated, just added to be safe. --- .../blenderbim/bim/module/model/wall.py | 27 ++++++++++++++++--- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/model/wall.py b/src/blenderbim/blenderbim/bim/module/model/wall.py index 639189b236..2544234f64 100644 --- a/src/blenderbim/blenderbim/bim/module/model/wall.py +++ b/src/blenderbim/blenderbim/bim/module/model/wall.py @@ -1319,6 +1319,7 @@ class DumbWallJoiner: bra1 = to_3d_axis(axis1["reference"], bp1.z) bba1 = to_3d_axis(axis1["base"], bp1.z) + tba1 = to_3d_axis(axis1["base"], tp1.z) bsa1 = to_3d_axis(axis1["side"], bp1.z) bba2 = to_3d_axis(axis2["base"], bp2.z) bsa2 = to_3d_axis(axis2["side"], bp2.z) @@ -1350,14 +1351,29 @@ class DumbWallJoiner: connected_at_end = connection1 == "ATEND" i = 0 if connected_at_end else 1 - def get_closest_and_furthest_vectors(point_2d, vectors): - closest = tool.Cad.closest_vector(point_2d.to_3d(), vectors) + def get_closest_and_furthest_vectors(ref_point_2d, vectors, clamp_axis=None): + def clamp_point_by_direction(point, edge): + percent = tool.Cad.edge_percent(point, edge) + if percent < 0: + return edge[0] + return point + + # When there is a small angle between walls, intersection points can occur outside the wall's axis. + # Which can lead to inaccuracies - therefore we bottom clamp them to stay within the axis + if clamp_axis: + # if wall connected at the start then reference point will be at the end + # therefore we reverse the axis + if not connected_at_end: + clamp_axis = clamp_axis[::-1] + vectors = tuple([clamp_point_by_direction(v, clamp_axis) for v in vectors]) + + closest = tool.Cad.closest_vector(ref_point_2d.to_3d(), vectors) farthest = vectors[1] if closest == vectors[0] else vectors[0] return closest, farthest - bbn, bbf = get_closest_and_furthest_vectors(axis1["base"][i], (bb1, bb2)) + bbn, bbf = get_closest_and_furthest_vectors(axis1["base"][i], (bb1, bb2), bba1) bsn, bsf = get_closest_and_furthest_vectors(axis1["side"][i], (bs1, bs2)) - tbn, tbf = get_closest_and_furthest_vectors(axis1["base"][i], (tb1, tb2)) + tbn, tbf = get_closest_and_furthest_vectors(axis1["base"][i], (tb1, tb2), tba1) tsn, tsf = get_closest_and_furthest_vectors(axis1["side"][i], (ts1, ts2)) j = 1 if connected_at_end else 0 @@ -1392,6 +1408,9 @@ class DumbWallJoiner: if connection1 != "ATEND": y_axis *= -1 + + x_axis.normalize() + y_axis.normalize() z_axis = x_axis.cross(y_axis) y_axis = z_axis.cross(x_axis) From fca90e9b0f43cb20f31ff6429f697cf6843962f3 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 8 Sep 2023 17:08:33 +0500 Subject: [PATCH 042/190] automatically adjust viewport camera on loading ifc It will make sure you'll be able to see the project's elements in viewport when it's loaded (same way as if you'd select all objects and hit "Numpad ."). Couldn't find any way to make it work without enablging / disabling selection - https://projects.blender.org/blender/blender/issues/112141 --- src/blenderbim/blenderbim/bim/import_ifc.py | 8 ++++++++ src/blenderbim/blenderbim/tool/blender.py | 3 ++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/blenderbim/blenderbim/bim/import_ifc.py b/src/blenderbim/blenderbim/bim/import_ifc.py index 5c20aea10c..f235f652d0 100644 --- a/src/blenderbim/blenderbim/bim/import_ifc.py +++ b/src/blenderbim/blenderbim/bim/import_ifc.py @@ -318,6 +318,7 @@ class IfcImporter: self.profile_code("Merging by colour") self.set_default_context() self.profile_code("Setting default context") + self.setup_viewport_camera() self.update_progress(100) bpy.context.window_manager.progress_end() @@ -1913,6 +1914,13 @@ class IfcImporter: obj.matrix_world = matrix_world tool.Geometry.record_object_position(obj) + def setup_viewport_camera(self): + context_override = tool.Blender.get_viewport_context() + with bpy.context.temp_override(**context_override): + bpy.ops.object.select_all(action="SELECT") + bpy.ops.view3d.view_selected() + bpy.ops.object.select_all(action="DESELECT") + class IfcImportSettings: def __init__(self): diff --git a/src/blenderbim/blenderbim/tool/blender.py b/src/blenderbim/blenderbim/tool/blender.py index 6f6bbd3628..20f825ab25 100644 --- a/src/blenderbim/blenderbim/tool/blender.py +++ b/src/blenderbim/blenderbim/tool/blender.py @@ -167,7 +167,8 @@ class Blender: when in real life you can have a couple of those but should work for the most cases. """ area = next(area for area in bpy.context.screen.areas if area.type == "VIEW_3D") - context_override = {"area": area} + region = next(region for region in area.regions if region.type == "WINDOW") + context_override = {"area": area, "region": region} return context_override @classmethod From d4e47178578c16dddf05ff72f179c7ddb42dfe12 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 8 Sep 2023 17:46:18 +0500 Subject: [PATCH 043/190] Fixed issue creating slabs in IFC2X3 #3696 Error traceback is below - it was trying to set IfcExtrudedAreaSolid.Position to None when in IFC2x3 Position wasn't optional, so now in IFC2X3 it's going to have some default position RuntimeError: Error: Python: Traceback (most recent call last): File "\bim\module\model\product.py", line 123, in execute return IfcStore.execute_ifc_operator(self, context) File "\bim\ifc.py", line 336, in execute_ifc_operator result = getattr(operator, "_execute")(context) File "\bim\module\model\product.py", line 148, in _execute if self.generate_layered_element(ifc_class, relating_type): File "\bim\module\model\product.py", line 274, in generate_layered_element obj = slab.DumbSlabGenerator(relating_type).generate() File "\bim\module\model\slab.py", line 143, in generate return self.derive_from_cursor() File "\bim\module\model\slab.py", line 147, in derive_from_cursor return self.create_slab() File "\bim\module\model\slab.py", line 179, in create_slab representation = ifcopenshell.api.run( File "\libs\site\packages\ifcopenshell\api\__init__.py", line 66, in run result = usecase_class(ifc_file, **settings).execute() File "\libs\site\packages\ifcopenshell\api\geometry\add_slab_representation.py", line 42, in execute [self.create_item()], File "\libs\site\packages\ifcopenshell\api\geometry\add_slab_representation.py", line 58, in create_item extrusion = self.file.createIfcExtrudedAreaSolid( File "\libs\site\packages\ifcopenshell\file.py", line 337, in create_entity e[idx] = arg File "\libs\site\packages\ifcopenshell\entity_instance.py", line 260, in __setitem__ self.wrapped_data.setArgumentAsNull(idx) File "\libs\site\packages\ifcopenshell\ifcopenshell_wrapper.py", line 4929, in setArgumentAsNull return _ifcopenshell_wrapper.entity_instance_setArgumentAsNull(self, i) RuntimeError: Attribute not set --- .../api/geometry/add_slab_representation.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_slab_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_slab_representation.py index 251c81ad7a..b70b5267d7 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_slab_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_slab_representation.py @@ -26,7 +26,7 @@ class Usecase: self.settings = { "context": None, # IfcGeometricRepresentationContext "depth": 0.2, - "x_angle": 0, # Radians + "x_angle": 0, # Radians # Planes are defined as a matrix. The XY plane is the clipping boundary and +Z is removed. "clippings": [], # A list of planes that define clipping half space solids } @@ -55,9 +55,19 @@ class Usecase: ) else: extrusion_direction = self.file.createIfcDirection((0.0, 0.0, 1.0)) + + position = None + # default position for IFC2X3 where .Position is not optional + if self.file.schema == "IFC2X3": + position = self.file.createIfcAxis2Placement3D( + self.file.createIfcCartesianPoint((0.0, 0.0, 0.0)), + self.file.createIfcDirection((0.0, 0.0, 1.0)), + self.file.createIfcDirection((1.0, 0.0, 0.0)), + ) + extrusion = self.file.createIfcExtrudedAreaSolid( self.file.createIfcArbitraryClosedProfileDef("AREA", None, curve), - None, + position, extrusion_direction, self.convert_si_to_unit(self.settings["depth"]) * 1 / cos(self.settings["x_angle"]), ) From bf80e4b5e164ce04a7ea854457ccaa7b22461922 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 8 Sep 2023 19:23:03 +0500 Subject: [PATCH 044/190] Considering parametric data for fitting #3695 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Search for compatible fitting wasn't taking into account that maybe fitting used for the same segments but fitting's parameters are not the same (such as start_length, end_length, angle and offset between profiles). I also forgot to promote transition angle to operator's property😬 --- .../blenderbim/bim/module/model/mep.py | 87 ++++++++++++++----- src/blenderbim/blenderbim/tool/cad.py | 4 +- .../ifcopenshell/util/shape_builder.py | 11 +-- 3 files changed, 71 insertions(+), 31 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/model/mep.py b/src/blenderbim/blenderbim/bim/module/model/mep.py index eb7fae3ee2..934902cf91 100644 --- a/src/blenderbim/blenderbim/bim/module/model/mep.py +++ b/src/blenderbim/blenderbim/bim/module/model/mep.py @@ -40,6 +40,7 @@ from copy import copy from mathutils import Vector, Matrix from ifcopenshell.util.shape_builder import ShapeBuilder from blenderbim.bim.module.model.profile import DumbProfileJoiner +from blenderbim.tool.cad import VTX_PRECISION V = lambda *x: Vector([float(i) for i in x]) @@ -47,7 +48,8 @@ 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." + "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"} @@ -335,7 +337,7 @@ class MEPGenerator: 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): + 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. @@ -348,9 +350,12 @@ class MEPGenerator: 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. """ - # TODO: check angle, start, end and offset for transitions if not isinstance(segment_or_segments, collections.abc.Iterable): segments = [segment_or_segments] ports = [port_or_ports] @@ -358,6 +363,10 @@ class MEPGenerator: 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 + segments_data = [] for segment, port in zip(segments, ports, strict=True): segment_type = ifcopenshell.util.element.get_type(segment) @@ -366,6 +375,28 @@ class MEPGenerator: return segments_data.append((segment_type, port.PredefinedType, port.SystemType)) + # TODO: test it with flipped transition where start length != end length + def compatible_with_bbim_data(fitting_type): + 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"] + for key in bbim_data: + requested_value = bbim_data[key] + fitting_value = fitting_bbim_data[key] + if isinstance(requested_value, float): + compare_precision = None 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) + if not compare: + 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] @@ -392,7 +423,7 @@ class MEPGenerator: # 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 exact cases + # 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): @@ -407,13 +438,13 @@ class MEPGenerator: 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) @@ -451,12 +482,14 @@ class MEPGenerator: fitting_data.append((element_type, port.PredefinedType, port.SystemType)) - # if we skipped the occurrence we still can other occurrences + # if we skipped the occurrence we still need to check other occurrences # otherwise checking 1 occurrence is enough if not skipped_the_occurrence: - if are_connected_elements_compatible(segments_data, fitting_data): + 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) - return + break def create_obstruction_type(self, segment): # code is very similar to "bim.add_type" @@ -596,6 +629,9 @@ class MEPAddTransition(bpy.types.Operator, tool.Ifc.Operator): end_length: bpy.props.FloatProperty( name="End Length", description="Transition end length in SI units", default=0.1, subtype="DISTANCE" ) + angle: bpy.props.FloatProperty( + name="Transition Angle", description="Transition angle in degrees", default=pi / 6, subtype="ANGLE" + ) 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) @@ -685,23 +721,18 @@ class MEPAddTransition(bpy.types.Operator, tool.Ifc.Operator): flip_profile_offset = base_transition_dir.dot(start_object_z_basis) < 0 if tool.Cad.are_edges_collinear(start_axis, end_axis): - profile_offset = None + 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 - if tool.Cad.is_x(profile_offset.length_squared, 0): - profile_offset = None - else: - profile_offset = profile_offset / si_conversion - if flip_profile_offset: - profile_offset *= V(1, -1) + profile_offset = profile_offset / si_conversion + if flip_profile_offset: + profile_offset *= V(1, -1) # world space profile offset - profile_offset_ws = ( - start_object_rotation @ (profile_offset * si_conversion).to_3d() if profile_offset else V(0, 0, 0) - ) + profile_offset_ws = start_object_rotation @ (profile_offset * si_conversion).to_3d() def get_segments_length(): start_dir = (start_point - first_segment_start).normalized() @@ -721,6 +752,7 @@ class MEPAddTransition(bpy.types.Operator, tool.Ifc.Operator): end_element, self.start_length / si_conversion, self.end_length / si_conversion, + angle=degrees(self.angle), profile_offset=profile_offset, ) @@ -760,9 +792,16 @@ class MEPAddTransition(bpy.types.Operator, tool.Ifc.Operator): DumbProfileJoiner().join_E(start_object, start_segment_extend_point, start_connection) DumbProfileJoiner().join_E(end_object, end_segment_extend_point, end_connection) + parametric_data = { + "start_length": self.start_length / si_conversion, + "end_length": self.end_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" + [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 @@ -818,7 +857,6 @@ class MEPAddTransition(bpy.types.Operator, tool.Ifc.Operator): start_port, end_port = end_port, start_port tool.Ifc.run("system.connect_port", port1=ports[0], port2=start_port, direction="NOTDEFINED") tool.Ifc.run("system.connect_port", port1=ports[1], port2=end_port, direction="NOTDEFINED") - return {"FINISHED"} @@ -872,7 +910,9 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator): # 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() + 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) @@ -900,8 +940,7 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator): 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.", + "Segments types do not match " "or one of the segments doesn't have type which is required for a bend.", ) return {"CANCELLED"} diff --git a/src/blenderbim/blenderbim/tool/cad.py b/src/blenderbim/blenderbim/tool/cad.py index 5a2c9190d9..d45921ecab 100644 --- a/src/blenderbim/blenderbim/tool/cad.py +++ b/src/blenderbim/blenderbim/tool/cad.py @@ -100,8 +100,8 @@ class Cad: return (x + tolerance) > value > (x - tolerance) @classmethod - def are_vectors_equal(cls, v1: Vector, v2: Vector): - return cls.is_x((v2 - v1).length, 0) + def are_vectors_equal(cls, v1: Vector, v2: Vector, tolerance: float = None): + return cls.is_x((v2 - v1).length, 0, tolerance) @classmethod def intersect_edges(cls, edge1, edge2): diff --git a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py index 0de74a7667..1a6c5b9c12 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py +++ b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py @@ -917,7 +917,7 @@ class ShapeBuilder: # TODO: move MEP to separate shape builder sub module def mep_transition_shape( - self, start_segment, end_segment, start_length, end_length, angle=30.0, profile_offset=None + self, start_segment, end_segment, start_length, end_length, angle=30.0, profile_offset=V(0, 0).freeze() ): """ returns tuple of Model/Body/MODEL_VIEW IfcRepresentation and transition shape data @@ -979,8 +979,7 @@ class ShapeBuilder: faces = [] end_extrusion_offset.z += transition_length - if profile_offset: - end_extrusion_offset.xy += profile_offset + end_extrusion_offset.xy += profile_offset if start_profile.is_a("IfcRectangleProfileDef") and end_profile.is_a("IfcRectangleProfileDef"): # no transitions for exactly the same profiles @@ -1115,10 +1114,12 @@ class ShapeBuilder: body = ifcopenshell.util.representation.get_context(self.file, "Model", "Body", "MODEL_VIEW") representation = self.get_representation(body, transition_items, "Tesselation") + transition_data = { "start_length": start_length, "end_length": end_length, "angle": angle, + "profile_offset": profile_offset, "transition_length": transition_length, "full_transition_length": start_length + transition_length + end_length, } @@ -1127,7 +1128,7 @@ class ShapeBuilder: # 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): + def mep_transition_length(self, start_half_dim, end_half_dim, angle, profile_offset=V(0, 0).freeze(), 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 @@ -1138,7 +1139,7 @@ class ShapeBuilder: # 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) + offset = 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]) From 0285d97e3f235a9e24573b26a5303b7945b97ab4 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 9 Sep 2023 21:26:57 +1000 Subject: [PATCH 045/190] Reimplement IfcCOBie contacts, facilities, floors, and spaces in IfcFM. --- src/ifcfm/ifcfm.py | 494 --------------------------- src/ifcfm/ifcfm/__init__.py | 243 +++++++++++++ src/ifcfm/ifcfm/basic.py | 391 +++++++++++++++++++++ src/ifcfm/ifcfm/cobie.py | 625 ++++++++++++++++++++++++++++++++++ src/ifcfm/ifcfm/parser.py | 658 ------------------------------------ src/ifcfm/ifcfm/util.py | 94 ------ src/ifcfm/ifcfm/writer.py | 344 ------------------- 7 files changed, 1259 insertions(+), 1590 deletions(-) delete mode 100644 src/ifcfm/ifcfm.py create mode 100644 src/ifcfm/ifcfm/__init__.py create mode 100644 src/ifcfm/ifcfm/basic.py create mode 100644 src/ifcfm/ifcfm/cobie.py delete mode 100644 src/ifcfm/ifcfm/parser.py delete mode 100644 src/ifcfm/ifcfm/util.py delete mode 100644 src/ifcfm/ifcfm/writer.py diff --git a/src/ifcfm/ifcfm.py b/src/ifcfm/ifcfm.py deleted file mode 100644 index 9a4da9649f..0000000000 --- a/src/ifcfm/ifcfm.py +++ /dev/null @@ -1,494 +0,0 @@ -# IfcFM - IFC for facility management -# Copyright (C) 2023 Dion Moult -# -# This file is part of IfcFM. -# -# IfcFM 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. -# -# IfcFM 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 IfcFM. If not, see . - -import os -import re -import csv -import datetime -import ifcopenshell -import ifcopenshell.util.fm -import ifcopenshell.util.date -import ifcopenshell.util.schema -import ifcopenshell.util.system -import ifcopenshell.util.placement -import ifcopenshell.util.classification - -try: - from openpyxl import Workbook - from openpyxl.styles import PatternFill -except: - pass # No XLSX support - -try: - from odf.opendocument import OpenDocumentSpreadsheet - from odf.style import Style, TableCellProperties - from odf.table import Table, TableRow, TableCell - from odf.text import P -except: - pass # No ODF support - - -class Parser: - def __init__(self, preset="BASIC"): - self.file = None - self.categories = {} - self.get_category_elements = {} - self.get_element_data = {} - self.get_custom_element_data = {} - self.duplicate_keys = [] - - if preset == "BASIC": - self.get_category_elements = { - "Actors": get_actors, - "Facilities": get_facilities, - "Storeys": get_storeys, - "Spaces": get_spaces, - "Zones": get_zones, - "Types": get_types, - "Elements": get_elements, - "Systems": get_systems, - } - self.get_element_data = { - "Actors": get_actor_data, - "Facilities": get_facility_data, - "Storeys": get_storey_data, - "Spaces": get_space_data, - "Zones": get_zone_data, - "Types": get_type_data, - "Elements": get_element_data, - "Systems": get_system_data, - } - - def parse(self, ifc_file): - for category_name, get_category_elements in self.get_category_elements.items(): - self.categories.setdefault(category_name, {}) - for element in get_category_elements(ifc_file): - get_element_data = self.get_element_data[category_name] - - if isinstance(get_element_data, dict): - data = {} - for key, query in get_element_data.items(): - data[key] = ifcopenshell.util.selector.get_element_value(element, query) - else: - data = get_element_data(ifc_file, element) or {} - - get_custom_element_data = self.get_custom_element_data.get(category_name, lambda x, y: None) - if isinstance(get_element_data, dict): - custom_data = {} - for key, query in get_custom_element_data.items(): - custom_data[key] = ifcopenshell.util.selector.get_element_value(element, query) - else: - custom_data = get_custom_element_data(ifc_file, element) or {} - - data.update(custom_data) - - if data: - key = data["key"] - del data["key"] - if key in self.categories[category_name]: - self.duplicate_keys.append((self.categories[category_name][key], data)) - self.categories[category_name][key] = data - - -class Writer: - def __init__(self, parser, colours=None, preset="BASIC"): - self.parser = parser - if colours: - self.colours = colours - else: - self.colours = { - "h": "dddddd", # Header data - "p": "dc8774", # Primary identification data - "s": "b8dd73", # Secondary asset data - "r": "eda786", # Internal reference - "e": "96c7d0", # External / autogenerated data - "o": "ddb873", # Conditional / optional data - "n": "eeeeee", # Other data - "b": "000000", # Not in scope - } - if preset == "BASIC": - self.config = { - "Actors": {"colours": "ppssssssss", "sort": [{"name": "Name", "order": "ASC"}]}, - "Facilities": {"colours": "ppppreeeeessss", "sort": [{"name": "Name", "order": "ASC"}]}, - "Storeys": { - "colours": "ppreeees", - "sort": [{"name": "Elevation", "order": "ASC"}, {"name": "Name", "order": "ASC"}], - }, - "Spaces": { - "colours": "ppprreeess", - "sort": [{"name": "LevelName", "order": "ASC"}, {"name": "Name", "order": "ASC"}], - }, - "Zones": {"colours": "prreee", "sort": [{"name": "Name", "order": "ASC"}]}, - "Types": { - "colours": "pppreeeee", - "sort": [{"name": "ModelObject", "order": "ASC"}, {"name": "Name", "order": "ASC"}], - }, - "Elements": { - "colours": "prrrreeee", - "sort": [{"name": "TypeName", "order": "ASC"}, {"name": "Name", "order": "ASC"}], - }, - "Systems": {"colours": "pppreee", "sort": [{"name": "Name", "order": "ASC"}]}, - } - - def write(self, null="N/A", empty="-", bool_true="YES", bool_false="NO"): - self.categories = {} - for category, data in self.parser.categories.items(): - if not data: - self.categories[category] = {"headers": [], "rows": []} - continue - - headers = list(data[list(data.keys())[0]].keys()) - rows = [] - for row in data.values(): - processed_row = [] - for value in row.values(): - if value is None: - value = null - elif value == "": - value = empty - elif value is True: - value = bool_true - elif value is False: - value = bool_false - processed_row.append(value) - rows.append(processed_row) - - sort = self.config.get(category, {}).get("sort", None) - if sort: - - def natural_sort(value): - if isinstance(value, str): - convert = lambda text: int(text) if text.isdigit() else text.lower() - return [convert(c) for c in re.split("([0-9]+)", value)] - return value - - # Sort least important keys first, then more important keys. - # https://stackoverflow.com/questions/11476371/sort-by-multiple-keys-using-different-orderings - for sort_data in reversed(sort): - i = headers.index(sort_data["name"]) - reverse = sort_data["order"] == "DESC" - rows = sorted(rows, key=lambda x: natural_sort(x[i]), reverse=reverse) - self.categories[category] = {"headers": headers, "rows": rows} - - def write_csv(self, output, delimiter=","): - filename = None - if len(self.categories.keys()) == 1 and "." in os.path.basename(output): - filename = output - for category, data in self.categories.items(): - category_filename = filename or os.path.join(output, f"{category}.csv") - with open(category_filename, "w", newline="", encoding="utf-8") as f: - writer = csv.writer(f, delimiter=delimiter) - writer.writerow(data["headers"]) - for row in data["rows"]: - writer.writerow(row) - - def write_ods(self, output): - doc = OpenDocumentSpreadsheet() - - for key, value in self.colours.items(): - style = Style(name=key, family="table-cell") - style.addElement(TableCellProperties(backgroundcolor="#" + value)) - doc.automaticstyles.addElement(style) - - for category, data in self.categories.items(): - colours = self.config.get(category, {}).get("colours", []) - - table = Table(name=category) - tr = TableRow() - for header in data["headers"]: - tc = TableCell(valuetype="string", stylename="h") - tc.addElement(P(text=header)) - tr.addElement(tc) - table.addElement(tr) - for row in data["rows"]: - tr = TableRow() - c = 0 - for col in row: - if c >= len(colours): - cell_format = "n" - else: - cell_format = colours[c] - tc = TableCell(valuetype="string", stylename=cell_format) - tc.addElement(P(text=str(col))) - tr.addElement(tc) - c += 1 - table.addElement(tr) - doc.spreadsheet.addElement(table) - - if len(output) > 4 and output[-4:].lower() == ".ods": - output = output[0:-4] - - doc.save(output, True) - - def write_xlsx(self, output): - workbook = Workbook() - - cell_formats = {} - for key, value in self.colours.items(): - fill = PatternFill(start_color=value, end_color=value, fill_type="solid") - cell_formats[key] = fill - - for category, data in self.categories.items(): - colours = self.config.get(category, {}).get("colours", []) - - if category in workbook.sheetnames: - worksheet = workbook[category] - else: - worksheet = workbook.create_sheet(category) - - r = 1 # Openpyxl uses 1-based indexing - c = 1 - for header in data["headers"]: - cell = worksheet.cell(row=r, column=c, value=header) - cell.fill = cell_formats["h"] - c += 1 - - r += 1 - for row in data["rows"]: - c = 1 - for col in row: - if c > len(colours): # Adjusted the comparison - cell_format = "n" - else: - cell_format = colours[c - 1] # Adjusted the indexing - cell = worksheet.cell(row=r, column=c, value=col) - cell.fill = cell_formats[cell_format] - c += 1 - r += 1 - - workbook.save(output) - - -def get_actors(ifc_file): - return ifc_file.by_type("IfcActor") - - -def get_facilities(ifc_file): - return ifc_file.by_type("IfcBuilding") - - -def get_storeys(ifc_file): - return ifc_file.by_type("IfcBuildingStorey") - - -def get_spaces(ifc_file): - return ifc_file.by_type("IfcSpace") - - -def get_zones(ifc_file): - zones = [] - for zone in ifc_file.by_type("IfcZone"): - for rel in zone.IsGroupedBy: - zones.extend([(zone, space) for space in rel.RelatedObjects]) - return zones - - -def get_types(ifc_file): - return ifcopenshell.util.fm.get_fmhem_types(ifc_file) - - -def get_elements(ifc_file): - elements = set() - for element_type in ifcopenshell.util.fm.get_fmhem_types(ifc_file): - elements.update(ifcopenshell.util.element.get_types(element_type)) - return elements - - -def get_systems(ifc_file): - return ifc_file.by_type("IfcSystem") - - -def get_actor_data(ifc_file, element): - return { - "key": element.TheActor.Name, - "Name": element.TheActor.Name, - "Category": get_classification(element), - "Email": get_actor_address(element, "ElectronicMailAddresses"), - "Phone": get_actor_address(element, "TelephoneNumbers"), - "CompanyURL": get_actor_address(element, "WWWHomePageURL"), - "Department": get_actor_address(element, "InternalLocation"), - "Address1": get_actor_address(element, "AddressLines"), - "Address2": get_actor_address(element, "Town"), - "StateRegion": get_actor_address(element, "Region"), - "PostalCode": get_actor_address(element, "PostalCode"), - "Country": get_actor_address(element, "Country"), - } - - -def get_facility_data(ifc_file, element): - return { - "key": element.Name, - "Name": element.Name, - "ProjectName": ifc_file.by_type("IfcProject")[0].Name, - "SiteName": getattr(get_facility_parent(element, "IfcSite"), "Name", None), - "Category": get_classification(element), - "AuthorOrganizationName": get_owner_name(element), - "AuthorDate": get_owner_creation_date(ifc_file.by_type("IfcProject")[0]), - "ModelSoftware": get_owner_application(element), - "ModelProjectID": ifc_file.by_type("IfcProject")[0].GlobalId, - "ModelSiteID": getattr(get_facility_parent(element, "IfcSite"), "GlobalId", None), - "ModelBuildingID": element.GlobalId, - "LinearUnits": "millimeters", - "AreaUnits": "square meters", - "AreaMeasurement": "BIM Software", - "Phase": ifc_file.by_type("IfcProject")[0].Phase, - } - - -def get_storey_data(ifc_file, element): - return { - "key": element.Name, - "Name": element.Name, - "Category": "Level", - "AuthorOrganizationName": get_owner_name(element), - "AuthorDate": get_owner_creation_date(element), - "ModelSoftware": get_owner_application(element), - "ModelObject": element.is_a(), - "ModelID": element.GlobalId, - "Elevation": ifcopenshell.util.placement.get_storey_elevation(element), - } - - -def get_space_data(ifc_file, element): - psets = ifcopenshell.util.element.get_psets(element) - return { - "key": element.Name, - "Name": element.Name, - "Description": element.LongName, - "Category": get_classification(element), - "LevelName": getattr(get_facility_parent(element, "IfcBuildingStorey"), "Name", None), - "AuthorOrganizationName": get_owner_name(element), - "AuthorDate": get_owner_creation_date(element), - "ModelSoftware": get_owner_application(element), - "ModelID": element.GlobalId, - "AreaGross": get_property(psets, "Qto_SpaceBaseQuantities", "GrossFloorArea", decimals=2), - "AreaNet": get_property(psets, "Qto_SpaceBaseQuantities", "NetFloorArea", decimals=2), - } - - -def get_zone_data(ifc_file, element): - zone, space = element - return { - "key": (element.Name or "Unnamed") + (space.Name or "Unnamed"), - "Name": zone.Name, - "SpaceName": space.Name, - "AuthorOrganizationName": get_owner_name(zone), - "AuthorDate": get_owner_creation_date(zone), - "ModelSoftware": get_owner_application(zone), - "ModelID": zone.GlobalId, - } - - -def get_type_data(ifc_file, element): - return { - "key": element.Name, - "Name": element.Name, - "Description": element.Description, - "Category": get_classification(element), - "AuthorOrganizationName": get_owner_name(element), - "AuthorDate": get_owner_creation_date(element), - "ModelSoftware": get_owner_application(element), - "ModelObject": "{}[{}]".format(element.is_a(), ifcopenshell.util.element.get_predefined_type(element)), - "ModelTag": element.Tag, - "ModelID": element.GlobalId, - } - - -def get_element_data(ifc_file, element): - space = ifcopenshell.util.element.get_container(element) - space_name = space.Name if space.is_a("IfcSpace") else None - systems = ifcopenshell.util.system.get_element_systems(element) - system = systems[0].Name if systems else None - return { - "key": element.Name, - "Name": element.Name, - "TypeName": ifcopenshell.util.element.get_type(element).Name, - "SpaceName": space_name, - "SystemName": system, - "AuthorOrganizationName": get_owner_name(element), - "AuthorDate": get_owner_creation_date(element), - "ModelSoftware": get_owner_application(element), - "ModelObject": "{}[{}]".format(element.is_a(), ifcopenshell.util.element.get_predefined_type(element)), - "ModelID": element.GlobalId, - } - - -def get_system_data(ifc_file, element): - return { - "key": element.Name, - "Name": element.Name, - "Description": element.Description, - "Category": get_classification(element), - "AuthorOrganizationName": get_owner_name(element), - "AuthorDate": get_owner_creation_date(element), - "ModelSoftware": get_owner_application(element), - "ModelID": element.GlobalId, - } - - -def get_owner_name(element): - if not getattr(element, "OwnerHistory", None): - return - return element.OwnerHistory.OwningUser.TheOrganization.Name - - -def get_owner_creation_date(element): - if not getattr(element, "OwnerHistory", None): - return - return ifcopenshell.util.date.ifc2datetime(element.OwnerHistory.CreationDate).isoformat() - - -def get_owner_application(element): - if not getattr(element, "OwnerHistory", None): - return - return element.OwnerHistory.OwningApplication.ApplicationFullName - - -def get_facility_parent(element, ifc_class): - parent = ifcopenshell.util.element.get_aggregate(element) - while parent: - if parent.is_a(ifc_class): - return parent - if parent.is_a("IfcProject"): - return - parent = ifcopenshell.util.element.get_aggregate(parent) - - -def get_classification(element): - references = list(ifcopenshell.util.classification.get_references(element)) - if references: - if hasattr(references[0], "Identification"): - return "{}:{}".format(references[0].Identification, references[0].Name) - return "{}:{}".format(references[0].ItemReference, references[0].Name) - - -def get_actor_address(element, name): - for address in element.TheActor.Addresses or []: - if hasattr(address, name) and getattr(address, name, None): - result = getattr(address, name) - if isinstance(result, tuple): - return result[0] - return result - - -def get_property(psets, pset_name, prop_name, decimals=None): - if pset_name in psets: - result = psets[pset_name].get(prop_name, None) - if decimals is None or result is None: - return result - return round(result, decimals) diff --git a/src/ifcfm/ifcfm/__init__.py b/src/ifcfm/ifcfm/__init__.py new file mode 100644 index 0000000000..ce8f0960b8 --- /dev/null +++ b/src/ifcfm/ifcfm/__init__.py @@ -0,0 +1,243 @@ +# IfcFM - IFC for facility management +# Copyright (C) 2023 Dion Moult +# +# This file is part of IfcFM. +# +# IfcFM 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. +# +# IfcFM 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 IfcFM. If not, see . + +import os +import re +import csv +import importlib + +try: + from openpyxl import Workbook + from openpyxl.styles import PatternFill +except: + pass # No XLSX support + +try: + from odf.opendocument import OpenDocumentSpreadsheet + from odf.style import Style, TableCellProperties + from odf.table import Table, TableRow, TableCell + from odf.text import P +except: + pass # No ODF support + + +class Parser: + def __init__(self, preset="basic"): + self.file = None + self.preset = preset + self.categories = {} + self.get_category_elements = {} + self.get_element_data = {} + self.get_custom_element_data = {} + self.duplicate_keys = [] + + if isinstance(preset, str): + module = importlib.import_module(f"ifcfm.{preset}") + self.get_category_elements = getattr(module, "get_category_elements") + self.get_element_data = getattr(module, "get_element_data") + else: + self.get_category_elements = getattr(preset, "get_category_elements") + self.get_element_data = getattr(preset, "get_element_data") + + def parse(self, ifc_file): + for category_name, get_category_elements in self.get_category_elements.items(): + self.categories.setdefault(category_name, {}) + for element in get_category_elements(ifc_file): + get_element_data = self.get_element_data[category_name] + + if isinstance(get_element_data, dict): + data = {} + for key, query in get_element_data.items(): + data[key] = ifcopenshell.util.selector.get_element_value(element, query) + else: + data = get_element_data(ifc_file, element) or {} + + get_custom_element_data = self.get_custom_element_data.get(category_name, lambda x, y: None) + if isinstance(get_element_data, dict): + custom_data = {} + for key, query in get_custom_element_data.items(): + custom_data[key] = ifcopenshell.util.selector.get_element_value(element, query) + else: + custom_data = get_custom_element_data(ifc_file, element) or {} + + data.update(custom_data) + + if data: + key = data["key"] + del data["key"] + if key in self.categories[category_name]: + self.duplicate_keys.append((self.categories[category_name][key], data)) + self.categories[category_name][key] = data + + +class Writer: + def __init__(self, parser, colours=None): + self.parser = parser + if colours: + self.colours = colours + else: + self.colours = { + "h": "dddddd", # Header data + "p": "dc8774", # Primary identification data + "s": "b8dd73", # Secondary asset data + "r": "eda786", # Internal reference + "e": "96c7d0", # External / autogenerated data + "o": "ddb873", # Conditional / optional data + "n": "eeeeee", # Other data + "b": "000000", # Not in scope + } + + if isinstance(self.parser.preset, str): + module = importlib.import_module(f"ifcfm.{self.parser.preset}") + self.config = getattr(module, "config") + else: + self.config = getattr(self.parser.preset, "config") + + + def write(self, null="N/A", empty="-", bool_true="YES", bool_false="NO"): + self.categories = {} + for category, data in self.parser.categories.items(): + headers = self.config.get(category, {}).get("headers", []) + + if not data: + self.categories[category] = {"headers": headers, "rows": []} + continue + + if not headers: + headers = list(data[list(data.keys())[0]].keys()) + + rows = [] + for row in data.values(): + processed_row = [] + for header in headers: + value = row[header] + if value is None: + value = null + elif value == "": + value = empty + elif value is True: + value = bool_true + elif value is False: + value = bool_false + processed_row.append(value) + rows.append(processed_row) + + sort = self.config.get(category, {}).get("sort", None) + if sort: + + def natural_sort(value): + if isinstance(value, str): + convert = lambda text: int(text) if text.isdigit() else text.lower() + return [convert(c) for c in re.split("([0-9]+)", value)] + return value + + # Sort least important keys first, then more important keys. + # https://stackoverflow.com/questions/11476371/sort-by-multiple-keys-using-different-orderings + for sort_data in reversed(sort): + i = headers.index(sort_data["name"]) + reverse = sort_data["order"] == "DESC" + rows = sorted(rows, key=lambda x: natural_sort(x[i]), reverse=reverse) + self.categories[category] = {"headers": headers, "rows": rows} + + def write_csv(self, output, delimiter=","): + filename = None + if len(self.categories.keys()) == 1 and "." in os.path.basename(output): + filename = output + for category, data in self.categories.items(): + category_filename = filename or os.path.join(output, f"{category}.csv") + with open(category_filename, "w", newline="", encoding="utf-8") as f: + writer = csv.writer(f, delimiter=delimiter) + writer.writerow(data["headers"]) + for row in data["rows"]: + writer.writerow(row) + + def write_ods(self, output): + doc = OpenDocumentSpreadsheet() + + for key, value in self.colours.items(): + style = Style(name=key, family="table-cell") + style.addElement(TableCellProperties(backgroundcolor="#" + value)) + doc.automaticstyles.addElement(style) + + for category, data in self.categories.items(): + colours = self.config.get(category, {}).get("colours", []) + + table = Table(name=category) + tr = TableRow() + for header in data["headers"]: + tc = TableCell(valuetype="string", stylename="h") + tc.addElement(P(text=header)) + tr.addElement(tc) + table.addElement(tr) + for row in data["rows"]: + tr = TableRow() + c = 0 + for col in row: + if c >= len(colours): + cell_format = "n" + else: + cell_format = colours[c] + tc = TableCell(valuetype="string", stylename=cell_format) + tc.addElement(P(text=str(col))) + tr.addElement(tc) + c += 1 + table.addElement(tr) + doc.spreadsheet.addElement(table) + + if len(output) > 4 and output[-4:].lower() == ".ods": + output = output[0:-4] + + doc.save(output, True) + + def write_xlsx(self, output): + workbook = Workbook() + + cell_formats = {} + for key, value in self.colours.items(): + fill = PatternFill(start_color=value, end_color=value, fill_type="solid") + cell_formats[key] = fill + + for category, data in self.categories.items(): + colours = self.config.get(category, {}).get("colours", []) + + if category in workbook.sheetnames: + worksheet = workbook[category] + else: + worksheet = workbook.create_sheet(category) + + r = 1 # Openpyxl uses 1-based indexing + c = 1 + for header in data["headers"]: + cell = worksheet.cell(row=r, column=c, value=header) + cell.fill = cell_formats["h"] + c += 1 + + r += 1 + for row in data["rows"]: + c = 1 + for col in row: + if c > len(colours): # Adjusted the comparison + cell_format = "n" + else: + cell_format = colours[c - 1] # Adjusted the indexing + cell = worksheet.cell(row=r, column=c, value=col) + cell.fill = cell_formats[cell_format] + c += 1 + r += 1 + + workbook.save(output) diff --git a/src/ifcfm/ifcfm/basic.py b/src/ifcfm/ifcfm/basic.py new file mode 100644 index 0000000000..74c3074d75 --- /dev/null +++ b/src/ifcfm/ifcfm/basic.py @@ -0,0 +1,391 @@ +# IfcFM - IFC for facility management +# Copyright (C) 2023 Dion Moult +# +# This file is part of IfcFM. +# +# IfcFM 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. +# +# IfcFM 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 IfcFM. If not, see . + +import ifcopenshell +import ifcopenshell.util.fm +import ifcopenshell.util.date +import ifcopenshell.util.system +import ifcopenshell.util.placement +import ifcopenshell.util.classification + + +def get_actors(ifc_file): + return ifc_file.by_type("IfcActor") + + +def get_facilities(ifc_file): + return ifc_file.by_type("IfcBuilding") + + +def get_storeys(ifc_file): + return ifc_file.by_type("IfcBuildingStorey") + + +def get_spaces(ifc_file): + return ifc_file.by_type("IfcSpace") + + +def get_zones(ifc_file): + zones = [] + for zone in ifc_file.by_type("IfcZone"): + for rel in zone.IsGroupedBy: + zones.extend([(zone, space) for space in rel.RelatedObjects]) + return zones + + +def get_types(ifc_file): + return ifcopenshell.util.fm.get_fmhem_types(ifc_file) + + +def get_elements(ifc_file): + elements = set() + for element_type in ifcopenshell.util.fm.get_fmhem_types(ifc_file): + elements.update(ifcopenshell.util.element.get_types(element_type)) + return elements + + +def get_systems(ifc_file): + return ifc_file.by_type("IfcSystem") + + +def get_actor_data(ifc_file, element): + return { + "key": element.TheActor.Name, + "Name": element.TheActor.Name, + "Category": get_classification(element), + "Email": get_actor_address(element, "ElectronicMailAddresses"), + "Phone": get_actor_address(element, "TelephoneNumbers"), + "CompanyURL": get_actor_address(element, "WWWHomePageURL"), + "Department": get_actor_address(element, "InternalLocation"), + "Address": get_actor_address(element, "AddressLines"), + "Town": get_actor_address(element, "Town"), + "Region": get_actor_address(element, "Region"), + "PostalCode": get_actor_address(element, "PostalCode"), + "Country": get_actor_address(element, "Country"), + } + + +def get_facility_data(ifc_file, element): + return { + "key": element.Name, + "Name": element.Name, + "ProjectName": ifc_file.by_type("IfcProject")[0].Name, + "SiteName": getattr(get_facility_parent(element, "IfcSite"), "Name", None), + "Category": get_classification(element), + "AuthorOrganizationName": get_owner_name(element), + "AuthorDate": get_owner_creation_date(element), + "ModelSoftware": get_owner_application(element), + "ModelProjectID": ifc_file.by_type("IfcProject")[0].GlobalId, + "ModelSiteID": getattr(get_facility_parent(element, "IfcSite"), "GlobalId", None), + "ModelBuildingID": element.GlobalId, + "LinearUnits": "millimeters", + "AreaUnits": "square meters", + "AreaMeasurement": "BIM Software", + "Phase": ifc_file.by_type("IfcProject")[0].Phase, + } + + +def get_storey_data(ifc_file, element): + return { + "key": element.Name, + "Name": element.Name, + "Category": "Level", + "AuthorOrganizationName": get_owner_name(element), + "AuthorDate": get_owner_creation_date(element), + "ModelSoftware": get_owner_application(element), + "ModelObject": element.is_a(), + "ModelID": element.GlobalId, + "Elevation": ifcopenshell.util.placement.get_storey_elevation(element), + } + + +def get_space_data(ifc_file, element): + psets = ifcopenshell.util.element.get_psets(element) + return { + "key": element.Name, + "Name": element.Name, + "Description": element.LongName, + "Category": get_classification(element), + "LevelName": getattr(get_facility_parent(element, "IfcBuildingStorey"), "Name", None), + "AuthorOrganizationName": get_owner_name(element), + "AuthorDate": get_owner_creation_date(element), + "ModelSoftware": get_owner_application(element), + "ModelID": element.GlobalId, + "AreaGross": get_property(psets, "Qto_SpaceBaseQuantities", "GrossFloorArea", decimals=2), + "AreaNet": get_property(psets, "Qto_SpaceBaseQuantities", "NetFloorArea", decimals=2), + } + + +def get_zone_data(ifc_file, element): + zone, space = element + return { + "key": (element.Name or "Unnamed") + (space.Name or "Unnamed"), + "Name": zone.Name, + "SpaceName": space.Name, + "AuthorOrganizationName": get_owner_name(zone), + "AuthorDate": get_owner_creation_date(zone), + "ModelSoftware": get_owner_application(zone), + "ModelID": zone.GlobalId, + } + + +def get_type_data(ifc_file, element): + return { + "key": element.Name, + "Name": element.Name, + "Description": element.Description, + "Category": get_classification(element), + "AuthorOrganizationName": get_owner_name(element), + "AuthorDate": get_owner_creation_date(element), + "ModelSoftware": get_owner_application(element), + "ModelObject": "{}[{}]".format(element.is_a(), ifcopenshell.util.element.get_predefined_type(element)), + "ModelTag": element.Tag, + "ModelID": element.GlobalId, + } + + +def get_element_data(ifc_file, element): + space = ifcopenshell.util.element.get_container(element) + space_name = space.Name if space.is_a("IfcSpace") else None + systems = ifcopenshell.util.system.get_element_systems(element) + system = systems[0].Name if systems else None + return { + "key": element.Name, + "Name": element.Name, + "TypeName": ifcopenshell.util.element.get_type(element).Name, + "SpaceName": space_name, + "SystemName": system, + "AuthorOrganizationName": get_owner_name(element), + "AuthorDate": get_owner_creation_date(element), + "ModelSoftware": get_owner_application(element), + "ModelObject": "{}[{}]".format(element.is_a(), ifcopenshell.util.element.get_predefined_type(element)), + "ModelID": element.GlobalId, + } + + +def get_system_data(ifc_file, element): + return { + "key": element.Name, + "Name": element.Name, + "Description": element.Description, + "Category": get_classification(element), + "AuthorOrganizationName": get_owner_name(element), + "AuthorDate": get_owner_creation_date(element), + "ModelSoftware": get_owner_application(element), + "ModelID": element.GlobalId, + } + + +def get_owner_name(element): + if not getattr(element, "OwnerHistory", None): + return + return element.OwnerHistory.OwningUser.TheOrganization.Name + + +def get_owner_creation_date(element): + if not getattr(element, "OwnerHistory", None): + return + return ifcopenshell.util.date.ifc2datetime(element.OwnerHistory.CreationDate).isoformat() + + +def get_owner_application(element): + if not getattr(element, "OwnerHistory", None): + return + return element.OwnerHistory.OwningApplication.ApplicationFullName + + +def get_facility_parent(element, ifc_class): + parent = ifcopenshell.util.element.get_aggregate(element) + while parent: + if parent.is_a(ifc_class): + return parent + if parent.is_a("IfcProject"): + return + parent = ifcopenshell.util.element.get_aggregate(parent) + + +def get_classification(element): + references = list(ifcopenshell.util.classification.get_references(element)) + if references: + if hasattr(references[0], "Identification"): + return "{}:{}".format(references[0].Identification, references[0].Name) + return "{}:{}".format(references[0].ItemReference, references[0].Name) + + +def get_actor_address(element, name): + actors = [] + if element.TheActor.is_a("IfcOrganization") or element.TheActor.is_a("IfcPerson"): + actors = [element.TheActor] + elif element.TheActor.is_a("IfcPersonAndOrganization"): + actors = [element.TheActor.TheOrganization, element.TheActor.ThePerson] + for actor in actors: + for address in actor.Addresses or []: + if hasattr(address, name) and getattr(address, name, None): + result = getattr(address, name) + if isinstance(result, tuple): + return result[0] + return result + + +def get_property(psets, pset_name, prop_name, decimals=None): + if pset_name in psets: + result = psets[pset_name].get(prop_name, None) + if decimals is None or result is None: + return result + return round(result, decimals) + + +get_category_elements = { + "Actors": get_actors, + "Facilities": get_facilities, + "Storeys": get_storeys, + "Spaces": get_spaces, + "Zones": get_zones, + "Types": get_types, + "Elements": get_elements, + "Systems": get_systems, +} + +get_element_data = { + "Actors": get_actor_data, + "Facilities": get_facility_data, + "Storeys": get_storey_data, + "Spaces": get_space_data, + "Zones": get_zone_data, + "Types": get_type_data, + "Elements": get_element_data, + "Systems": get_system_data, +} + +config = { + "Actors": { + "headers": [ + "Name", + "Category", + "Email", + "Phone", + "CompanyURL", + "Department", + "Address1", + "Address2", + "StateRegion", + "PostalCode", + "Country", + ], + "colours": "ppssssssss", + "sort": [{"name": "Name", "order": "ASC"}], + }, + "Facilities": { + "headers": [ + "Name", + "ProjectName", + "SiteName", + "Category", + "AuthorOrganizationName", + "AuthorDate", + "ModelSoftware", + "ModelProjectID", + "ModelSiteID", + "ModelBuildingID", + "LinearUnits", + "AreaUnits", + "AreaMeasurement", + "Phase", + ], + "colours": "ppppreeeeessss", + "sort": [{"name": "Name", "order": "ASC"}], + }, + "Storeys": { + "headers": [ + "Name", + "Category", + "AuthorOrganizationName", + "AuthorDate", + "ModelSoftware", + "ModelObject", + "ModelID", + "Elevation", + ], + "colours": "ppreeees", + "sort": [{"name": "Elevation", "order": "ASC"}, {"name": "Name", "order": "ASC"}], + }, + "Spaces": { + "headers": [ + "Name", + "Description", + "Category", + "LevelName", + "AuthorOrganizationName", + "AuthorDate", + "ModelSoftware", + "ModelID", + "AreaGross", + "AreaNet", + ], + "colours": "ppprreeess", + "sort": [{"name": "LevelName", "order": "ASC"}, {"name": "Name", "order": "ASC"}], + }, + "Zones": { + "headers": ["Name", "SpaceName", "AuthorOrganizationName", "AuthorDate", "ModelSoftware", "ModelID"], + "colours": "prreee", + "sort": [{"name": "Name", "order": "ASC"}], + }, + "Types": { + "headers": [ + "Name", + "Description", + "Category", + "AuthorOrganizationName", + "AuthorDate", + "ModelSoftware", + "ModelObject", + "ModelTag", + "ModelID", + ], + "colours": "pppreeeee", + "sort": [{"name": "ModelObject", "order": "ASC"}, {"name": "Name", "order": "ASC"}], + }, + "Elements": { + "headers": [ + "Name", + "TypeName", + "SpaceName", + "SystemName", + "AuthorOrganizationName", + "AuthorDate", + "ModelSoftware", + "ModelObject", + "ModelID", + ], + "colours": "prrrreeee", + "sort": [{"name": "TypeName", "order": "ASC"}, {"name": "Name", "order": "ASC"}], + }, + "Systems": { + "headers": [ + "Name", + "Description", + "Category", + "AuthorOrganizationName", + "AuthorDate", + "ModelSoftware", + "ModelID", + ], + "colours": "pppreee", + "sort": [{"name": "Name", "order": "ASC"}], + }, +} diff --git a/src/ifcfm/ifcfm/cobie.py b/src/ifcfm/ifcfm/cobie.py new file mode 100644 index 0000000000..d87fbfeeca --- /dev/null +++ b/src/ifcfm/ifcfm/cobie.py @@ -0,0 +1,625 @@ +# IfcFM - IFC for facility management +# Copyright (C) 2023 Dion Moult +# +# This file is part of IfcFM. +# +# IfcFM 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. +# +# IfcFM 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 IfcFM. If not, see . + +import ifcopenshell +import ifcopenshell.util.fm +import ifcopenshell.util.date +import ifcopenshell.util.system +import ifcopenshell.util.placement +import ifcopenshell.util.classification + + +# The original BIMServer plugin has a function called ifcToCOBie: +# https://github.com/opensourceBIM/COBie-plugins/blob/master/COBieShared/src/org/bimserver/cobie/shared/serialization/COBieTabSerializer.java#L54 +# This calls various serialisers here: +# https://github.com/opensourceBIM/COBie-plugins/tree/master/COBieShared/src/org/bimserver/cobie/shared/serialization/util +# Some settings are also defined here: +# https://github.com/opensourceBIM/COBie-plugins/blob/master/COBiePlugins/lib/IfcToCobieConfig.xml + + +def get_contacts(ifc_file): + return ifc_file.by_type("IfcPersonAndOrganization") + + +def get_facilities(ifc_file): + return ifc_file.by_type("IfcBuilding") + + +def get_floors(ifc_file): + return [ + e + for e in ifc_file.by_type("IfcBuildingStorey") + if ifcopenshell.util.element.get_aggregate(e).is_a("IfcBuilding") + ] + + +def get_spaces(ifc_file): + return ifc_file.by_type("IfcSpace") + + +def get_zones(ifc_file): + zones = [] + for zone in ifc_file.by_type("IfcZone"): + for rel in zone.IsGroupedBy: + zones.extend([(zone, space) for space in rel.RelatedObjects]) + return zones + + +def get_types(ifc_file): + return ifcopenshell.util.fm.get_cobie_types(ifc_file) + + +def get_components(ifc_file): + elements = set() + for element_type in ifcopenshell.util.fm.get_cobie_types(ifc_file): + elements.update(ifcopenshell.util.element.get_types(element_type)) + return elements + + +def get_systems(ifc_file): + return ifc_file.by_type("IfcSystem") + + +def get_contact_data(ifc_file, element): + email = get_email_from_pao(element) + + history = None + created_by = None + created_on = None + histories = ifc_file.by_type("IfcOwnerHistory") + if histories: + history = sorted(histories, key=lambda x: x.id())[-1] + + roles = [] + for actor in [element, element.ThePerson, element.TheOrganization]: + for role in actor.Roles or []: + if role.Role == "USERDEFINED": + if role.UserDefinedRole: + roles.append(role.UserDefinedRole) + else: + roles.append(role.Role) + + organization = element.TheOrganization + person = element.ThePerson + + department = get_pao_address(element, "InternalLocation") + if not department: + for rel in organization.Relates: + for org in rel.RelatedOrganizations: + if val(org.Name): + department = org.Name + + return { + "key": email, + "Email": email, + "CreatedBy": get_email_from_history(history) if history else None, + "CreatedOn": ifcopenshell.util.date.ifc2datetime(history.CreationDate).isoformat() if history else None, + "Category": ",".join(roles), + "Company": getattr(organization, "Name", None), + "Phone": get_pao_address(element, "TelephoneNumbers"), + "ExternalSystem": history.OwningApplication.ApplicationFullName if history else None, + "ExternalObject": element.is_a(), + "ExternalIdentifier": email, + "Department": department, + "OrganizationCode": getattr(organization, "Id", getattr(organization, "Identification", None)) + or organization.Name, + "GivenName": getattr(person, "GivenName", None), + "FamilyName": getattr(person, "FamilyName", None), + "Street": get_pao_address(element, "AddressLines"), + "PostalBox": get_pao_address(element, "PostalBox"), + "Town": get_pao_address(element, "Town"), + "StateRegion": get_pao_address(element, "Region"), + "PostalCode": get_pao_address(element, "PostalCode"), + "Country": get_pao_address(element, "Country"), + } + + +def get_facility_data(ifc_file, element): + site = get_facility_parent(element, "IfcSite") + site_name = None + site_description = None + if site: + site_name = val(site.Name) or val(site.LongName) or site.GlobalId + site_description = val(site.Description) or val(site.LongName) or val(site.Name) + + project = None + project_name = None + project_description = None + try: + project = ifc_file.by_type("IfcProject")[0] + project_name = val(project.Name) or val(project.LongName) or project.GlobalId + project_description = val(project.Description) or val(project.LongName) or val(project.Name) + except: + pass + + name = val(element.Name) or val(element.LongName) + if not name: + name = val(project.Name) or val(project.LongName) + if not name and site: + name = val(site.Name) or val(site.LongName) + + return { + "key": name, + "Name": name, + "CreatedBy": get_created_by(element), + "CreatedOn": get_created_on(element), + "Category": get_category(element), + "ProjectName": project_name, + "SiteName": site_name, + "LinearUnits": get_unit_name(ifc_file, "LENGTHUNIT"), + "AreaUnits": get_unit_name(ifc_file, "AREAUNIT"), + "VolumeUnits": get_unit_name(ifc_file, "VOLUMEUNIT"), + "CurrencyUnit": get_unit_name(ifc_file, "IfcMonetaryUnit"), + "AreaMeasurement": get_area_measurement(element), + "ExternalSystem": get_external_system(element), + "ExternalProjectObject": "IfcProject", + "ExternalProjectIdentifier": project.GlobalId if project else ifcopenshell.guid.new(), + "ExternalSiteObject": "IfcSite", + "ExternalSiteIdentifier": site.GlobalId if site else ifcopenshell.guid.new(), + "ExternalFacilityObject": "IfcBuilding", + "ExternalFacilityIdentifier": element.GlobalId, + "Description": val(element.Description) or val(element.LongName) or val(element.Name), + "ProjectDescription": project_description, + "SiteDescription": site_description, + "Phase": val(project.Phase) if project else None, + } + + +def get_floor_data(ifc_file, element): + external_object = element.is_a() + if external_object.ObjectType and external_object.ObjectType.lower() in ("site", "ifcsite"): + external_object = "IfcSite" + + height_names = { + "Height", + "NetHeight", + "GrossHeight", + "Net Height", + "Gross Height", + "StoreyHeight", + "Storey Height", + "FloorHeight", + "Floor Height", + } + + height = None + for _, props in ifcopenshell.util.element.get_psets(element): + if height is not None: + break + for name, value in props.items(): + if name in height_names and val(value): + height = str(value) + break + + return { + "key": var(element.Name), + "Name": var(element.Name), + "CreatedBy": get_created_by(element), + "CreatedOn": get_created_on(element), + "Category": get_category(element), + "ExternalSystem": get_external_system(element), + "ExternalObject": external_object, + "ExternalIdentifier": element.GlobalId, + "Description": val(element.Description) or val(element.LongName) or val(element.Name), + "Elevation": val(str(getattr(element, "Elevation", ""))), + "Height": height, + } + + +def get_space_data(ifc_file, element): + floor_name = None + floor = ifcopenshell.util.element.get_aggregate(element) + if floor and floor.is_a("IfcBuildingStorey"): + floor_name = val(floor.Name) + + room_tag = None + room_tag_names = {"RoomTag", "Tag", "Room Tag"} + usable_height = None + usable_height_names = {"FinishCeilingHeight", "Height", "UsableHeight"} + gross_area = None + gross_area_names = {"GrossFloorArea", "GSA"} + net_area = None + net_area_names = {"NetFloorArea", "GSA"} + for _, props in ifcopenshell.util.element.get_psets(element): + for name, value in props.items(): + if not room_tag and name in room_tag_names and val(value): + room_tag = str(value) + if not usable_height and name in usable_height_names and val(value): + usable_height = str(value) + if not gross_area and name in gross_area_names and val(value): + gross_area = str(value) + if not net_area and name in net_area_names and val(value): + net_area = str(value) + + return { + "key": val(element.Name), + "Name": val(element.Name), + "CreatedBy": get_created_by(element), + "CreatedOn": get_created_on(element), + "Category": get_category(element), + "FloorName": floor_name, + "Description": val(element.Description) or val(element.LongName) or val(element.Name), + "ExternalSystem": get_external_system(element), + "ExternalObject": element.is_a(), + "ExternalIdentifier": element.GlobalId, + "RoomTag": room_tag, + "UsableHeight": usable_height, + "GrossArea": gross_area, + "NetArea": net_area, + } + + +def get_zone_data(ifc_file, element): + zone, space = element + return { + "key": (element.Name or "Unnamed") + (space.Name or "Unnamed"), + "Name": zone.Name, + "SpaceName": space.Name, + "AuthorOrganizationName": get_owner_name(zone), + "AuthorDate": get_owner_creation_date(zone), + "ModelSoftware": get_external_system(zone), + "ModelID": zone.GlobalId, + } + + +def get_type_data(ifc_file, element): + return { + "key": element.Name, + "Name": element.Name, + "Description": element.Description, + "Category": get_classification(element), + "AuthorOrganizationName": get_owner_name(element), + "AuthorDate": get_owner_creation_date(element), + "ModelSoftware": get_external_system(element), + "ModelObject": "{}[{}]".format(element.is_a(), ifcopenshell.util.element.get_predefined_type(element)), + "ModelTag": element.Tag, + "ModelID": element.GlobalId, + } + + +def get_element_data(ifc_file, element): + space = ifcopenshell.util.element.get_container(element) + space_name = space.Name if space.is_a("IfcSpace") else None + systems = ifcopenshell.util.system.get_element_systems(element) + system = systems[0].Name if systems else None + return { + "key": element.Name, + "Name": element.Name, + "TypeName": ifcopenshell.util.element.get_type(element).Name, + "SpaceName": space_name, + "SystemName": system, + "AuthorOrganizationName": get_owner_name(element), + "AuthorDate": get_owner_creation_date(element), + "ModelSoftware": get_external_system(element), + "ModelObject": "{}[{}]".format(element.is_a(), ifcopenshell.util.element.get_predefined_type(element)), + "ModelID": element.GlobalId, + } + + +def get_system_data(ifc_file, element): + return { + "key": element.Name, + "Name": element.Name, + "Description": element.Description, + "Category": get_classification(element), + "AuthorOrganizationName": get_owner_name(element), + "AuthorDate": get_owner_creation_date(element), + "ModelSoftware": get_external_system(element), + "ModelID": element.GlobalId, + } + + +def get_unit_name(ifc_file, unit_type): + for unit in ifc_file.by_type("IfcUnitAssignment")[0].Units: + if unit.is_a("IfcNamedUnit") and unit.UnitType == unit_type: + if unit.is_a("IfcSIUnit"): + prefix = (unit.Prefix or "").lower() + if unit_type == "LENGTHUNIT": + return f"{prefix}meters" + elif unit_type == "AREAUNIT": + return f"square {prefix}meters" + elif unit_type == "VOLUMEUNIT": + return f"cubic {prefix}meters" + else: + return val(unit.Name) + elif unit.is_a("IfcMonetaryUnit") and unit_type == "IfcMonetaryUnit": + return val(unit.Currency) + + +def get_created_by(element): + if getattr(element, "OwnerHistory", None): + return get_email_from_history(element.OwnerHistory) + + +def get_email_from_history(element): + pao = element.OwningUser + if pao.is_a("IfcPersonAndOrganization"): + return get_email_from_pao(pao) + + +def get_email_from_pao(pao): + for address in pao.ThePerson.Addresses or []: + if address.is_a("IfcTelecomAddress") and address.ElectronicMailAddresses: + return address.ElectronicMailAddresses[0] + + for address in pao.TheOrganization.Addresses or []: + if address.is_a("IfcTelecomAddress") and address.ElectronicMailAddresses: + return address.ElectronicMailAddresses[0] + + person_id = getattr(pao.ThePerson, "Identification", getattr(pao.ThePerson, "Id", None)) + if person_id: + return person_id + + organization_id = getattr(pao.TheOrganization, "Identification", getattr(pao.TheOrganization, "Id", None)) + if organization_id: + return organization_id + + if pao.ThePerson.GivenName and pao.ThePerson.FamilyName and pao.TheOrganization.Name: + return pao.ThePerson.GivenName + pao.ThePerson.FamilyName + "@" + pao.TheOrganization.Name + ".com" + + +def get_owner_name(element): + if getattr(element, "OwnerHistory", None): + return element.OwnerHistory.OwningUser.TheOrganization.Name + + +def get_created_on(element): + if getattr(element, "OwnerHistory", None): + return ifcopenshell.util.date.ifc2datetime(element.OwnerHistory.CreationDate).isoformat() + + +def get_external_system(element): + if getattr(element, "OwnerHistory", None): + return val(element.OwnerHistory.OwningApplication.ApplicationFullName) + + +def get_facility_parent(element, ifc_class): + parent = ifcopenshell.util.element.get_aggregate(element) + while parent: + if parent.is_a(ifc_class): + return parent + if parent.is_a("IfcProject"): + return + parent = ifcopenshell.util.element.get_aggregate(parent) + + +def val(x): + return x if x not in ("", "n/a") else None + + +def get_area_measurement(element): + for relationship in getattr(element, "IsDefinedBy", []) or []: + if relationship.is_a("IfcRelDefinesByProperties"): + definition = relationship.RelatingPropertyDefinition + if definition.is_a("IfcElementQuantity") and val(definition.MethodOfMeasurement): + return definition.MethodOfMeasurement + for rel in getattr(element, "IsDecomposedBy", []): + for related_object in rel.RelatedObjects: + result = get_area_measurement(related_object) + if result: + return result + + +def get_category(element): + references = list(ifcopenshell.util.classification.get_references(element)) + results = [] + for reference in references: + if reference.is_a("IfcClassification"): + results.append(reference.Name) + elif reference.is_a("IfcClassificationReference"): + identification = val(getattr(reference, "Identification", getattr(reference, "ItemReference", None))) + if val(reference.Name) and identification and val(reference.Name) != identification: + results.append(identification + " : " + val(reference.Name)) + elif val(reference.Name): + results.append(reference.Name) + elif identification: + results.append(identification) + elif reference.ReferencedSource and val(reference.ReferencedSource.Name): + results.append(reference.ReferencedSource.Name) + elif val(reference.Location): + results.append(reference.Location) + if results: + return ",".join(results) + + category_props = [ + ("Assembly Code", "Assembly Description"), + ("Category Code", "Category Description"), + ("Classification Code", "Classification Description"), + ("OmniClass Number", "OmniClass Title"), + ("Uniclass Code", "Uniclass Description"), + ] + + psets = ifcopenshell.util.element.get_psets(element) + properties = {} + if psets: + for _, props in psets.items(): + properties.update(props) + + for code, description in category_props: + code = val(properties.get(code, None)) + if code: + description = val(properties.get(description, None)) + if code and description: + results.append(code + " : " + description) + else: + results.append(code) + if results: + return ",".join(results) + + return val(getattr(element, "ObjectType", None)) + + +def get_pao_address(element, name): + for actor in [element.TheActor.ThePerson, element.TheActor.TheOrganization]: + for address in actor.Addresses or []: + if hasattr(address, name) and getattr(address, name, None): + result = getattr(address, name) + if isinstance(result, tuple): + if name == "AddressLines": + return " ".join(result) + return result[0] + return result + + +def get_property(psets, pset_name, prop_name, decimals=None): + if pset_name in psets: + result = psets[pset_name].get(prop_name, None) + if decimals is None or result is None: + return result + return round(result, decimals) + + +get_category_elements = { + "Contact": get_contacts, + "Facility": get_facilities, + "Floor": get_floors, + "Space": get_spaces, + "Zone": get_zones, + "Type": get_types, + "Component": get_components, + "System": get_systems, +} + +get_element_data = { + "Contact": get_contact_data, + "Facility": get_facility_data, + "Floor": get_floor_data, + "Space": get_space_data, + "Zone": get_zone_data, + "Type": get_type_data, + "Component": get_component_data, + "System": get_system_data, +} + +config = { + "Actors": { + "headers": [ + "Name", + "Category", + "Email", + "Phone", + "CompanyURL", + "Department", + "Address1", + "Address2", + "StateRegion", + "PostalCode", + "Country", + ], + "colours": "ppssssssss", + "sort": [{"name": "Name", "order": "ASC"}], + }, + "Facilities": { + "headers": [ + "Name", + "ProjectName", + "SiteName", + "Category", + "AuthorOrganizationName", + "AuthorDate", + "ModelSoftware", + "ModelProjectID", + "ModelSiteID", + "ModelBuildingID", + "LinearUnits", + "AreaUnits", + "AreaMeasurement", + "Phase", + ], + "colours": "ppppreeeeessss", + "sort": [{"name": "Name", "order": "ASC"}], + }, + "Storeys": { + "headers": [ + "Name", + "Category", + "AuthorOrganizationName", + "AuthorDate", + "ModelSoftware", + "ModelObject", + "ModelID", + "Elevation", + ], + "colours": "ppreeees", + "sort": [{"name": "Elevation", "order": "ASC"}, {"name": "Name", "order": "ASC"}], + }, + "Spaces": { + "headers": [ + "Name", + "Description", + "Category", + "LevelName", + "AuthorOrganizationName", + "AuthorDate", + "ModelSoftware", + "ModelID", + "AreaGross", + "AreaNet", + ], + "colours": "ppprreeess", + "sort": [{"name": "LevelName", "order": "ASC"}, {"name": "Name", "order": "ASC"}], + }, + "Zones": { + "headers": ["Name", "SpaceName", "AuthorOrganizationName", "AuthorDate", "ModelSoftware", "ModelID"], + "colours": "prreee", + "sort": [{"name": "Name", "order": "ASC"}], + }, + "Types": { + "headers": [ + "Name", + "Description", + "Category", + "AuthorOrganizationName", + "AuthorDate", + "ModelSoftware", + "ModelObject", + "ModelTag", + "ModelID", + ], + "colours": "pppreeeee", + "sort": [{"name": "ModelObject", "order": "ASC"}, {"name": "Name", "order": "ASC"}], + }, + "Elements": { + "headers": [ + "Name", + "TypeName", + "SpaceName", + "SystemName", + "AuthorOrganizationName", + "AuthorDate", + "ModelSoftware", + "ModelObject", + "ModelID", + ], + "colours": "prrrreeee", + "sort": [{"name": "TypeName", "order": "ASC"}, {"name": "Name", "order": "ASC"}], + }, + "Systems": { + "headers": [ + "Name", + "Description", + "Category", + "AuthorOrganizationName", + "AuthorDate", + "ModelSoftware", + "ModelID", + ], + "colours": "pppreee", + "sort": [{"name": "Name", "order": "ASC"}], + }, +} diff --git a/src/ifcfm/ifcfm/parser.py b/src/ifcfm/ifcfm/parser.py deleted file mode 100644 index f74ce4ad78..0000000000 --- a/src/ifcfm/ifcfm/parser.py +++ /dev/null @@ -1,658 +0,0 @@ -# IfcFM - IFC for facility management -# Copyright (C) 2021 Dion Moult -# -# This file is part of IfcFM. -# -# IfcFM 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. -# -# IfcFM 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 IfcFM. If not, see . - -import datetime -import ifcopenshell -import ifcopenshell.util.fm -import ifcopenshell.util.selector -import ifcopenshell.util.date -import ifcopenshell.util.schema -import ifcopenshell.util.system -import ifcopenshell.util.placement -import ifcopenshell.util.classification - - -class Parser2: - def __init__(self, preset="BASIC"): - self.file = None - self.categories = {} - self.get_category_elements = {} - self.get_element_data = {} - self.get_custom_element_data = {} - self.duplicate_keys = [] - - if preset == "BASIC": - self.get_category_elements = { - "actors": get_actors, - "facilities": get_facilities, - "storeys": get_storeys, - "spaces": get_spaces, - "zones": get_zones, - "types": get_types, - "elements": get_elements, - "systems": get_systems, - } - self.get_element_data = { - "actors": get_actor_data, - "facilities": get_facility_data, - "storeys": get_storey_data, - "spaces": get_space_data, - "zones": get_zone_data, - "types": get_type_data, - "elements": get_element_data, - "systems": get_system_data, - } - - def parse(self, ifc_file): - for category_name, get_category_elements in self.get_category_elements.items(): - self.categories.setdefault(category_name, {}) - for element in get_category_elements(ifc_file): - data = self.get_element_data[category_name](ifc_file, element) or {} - custom_data = ( - self.get_custom_element_data.get(category_name, lambda x, y: None)(ifc_file, element) or {} - ) - data.update(custom_data) - - if data: - if data["key"] in self.categories[category_name]: - self.duplicate_keys.append((self.categories[category_name][data["key"]], data)) - self.categories[category_name][data["key"]] = data - - -def get_actors(ifc_file): - return ifc_file.by_type("IfcActor") - - -def get_facilities(ifc_file): - return ifc_file.by_type("IfcBuilding") - - -def get_storeys(ifc_file): - return ifc_file.by_type("IfcBuildingStorey") - - -def get_spaces(ifc_file): - return ifc_file.by_type("IfcSpace") - - -def get_zones(ifc_file): - zones = [] - for zone in ifc_file.by_type("IfcZone"): - for rel in zone.IsGroupedBy: - zones.extend([(zone, space) for space in rel.RelatedObjects]) - return zones - - -def get_types(ifc_file): - return ifcopenshell.util.fm.get_fmhem_types(ifc_file) - - -def get_elements(ifc_file): - elements = set() - for element_type in ifcopenshell.util.fm.get_fmhem_types(ifc_file): - elements.update(ifcopenshell.util.element.get_types(element_type)) - return elements - - -def get_systems(ifc_file): - return ifc_file.by_type("IfcSystem") - - -def get_actor_data(ifc_file, element): - return { - "key": element.TheActor.Name, - "Name": element.TheActor.Name, - "Category": get_classification(element), - "Email": get_actor_address(element, "ElectronicMailAddresses"), - "Phone": get_actor_address(element, "TelephoneNumbers"), - "CompanyURL": get_actor_address(element, "WWWHomePageURL"), - "Department": get_actor_address(element, "InternalLocation"), - "Address1": get_actor_address(element, "AddressLines"), - "Address2": get_actor_address(element, "Town"), - "StateRegion": get_actor_address(element, "Region"), - "PostalCode": get_actor_address(element, "PostalCode"), - "Country": get_actor_address(element, "Country"), - } - - -def get_facility_data(ifc_file, element): - return { - "key": element.Name, - "Name": element.Name, - "AuthorOrganizationName": get_owner_name(element), - "AuthorDate": get_owner_creation_date(ifc_file.by_type("IfcProject")[0]), - "Category": get_classification(element), - "ProjectName": ifc_file.by_type("IfcProject")[0].Name, - "SiteName": getattr(get_facility_parent(element, "IfcSite"), "Name", None), - "LinearUnits": "millimeters", - "AreaUnits": "square meters", - "AreaMeasurement": "BIM Software", - "Phase": ifc_file.by_type("IfcProject")[0].Phase, - "ModelSoftware": get_owner_application(element), - "ModelProjectID": ifc_file.by_type("IfcProject")[0].GlobalId, - "ModelSiteID": getattr(get_facility_parent(element, "IfcSite"), "GlobalId", None), - "ModelBuildingID": element.GlobalId, - } - - -def get_storey_data(ifc_file, element): - return { - "key": element.Name, - "Name": element.Name, - "AuthorOrganizationName": get_owner_name(element), - "AuthorDate": get_owner_creation_date(element), - "Category": "Level", - "ModelSoftware": get_owner_application(element), - "ModelObject": element.is_a(), - "ModelID": element.GlobalId, - "Elevation": ifcopenshell.util.placement.get_storey_elevation(element), - } - - -def get_space_data(ifc_file, element): - psets = ifcopenshell.util.element.get_psets(element) - return { - "key": element.Name, - "Name": element.Name, - "AuthorOrganizationName": get_owner_name(element), - "AuthorDate": get_owner_creation_date(element), - "Category": get_classification(element), - "LevelName": getattr(get_facility_parent(element, "IfcBuildingStorey"), "Name", None), - "Description": element.LongName, - "ModelSoftware": get_owner_application(element), - "ModelID": element.GlobalId, - "AreaGross": get_property(psets, "Qto_SpaceBaseQuantities", "GrossFloorArea", decimals=2), - "AreaNet": get_property(psets, "Qto_SpaceBaseQuantities", "NetFloorArea", decimals=2), - } - - -def get_zone_data(ifc_file, element): - zone, space = element - return { - "key": (element.Name or "Unnamed") + (space.Name or "Unnamed"), - "Name": zone.Name, - "AuthorOrganizationName": get_owner_name(zone), - "AuthorDate": get_owner_creation_date(zone), - "SpaceName": space.Name, - "ModelSoftware": get_owner_application(zone), - "ModelID": zone.GlobalId, - } - - -def get_type_data(ifc_file, element): - return { - "key": element.Name, - "Name": element.Name, - "AuthorOrganizationName": get_owner_name(element), - "AuthorDate": get_owner_creation_date(element), - "Category": get_classification(element), - "Description": element.Description, - "ModelSoftware": get_owner_application(element), - "ModelObject": "{}[{}]".format(element.is_a(), ifcopenshell.util.element.get_predefined_type(element)), - "ModelTag": element.Tag, - "ModelID": element.GlobalId, - } - - -def get_element_data(ifc_file, element): - space = ifcopenshell.util.element.get_container(element) - space_name = space.Name if space.is_a("IfcSpace") else None - systems = ifcopenshell.util.system.get_element_systems(element) - system = systems[0].Name if systems else None - return { - "key": element.Name, - "Name": element.Name, - "AuthorOrganizationName": get_owner_name(element), - "AuthorDate": get_owner_creation_date(element), - "TypeName": ifcopenshell.util.element.get_type(element).Name, - "SpaceName": space_name, - "SystemName": system, - "ModelSoftware": get_owner_application(element), - "ModelObject": "{}[{}]".format(element.is_a(), ifcopenshell.util.element.get_predefined_type(element)), - "ModelID": element.GlobalId, - } - - -def get_system_data(ifc_file, element): - return { - "key": element.Name, - "Name": element.Name, - "Description": element.Description, - "AuthorOrganizationName": get_owner_name(element), - "AuthorDate": get_owner_creation_date(element), - "Category": get_classification(element), - "ModelSoftware": get_owner_application(element), - "ModelID": element.GlobalId, - } - - -def get_owner_name(element): - if not getattr(element, "OwnerHistory", None): - return - return element.OwnerHistory.OwningUser.TheOrganization.Name - - -def get_owner_creation_date(element): - if not getattr(element, "OwnerHistory", None): - return - return ifcopenshell.util.date.ifc2datetime(element.OwnerHistory.CreationDate).isoformat() - - -def get_owner_application(element): - if not getattr(element, "OwnerHistory", None): - return - return element.OwnerHistory.OwningApplication.ApplicationFullName - - -def get_facility_parent(element, ifc_class): - parent = ifcopenshell.util.element.get_aggregate(element) - while parent: - if parent.is_a(ifc_class): - return parent - if parent.is_a("IfcProject"): - return - parent = ifcopenshell.util.element.get_aggregate(parent) - - -def get_classification(element): - references = list(ifcopenshell.util.classification.get_references(element)) - if references: - if hasattr(references[0], "Identification"): - return "{}:{}".format(references[0].Identification, references[0].Name) - return "{}:{}".format(references[0].ItemReference, references[0].Name) - - -def get_actor_address(element, name): - for address in element.TheActor.Addresses or []: - if hasattr(address, name) and getattr(address, name, None): - result = getattr(address, name) - if isinstance(result, tuple): - return result[0] - return result - - -def get_property(psets, pset_name, prop_name, decimals=None): - if pset_name in psets: - result = psets[pset_name].get(prop_name, None) - if decimals is None or result is None: - return result - return round(result, decimals) - - -class Parser: - def __init__(self, logger): - self.logger = logger - self.file = None - self.categories = { - "actors": self.get_actors, - "facilities": self.get_facilities, - "floors": self.get_floors, - "spaces": self.get_spaces, - "zones": self.get_zones, - "types": self.get_types, - "components": self.get_components, - "systems": self.get_systems, - # "assemblies", - # "connections", - # "spares", - # "resources", - # "jobs", - # "impacts", - "documents": self.get_documents, - # "attributes", - # "coordinates", - # "issues", - } - # COBie - self.custom_parameters = { - "types": { - "AssetType": lambda e, p: None, - "ManufacturerOrganizationName": lambda e, p: None, - "ModelNumber": lambda e, p: None, - "WarrantyGuarantorParts": lambda e, p: None, - "WarrantyDurationParts": lambda e, p: None, - "WarrantyGuarantorLabour": lambda e, p: None, - "WarrantyDurationLabour": lambda e, p: None, - "DurationUnit": lambda e, p: "months", - "WarrantyDescription": lambda e, p: None, - "ReplacementCost": lambda e, p: None, - "ExpectedLife": lambda e, p: None, - "NominalLength": lambda e, p: None, - "NominalWidth": lambda e, p: None, - "NominalHeight": lambda e, p: None, - }, - "components": { - "SerialNumber": lambda e, p: None, - "InstallationDate": lambda e, p: None, - "WarrantyStartDate": lambda e, p: None, - "TagNumber": lambda e, p: None, - "BarCode": lambda e, p: None, - "AssetIdentifier": lambda e, p: None, - } - } - # AOH-BEM - self.custom_parameters = { - "types": { - "ProcurementMethod": lambda e, p: None, - "ManufacturerOrganizationName": lambda e, p: None, - "SupplierOrganizationName": lambda e, p: None, - "ModelNumber": lambda e, p: None, - "WarrantyOrganizationName": lambda e, p: None, - "WarrantyDuration": lambda e, p: None, - "SpecificationSection": lambda e, p: None, - "SubmittalID": lambda e, p: None, - "ProductURL": lambda e, p: None, - }, - "components": { - "InstallationDate": lambda e, p: None, - "WarrantyStartDate": lambda e, p: None, - "InstalledModelNumber": lambda e, p: None, - "SerialNumber": lambda e, p: None, - "BarCode": lambda e, p: None, - "TagNumber": lambda e, p: None, - "OwnerAssetID": lambda e, p: None, - "FluidHotFeedName": lambda e, p: None, - "FluidColdFeedName": lambda e, p: None, - "ElectricPanelName": lambda e, p: None, - "ElectricCircuitName": lambda e, p: None, - "ControlledByName": lambda e, p: None, - "InterlockedWithName": lambda e, p: None, - "PartOfAssemblyName": lambda e, p: None, - } - } - self.picklists = { - "Category-Role": [], - "Category-Facility": [], - "FloorType": [], - "Category-Space": [], - "ZoneType": [], - "Category-Product": [], - "AssetType": [], - "DurationUnit": ["day"], # See note about hardcoded day below - "Category-Element": [], - "SpareType": [], - "ApprovalBy": [], - "StageType": [], - "objType": [], - } - self.default_date = (datetime.datetime(1970, 1, 1) + datetime.timedelta(seconds=-2177452801)).isoformat() - - def parse(self, files): - self.files = files - for category, get_category in self.categories.items(): - setattr(self, category, {}) - get_category() - - def get_actors(self): - for ifc in self.files.values(): - for element in ifc.by_type("IfcActor"): - name = element.TheActor.Name - self.actors[name] = self.get_actor(element) - - def get_actor(self, element): - psets = ifcopenshell.util.element.get_psets(element) - return { - "Name": element.TheActor.Name, - "Category": self.get_classification(element), - "Email": self.get_actor_address(element, "ElectronicMailAddresses"), - "Phone": self.get_actor_address(element, "TelephoneNumbers"), - "CompanyURL": self.get_actor_address(element, "WWWHomePageURL"), - "Department": self.get_actor_address(element, "InternalLocation"), - "Address1": self.get_actor_address(element, "AddressLines"), - "Address2": self.get_actor_address(element, "Town"), - "StateRegion": self.get_actor_address(element, "Region"), - "PostalCode": self.get_actor_address(element, "PostalCode"), - "Country": self.get_actor_address(element, "Country"), - } - - def get_actor_address(self, element, name): - for address in element.TheActor.Addresses or []: - if hasattr(address, name) and getattr(address, name, None): - result = getattr(address, name) - if isinstance(result, tuple): - return result[0] - return result - - def get_facilities(self): - for key, ifc in self.files.items(): - if "arch" not in key: - continue - element = ifc.by_type("IfcBuilding")[0] - self.facilities[element.Name] = { - "Name": element.Name, - "AuthorOrganizationName": element.OwnerHistory.OwningUser.TheOrganization.Name, - "AuthorDate": ifcopenshell.util.date.ifc2datetime( - ifc.by_type("IfcProject")[0].OwnerHistory.CreationDate - ).isoformat(), - "Category": self.get_classification(element), - "ProjectName": element.Decomposes[0].RelatingObject.Decomposes[0].RelatingObject.Name, - "SiteName": element.Decomposes[0].RelatingObject.Name, - "LinearUnits": "millimeters", - "AreaUnits": "square meters", - "AreaMeasurement": "Revit", - "Phase": element.Decomposes[0].RelatingObject.Decomposes[0].RelatingObject.Phase, - "ModelSoftware": element.OwnerHistory.OwningApplication.ApplicationFullName, - "ModelProjectID": ifc.by_type("IfcProject")[0].GlobalId, - "ModelSiteID": element.Decomposes[0].RelatingObject.GlobalId, - "ModelBuildingID": element.GlobalId, - } - - def get_classification(self, element): - references = list(ifcopenshell.util.classification.get_references(element)) - if references: - if hasattr(references[0], "Identification"): - return "{}:{}".format(references[0].Identification, references[0].Name) - return "{}:{}".format(references[0].ItemReference, references[0].Name) - - def get_floors(self): - for key, ifc in self.files.items(): - if "arch" not in key: - continue - storeys = ifc.by_type("IfcBuildingStorey") - for element in storeys: - self.get_floor(element) - - def get_floor(self, element): - name = element.Name - elevation = element.ObjectPlacement.RelativePlacement.Location.Coordinates[2] - self.floors[name] = { - "Name": name, - "AuthorOrganizationName": element.OwnerHistory.OwningUser.TheOrganization.Name, - "AuthorDate": ifcopenshell.util.date.ifc2datetime(element.OwnerHistory.CreationDate).isoformat(), - "Category": "Level", - "ModelSoftware": element.OwnerHistory.OwningApplication.ApplicationFullName, - "ModelObject": element.is_a(), - "ModelID": element.GlobalId, - "Elevation": elevation, - } - - def get_property(self, psets, pset_name, prop_name, decimals=None): - if pset_name in psets: - result = psets[pset_name].get(prop_name, None) - if decimals is None or result is None: - return result - return round(result, decimals) - - def get_custom_parameters(self, category, data, element, psets): - for parameter, get_parameter in self.custom_parameters.get(category, {}).items(): - data[parameter] = get_parameter(element, psets) - - def get_spaces(self): - for key, ifc in self.files.items(): - if "arch" not in key: - continue - primary_keys = [] - for element in ifc.by_type("IfcSpace"): - name = element.Name - primary_keys.append(name) - # TODO: not correct mapping - psets = ifcopenshell.util.element.get_psets(element) - data = { - "Name": name, - "AuthorOrganizationName": element.OwnerHistory.OwningUser.TheOrganization.Name, - "AuthorDate": ifcopenshell.util.date.ifc2datetime(element.OwnerHistory.CreationDate).isoformat(), - "Category": self.get_classification(element), - "LevelName": element.Decomposes[0].RelatingObject.Name, - "Description": element.LongName, - "ModelSoftware": element.OwnerHistory.OwningApplication.ApplicationFullName, - "ModelID": element.GlobalId, - "BuildingRoomNumber": None, - "UsableHeight": self.get_property(psets, "Data", "COBie.Space.UsableHeight", decimals=0), - "AreaGross": self.get_property(psets, "Qto_SpaceBaseQuantities", "GrossFloorArea", decimals=2), - "AreaNet": self.get_property(psets, "Qto_SpaceBaseQuantities", "NetFloorArea", decimals=2), - } - self.get_custom_parameters("spaces", data, element, psets) - self.spaces[name] = data - - def get_zones(self): - for ifc in self.files.values(): - for element in ifc.by_type("IfcZone"): - for rel in element.IsGroupedBy: - for space in rel.RelatedObjects: - if not space.is_a("IfcSpace"): - continue - self.zones[(element.Name or "Unnamed") + (space.Name or "Unnamed")] = { - "Name": element.Name, - "AuthorOrganizationName": element.OwnerHistory.OwningUser.TheOrganization.Name, - "AuthorDate": ifcopenshell.util.date.ifc2datetime( - element.OwnerHistory.CreationDate - ).isoformat(), - "Category": "Occupancy", - "SpaceName": space.Name, - "ModelSoftware": element.OwnerHistory.OwningApplication.ApplicationFullName, - "ModelID": element.GlobalId, - "ParentZoneName": None, - } - - def get_systems(self): - for discipline, ifc in self.files.items(): - for element in ifc.by_type("IfcSystem"): - name = element.Name - self.systems[name] = { - "Name": name, - "AuthorOrganizationName": element.OwnerHistory.OwningUser.TheOrganization.Name, - "AuthorDate": ifcopenshell.util.date.ifc2datetime(element.OwnerHistory.CreationDate).isoformat(), - "Category": None, - "ModelSoftware": element.OwnerHistory.OwningApplication.ApplicationFullName, - "ModelID": element.GlobalId, - "ParentSystemName": None, - } - - def get_types(self): - for discipline, ifc in self.files.items(): - self.get_types_from_file(discipline) - - def get_types_from_file(self, ifc_file): - primary_keys = [] - for element in ifcopenshell.util.fm.get_fmhem_types(self.files[ifc_file]): - name = element.Name - primary_keys.append(name) - psets = ifcopenshell.util.element.get_psets(element) - data = { - "Name": name, - "AuthorOrganizationName": element.OwnerHistory.OwningUser.TheOrganization.Name, - "AuthorDate": ifcopenshell.util.date.ifc2datetime(element.OwnerHistory.CreationDate).isoformat(), - "Category": self.get_classification(element), - "Description": element.Description, - "ModelSoftware": element.OwnerHistory.OwningApplication.ApplicationFullName, - "ModelObject": "{}[{}]".format(element.is_a(), ifcopenshell.util.element.get_predefined_type(element)), - "ModelID": element.GlobalId, - } - self.get_custom_parameters("types", data, element, psets) - self.types[name] = data - - def get_components(self): - for discipline, ifc in self.files.items(): - self.get_components_from_file(discipline) - - def get_components_from_file(self, ifc_file): - for element_type in ifcopenshell.util.fm.get_fmhem_types(self.files[ifc_file]): - elements = ifcopenshell.util.element.get_types(element_type) - if not elements: - self.logger.warning("The type has no occurrences %s", element_type) - continue - for element in elements: - name = element.Name - - system = None - for rel in element.HasAssignments: - if rel.is_a("IfcRelAssignsToGroup") and rel.RelatingGroup.is_a("IfcSystem"): - system = rel.RelatingGroup.Name - - space = ifcopenshell.util.element.get_container(element) - space_name = space.Name if space.is_a("IfcSpace") else None - - psets = ifcopenshell.util.element.get_psets(element) - - data = { - "Name": name, - "AuthorOrganizationName": element.OwnerHistory.OwningUser.TheOrganization.Name, - "AuthorDate": ifcopenshell.util.date.ifc2datetime(element.OwnerHistory.CreationDate).isoformat(), - "TypeName": element_type.Name, - "SpaceName": space_name, - "SystemName": system, - "ModelSoftware": element.OwnerHistory.OwningApplication.ApplicationFullName, - "ModelObject": "{}[{}]".format( - element.is_a(), ifcopenshell.util.element.get_predefined_type(element) - ), - "ModelID": element.GlobalId, - } - self.get_custom_parameters("components", data, element, psets) - self.components[name] = data - - def get_documents(self): - for ifc in self.files.values(): - for rel in ifc.by_type("IfcRelAssociatesDocument"): - element = rel.RelatingDocument - if element.is_a("IfcDocumentInformation"): - continue - for related_object in rel.RelatedObjects: - worksheet_row = related_object.Name - if ifc.schema == "IFC2X3": - referenced_document = element.ReferenceToDocument[0] - identification = referenced_document.ItemReference - author_date = None - if referenced_document.CreationTime: - author_date = ifcopenshell.util.date.ifc2datetime( - referenced_document.CreationTime - ).isoformat() - else: - referenced_document = element.ReferencedDocument - identification = referenced_document.Identification - author_date = referenced_document.CreationTime - - worksheet_name = None - if related_object.is_a("IfcSpace"): - worksheet_name = "Space" - elif related_object.is_a("IfcTypeObject"): - worksheet_name = "Type" - - self.documents[identification + worksheet_name + worksheet_row] = { - "Name": identification, - "AuthorOrganizationName": referenced_document.DocumentOwner.Name, - "AuthorDate": author_date, - "Category": referenced_document.Purpose, - "WorksheetName": worksheet_name, - "WorksheetRow": worksheet_row, - "Revision": referenced_document.Revision, - "Location": referenced_document.Location, - "Description": referenced_document.Name, - "SpecificationSection": None, - "SubmittalID": None, - "SourceURL": None, - } diff --git a/src/ifcfm/ifcfm/util.py b/src/ifcfm/ifcfm/util.py deleted file mode 100644 index 199b280b6e..0000000000 --- a/src/ifcfm/ifcfm/util.py +++ /dev/null @@ -1,94 +0,0 @@ -# IfcFM - IFC for facility management -# Copyright (C) 2021 Dion Moult -# -# This file is part of IfcFM. -# -# IfcFM 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. -# -# IfcFM 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 IfcFM. If not, see . - - -import textwrap -import ifcopenshell -import ifcopenshell.util.fm -import ifcopenshell.util.schema -import ifcopenshell.util.attribute - - -def print_element(declaration, should_print_subtypes=True): - if declaration.name() in ifcopenshell.util.fm.fmhem_excluded_classes: - pass - elif declaration.is_abstract(): - pass - else: - types = [] - for attribute in declaration.all_attributes(): - if attribute.name() == "PredefinedType": - types = list(ifcopenshell.util.attribute.get_enum_items(attribute)) - if "NOTDEFINED" in types: - types.remove("NOTDEFINED") - print("{}".format(declaration.name())) - if types: - types = sorted(types) - for line in textwrap.wrap(", ".join(types), width=70): - print("\t\t{}".format(line)) - if should_print_subtypes: - for subtype in declaration.subtypes(): - print_element(subtype) - - -def print_fmhem_documentation(schema="IFC4"): - if schema == "IFC4": - classes = ifcopenshell.util.fm.fmhem_classes_ifc4 - elif schema == "IFC2X3": - classes = ifcopenshell.util.fm.fmhem_classes_ifc2x3 - schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name(schema) - for ifc_class in classes: - try: - declaration = schema.declaration_by_name(ifc_class) - print_element(declaration) - except: - pass - - -def print_all_documentation(schema="IFC4"): - if schema == "IFC4": - classes = ifcopenshell.util.fm.fmhem_classes_ifc4 - elif schema == "IFC2X3": - classes = ifcopenshell.util.fm.fmhem_classes_ifc2x3 - schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name(schema) - class_queue = [schema.declaration_by_name("IfcElementType")] - maintainable_declarations = [] - other_declarations = [] - while class_queue: - declaration = class_queue.pop(0) - class_queue.extend(declaration.subtypes()) - if declaration.is_abstract(): - continue - is_maintainable = False - for ifc_class in classes: - if ifcopenshell.util.schema.is_a(declaration, ifc_class): - is_maintainable = True - break - if is_maintainable: - maintainable_declarations.append(declaration) - else: - other_declarations.append(declaration) - print("# Maintainable classes\n") - for declaration in maintainable_declarations: - print_element(declaration, should_print_subtypes=False) - print("\n\n# Other classes\n") - for declaration in other_declarations: - print_element(declaration, should_print_subtypes=False) - - -print_all_documentation("IFC2X3") diff --git a/src/ifcfm/ifcfm/writer.py b/src/ifcfm/ifcfm/writer.py deleted file mode 100644 index bb102ab63c..0000000000 --- a/src/ifcfm/ifcfm/writer.py +++ /dev/null @@ -1,344 +0,0 @@ -# IfcFM - IFC for facility management -# Copyright (C) 2021 Dion Moult -# -# This file is part of IfcFM. -# -# IfcFM 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. -# -# IfcFM 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 IfcFM. If not, see . - -import csv - -try: - from xlsxwriter import Workbook -except: - pass # No XLSX support - -try: - from odf.opendocument import OpenDocumentSpreadsheet - from odf.style import Style, TableCellProperties - from odf.table import Table, TableRow, TableCell - from odf.text import P -except: - pass # No ODF support - - -# https://stackoverflow.com/questions/1143671/how-to-sort-objects-by-multiple-keys-in-python - -from operator import itemgetter as i -from functools import cmp_to_key - - -def cmp(x, y): - """ - Replacement for built-in function cmp that was removed in Python 3 - - Compare the two objects x and y and return an integer according to - the outcome. The return value is negative if x < y, zero if x == y - and strictly positive if x > y. - - https://portingguide.readthedocs.io/en/latest/comparisons.html#the-cmp-function - """ - - try: - return (x > y) - (x < y) - except: - return 0 - - -def multikeysort(items, columns): - comparers = [((i(col[1:].strip()), -1) if col.startswith("-") else (i(col.strip()), 1)) for col in columns] - - def comparer(left, right): - comparer_iter = (cmp(fn(left), fn(right)) * mult for fn, mult in comparers) - return next((result for result in comparer_iter if result), 0) - - return sorted(items, key=cmp_to_key(comparer)) - - -class Writer: - def __init__(self, parser, filename=None): - self.filename = filename - self.parser = parser - self.sheet_data = {} - self.colours = { - "r": "fdff8e", # Required - "i": "fdcd94", # Internal reference - "e": "cd95ff", # External reference - "o": "cdffc8", # Optional - "s": "c0c0c0", # Secondary information - "p": "9ccaff", # Project specific - "n": "000000", # Not used - } - self.colours = { - "r": "dc8774", # Required - "i": "eda786", # Internal reference - "e": "96c7d0", # External reference - "o": "ddb873", # Optional or edd889 - "s": "dddddd", # Secondary information - "p": "b8dd73", # Project specific - "n": "000000", # Not used - } - self.sheets = { - "Actor": { - "data": self.parser.actors, - "fields": [ - "Name", - "Category", - "Email", - "Phone", - "CompanyURL", - "Department", - "Address1", - "Address2", - "StateRegion", - "PostalCode", - "Country", - ], - "colours": "rirrrrrrrrr", - "order": ["Name"], - }, - "Facility": { - "data": self.parser.facilities, - "fields": [ - "Name", - "AuthorOrganizationName", - "AuthorDate", - "Category", - "ProjectName", - "SiteName", - "LinearUnits", - "AreaUnits", - "AreaMeasurement", - "Phase", - "ModelSoftware", - "ModelProjectID", - "ModelSiteID", - "ModelBuildingID", - ], - "colours": "ririrrrrrreeee", - "order": ["Name"], - }, - "Floor": { - "data": self.parser.floors, - "fields": [ - "Name", - "AuthorOrganizationName", - "AuthorDate", - "Category", - "ModelSoftware", - "ModelObject", - "ModelID", - "Elevation", - ], - "colours": "ririeeer", - "order": ["Elevation"], - }, - "Space": { - "data": self.parser.spaces, - "fields": [ - "Name", - "AuthorOrganizationName", - "AuthorDate", - "Category", - "LevelName", - "Description", - "ModelSoftware", - "ModelID", - "BuildingRoomNumber", - "UsableHeight", - "AreaGross", - "AreaNet", - ], - "colours": "ririireerrrr", - "order": ["LevelName", "Name"], - }, - "Zone": { - "data": self.parser.zones, - "fields": [ - "Name", - "AuthorOrganizationName", - "AuthorDate", - "Category", - "SpaceName", - "ModelSoftware", - "ModelID", - "ParentZoneName", - ], - "colours": "ririieei", - "order": ["Name", "SpaceName"], - }, - "Type": { - "data": self.parser.types, - "fields": [ - "Name", - "AuthorOrganizationName", - "AuthorDate", - "Category", - "Description", - "ModelSoftware", - "ModelObject", - "ModelID", - ], - "colours": "ririreee", - "order": ["ModelObject", "Name"], - }, - "Component": { - "data": self.parser.components, - "fields": [ - "Name", - "AuthorOrganizationName", - "AuthorDate", - "TypeName", - "SpaceName", - "SystemName", - "ModelSoftware", - "ModelObject", - "ModelID", - ], - "colours": "ririiieee", - "order": ["ModelObject", "Name"], - }, - "System": { - "data": self.parser.systems, - "fields": [ - "Name", - "AuthorOrganizationName", - "AuthorDate", - "Category", - "ModelSoftware", - "ModelID", - "ParentSystemName", - ], - "colours": "ririeei", - "order": ["Name"], - }, - "Document": { - "data": self.parser.documents, - "fields": [ - "Name", - "AuthorOrganizationName", - "AuthorDate", - "Category", - "WorksheetName", - "WorksheetRow", - "Revision", - "Location", - "Description", - "SpecificationSection", - "SubmittalID", - "SourceURL", - ], - "colours": "ririiirrrrer", - "order": ["Name"], - }, - } - - def write(self): - for category, spec in self.sheets.items(): - self.write_data(category, spec["data"], spec["fields"], spec["colours"], spec["order"]) - - def write_data(self, sheet, data, fieldnames, colours, sort_fields): - self.sheet_data[sheet] = {"headers": fieldnames, "colours": colours, "rows": []} - for row in multikeysort(list(data.values()), sort_fields): - values = [] - for fieldname in fieldnames: - values.append(row[fieldname]) - self.sheet_data[sheet]["rows"].append(values) - - -class CsvWriter(Writer): - def write(self): - super().write() - for sheet, data in self.sheet_data.items(): - with open(os.path.join(self.filename, "{}.csv".format(sheet)), "w", newline="", encoding="utf-8") as file: - writer = csv.writer(file) - writer.writerow(data["headers"]) - for row in data["rows"]: - writer.writerow(row) - - -class XlsWriter(Writer): - def write(self): - super().write() - self.workbook = Workbook(self.filename + ".xlsx") - - self.cell_formats = {} - for key, value in self.colours.items(): - self.cell_formats[key] = self.workbook.add_format() - self.cell_formats[key].set_bg_color(value) - - for sheet in self.sheets.keys(): - self.write_worksheet(sheet) - self.workbook.close() - - def write_worksheet(self, name): - worksheet = self.workbook.add_worksheet(name) - r = 0 - c = 0 - for header in self.sheet_data[name]["headers"]: - cell = worksheet.write(r, c, header, self.cell_formats["s"]) - c += 1 - c = 0 - r += 1 - for row in self.sheet_data[name]["rows"]: - c = 0 - for col in row: - if c >= len(self.sheet_data[name]["colours"]): - cell_format = "p" - else: - cell_format = self.sheet_data[name]["colours"][c] - cell = worksheet.write(r, c, col, self.cell_formats[cell_format]) - c += 1 - r += 1 - - -class OdsWriter(Writer): - def write(self): - super().write() - self.doc = OpenDocumentSpreadsheet() - - self.cell_formats = {} - for key, value in self.colours.items(): - style = Style(name=key, family="table-cell") - style.addElement(TableCellProperties(backgroundcolor="#" + value)) - self.doc.automaticstyles.addElement(style) - self.cell_formats[key] = style - - for sheet in self.sheets.keys(): - self.write_table(sheet) - self.doc.save(self.filename, True) - - def write_table(self, name): - table = Table(name=name) - tr = TableRow() - for header in self.sheet_data[name]["headers"]: - tc = TableCell(valuetype="string", stylename="s") - tc.addElement(P(text=header)) - tr.addElement(tc) - table.addElement(tr) - for row in self.sheet_data[name]["rows"]: - tr = TableRow() - c = 0 - for col in row: - if c >= len(self.sheet_data[name]["colours"]): - cell_format = "p" - else: - cell_format = self.sheet_data[name]["colours"][c] - tc = TableCell(valuetype="string", stylename=cell_format) - if col is None: - col = "NULL" - tc.addElement(P(text=col)) - tr.addElement(tc) - c += 1 - table.addElement(tr) - self.doc.spreadsheet.addElement(table) From 90f92cd13987b9480a809460f5ac308596622d9e Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Sat, 9 Sep 2023 17:55:40 +0500 Subject: [PATCH 046/190] Units formatting for Spatial Manager Noticed in Sigma Dimensions video that we have some floating point garbage displayed in the Spatial Manager UI and I've added some simple formatting. Now - https://i.imgur.com/eM1rDDu.png Before - https://i.imgur.com/ySln5uU.png --- src/blenderbim/blenderbim/bim/module/spatial/prop.py | 5 ++--- src/blenderbim/blenderbim/bim/module/spatial/ui.py | 2 +- src/blenderbim/blenderbim/tool/spatial.py | 3 ++- src/blenderbim/blenderbim/tool/unit.py | 10 ++++++++++ 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/spatial/prop.py b/src/blenderbim/blenderbim/bim/module/spatial/prop.py index 35ecf3236d..b17b643af7 100644 --- a/src/blenderbim/blenderbim/bim/module/spatial/prop.py +++ b/src/blenderbim/blenderbim/bim/module/spatial/prop.py @@ -44,10 +44,9 @@ def update_elevation(self, context): def update_active_container_index(self, context): - si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) self.active_container_id = self.containers[self.active_container_index].ifc_definition_id self.container_name = self.containers[self.active_container_index].name - self.elevation = self.containers[self.active_container_index].elevation * si_conversion + self.elevation = self.containers[self.active_container_index].elevation def updateContainerName(self, context): @@ -78,7 +77,7 @@ class BIMObjectSpatialProperties(PropertyGroup): class BIMContainer(PropertyGroup): name: StringProperty(name="Name", update=updateContainerName) - elevation: FloatProperty(name="Elevation") + elevation: FloatProperty(name="Elevation", subtype="DISTANCE") level_index: IntProperty(name="Level Index") has_children: BoolProperty(name="Has Children") is_expanded: BoolProperty(name="Is Expanded") diff --git a/src/blenderbim/blenderbim/bim/module/spatial/ui.py b/src/blenderbim/blenderbim/bim/module/spatial/ui.py index 8f73b18a4f..28e65d2982 100644 --- a/src/blenderbim/blenderbim/bim/module/spatial/ui.py +++ b/src/blenderbim/blenderbim/bim/module/spatial/ui.py @@ -147,7 +147,7 @@ class BIM_UL_containers_manager(UIList): split1 = row.split(factor=0.7) split1.prop(item, "name", emboss=False, text="") split2 = row.split(factor=1) - split2.label(text=str(item.elevation), icon="BLANK1") + split2.label(icon="BLANK1", text=tool.Unit.blender_format_unit(item.elevation)) def draw_hierarchy(self, row, item): for i in range(0, item.level_index): diff --git a/src/blenderbim/blenderbim/tool/spatial.py b/src/blenderbim/blenderbim/tool/spatial.py index 3e89449511..1309232183 100644 --- a/src/blenderbim/blenderbim/tool/spatial.py +++ b/src/blenderbim/blenderbim/tool/spatial.py @@ -212,12 +212,13 @@ class Spatial(blenderbim.core.tool.Spatial): @classmethod def create_new_storey_li(cls, element, level_index): + si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) new = cls.props.containers.add() new.name = element.Name or "Unnamed" new.long_name = element.LongName or "" new.has_decomposition = bool(element.IsDecomposedBy) new.ifc_definition_id = element.id() - new.elevation = ifcopenshell.util.placement.get_storey_elevation(element) + new.elevation = ifcopenshell.util.placement.get_storey_elevation(element) * si_conversion new.is_expanded = element.id() not in cls.contracted_containers new.level_index = level_index diff --git a/src/blenderbim/blenderbim/tool/unit.py b/src/blenderbim/blenderbim/tool/unit.py index 8d39f9fab1..baec38b579 100644 --- a/src/blenderbim/blenderbim/tool/unit.py +++ b/src/blenderbim/blenderbim/tool/unit.py @@ -163,3 +163,13 @@ class Unit(blenderbim.core.tool.Unit): unit = cls.get_project_currency_unit() if unit: return unit.Currency + + @classmethod + def blender_format_unit(cls, value): + return bpy.utils.units.to_string( + bpy.context.scene.unit_settings.system, + "LENGTH", + value, + precision=4, + split_unit=bpy.context.scene.unit_settings.system == "IMPERIAL", + ) From 45feaeaec4de83beb340fc336003ffa2dd60092a Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Sat, 9 Sep 2023 18:04:35 +0500 Subject: [PATCH 047/190] Spatial Manager - try to preserve the selection on loading the manager Also noticed in Sigma Dimensions video that every time user deletes a storey or applies new elevation the selection was jumping to the first storey. Now it's going to be more natural. Before - https://imgur.com/a/gbn7ryQ After - https://imgur.com/a/QX2RulF --- src/blenderbim/blenderbim/tool/spatial.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/blenderbim/blenderbim/tool/spatial.py b/src/blenderbim/blenderbim/tool/spatial.py index 1309232183..5c3b6b895a 100644 --- a/src/blenderbim/blenderbim/tool/spatial.py +++ b/src/blenderbim/blenderbim/tool/spatial.py @@ -198,6 +198,7 @@ class Spatial(blenderbim.core.tool.Spatial): @classmethod def load_container_manager(cls): cls.props = bpy.context.scene.BIMSpatialManagerProperties + previous_container_index = cls.props.active_container_index cls.props.containers.clear() cls.contracted_containers = json.loads(cls.props.contracted_containers) cls.props.is_container_update_enabled = False @@ -208,7 +209,7 @@ class Spatial(blenderbim.core.tool.Spatial): cls.create_new_storey_li(object, 0) cls.props.is_container_update_enabled = True # triggers spatial manager props setup - cls.props.active_container_index = 0 + cls.props.active_container_index = min(previous_container_index, len(cls.props.containers) - 1) @classmethod def create_new_storey_li(cls, element, level_index): From 0b5535b6d4c0436d97d1a388b6d82a9535d5761f Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 9 Sep 2023 23:11:01 +1000 Subject: [PATCH 048/190] Implement COBie zones --- src/ifcfm/ifcfm/cobie.py | 84 +++++++++++++++++++++++++++++++++------- 1 file changed, 69 insertions(+), 15 deletions(-) diff --git a/src/ifcfm/ifcfm/cobie.py b/src/ifcfm/ifcfm/cobie.py index d87fbfeeca..9584c14e4d 100644 --- a/src/ifcfm/ifcfm/cobie.py +++ b/src/ifcfm/ifcfm/cobie.py @@ -53,11 +53,34 @@ def get_spaces(ifc_file): def get_zones(ifc_file): - zones = [] - for zone in ifc_file.by_type("IfcZone"): + results = [] + zones = ifc_file.by_type("IfcZone") + for zone in zones or []: + has_space = False for rel in zone.IsGroupedBy: - zones.extend([(zone, space) for space in rel.RelatedObjects]) - return zones + items = [(zone, space) for space in rel.RelatedObjects if space.is_a("IfcSpace") and val(space.Name)] + if items: + results.extend(items) + has_space = True + if not has_space: + results.append((zone, None)) + if zones: + return results + + zone_spaces = {} + for space in ifc_file.by_type("IfcSpace"): + for _, props in ifcopenshell.util.element.get_psets(space).items(): + for name, value in props.items(): + if "ZoneName" in name: + zone_name = val(value) + space_name = val(space.Name) + category = name + zone_key = str(name) + "," + str(category) + zone_spaces.setdefault(zone_key, []) + if space_name not in zone_spaces[zone_key]: + zone_spaces[zone_key].append(space_name) + results.append(((zone_name, category), space_name)) + return results def get_types(ifc_file): @@ -78,12 +101,9 @@ def get_systems(ifc_file): def get_contact_data(ifc_file, element): email = get_email_from_pao(element) - history = None + history = get_history(ifc_file) created_by = None created_on = None - histories = ifc_file.by_type("IfcOwnerHistory") - if histories: - history = sorted(histories, key=lambda x: x.id())[-1] roles = [] for actor in [element, element.ThePerson, element.TheOrganization]: @@ -266,14 +286,42 @@ def get_space_data(ifc_file, element): def get_zone_data(ifc_file, element): zone, space = element + + if isinstance(zone, tuple): + name, category = zone + history = get_history(ifc_file) + return { + "key": "-".join([str(name), str(category), str(space)]), + "Name": name, + "CreatedBy": get_email_from_history(history) if history else None, + "CreatedOn": ifcopenshell.util.date.ifc2datetime(history.CreationDate).isoformat() if history else None, + "Category": category, + "SpaceNames": space, + "ExternalSystem": history.OwningApplication.ApplicationFullName if history else None, + "ExternalObject": "IfcPropertySingleValue", + "ExternalIdentifier": None, + "Description": val(name) or val(category), + } + + name = zone.Name + parent = ifcopenshell.util.element.get_aggregate(zone) + if parent and val(parent.Name): + name = parent.Name + "-" + name + + category = get_category(zone) + space_name = val(space.Name) if space else None + return { - "key": (element.Name or "Unnamed") + (space.Name or "Unnamed"), - "Name": zone.Name, - "SpaceName": space.Name, - "AuthorOrganizationName": get_owner_name(zone), - "AuthorDate": get_owner_creation_date(zone), - "ModelSoftware": get_external_system(zone), - "ModelID": zone.GlobalId, + "key": "-".join([str(name), str(category), str(space_name)]), + "Name": name, + "CreatedBy": get_created_by(zone), + "CreatedOn": get_created_on(zone), + "Category": category, + "SpaceNames": space_name, + "ExternalSystem": get_external_system(zone), + "ExternalObject": zone.is_a(), + "ExternalIdentifier": zone.GlobalId, + "Description": val(zone.Description) or zone.Name, } @@ -484,6 +532,12 @@ def get_property(psets, pset_name, prop_name, decimals=None): return round(result, decimals) +def get_history(ifc_file): + histories = ifc_file.by_type("IfcOwnerHistory") + if histories: + return sorted(histories, key=lambda x: x.id())[-1] + + get_category_elements = { "Contact": get_contacts, "Facility": get_facilities, From aaa46834624b360bdec66ae7b6a0c97f6ff8d200 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 10 Sep 2023 17:35:33 +1000 Subject: [PATCH 049/190] Fix bug where you couldn't add psets that were inherited. --- src/blenderbim/blenderbim/bim/module/pset/data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blenderbim/blenderbim/bim/module/pset/data.py b/src/blenderbim/blenderbim/bim/module/pset/data.py index f30f298dc7..344dcaeed4 100644 --- a/src/blenderbim/blenderbim/bim/module/pset/data.py +++ b/src/blenderbim/blenderbim/bim/module/pset/data.py @@ -91,7 +91,7 @@ class ObjectPsetsData(Data): return [] psets = blenderbim.bim.schema.ifc.psetqto.get_applicable(element.is_a(), pset_only=True) psetnames = cls.format_pset_enum(psets) - assigned_names = ifcopenshell.util.element.get_psets(element, psets_only=True).keys() + assigned_names = ifcopenshell.util.element.get_psets(element, psets_only=True, should_inherit=False).keys() return [p for p in psetnames if p[0] not in assigned_names] @classmethod From 6cf482dc6377d472e0c9158ccbc32ce140e49dde Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 10 Sep 2023 17:36:07 +1000 Subject: [PATCH 050/190] Minor UI bugfix where pset template files sometimes displayed with truncated filenames --- src/blenderbim/blenderbim/bim/module/pset_template/data.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/pset_template/data.py b/src/blenderbim/blenderbim/bim/module/pset_template/data.py index a48a315610..de44529cb5 100644 --- a/src/blenderbim/blenderbim/bim/module/pset_template/data.py +++ b/src/blenderbim/blenderbim/bim/module/pset_template/data.py @@ -56,12 +56,12 @@ class PsetTemplatesData: pset_dir = os.path.join(bpy.context.scene.BIMProperties.data_dir, "pset") files = os.listdir(pset_dir) for f in files: - results.append((os.path.join(pset_dir, f), f.strip(".ifc"), "Global Pset Template")) + results.append((os.path.join(pset_dir, f), os.path.splitext(os.path.basename(f))[0], "Global Pset Template")) pset_dir = tool.Ifc.resolve_uri(bpy.context.scene.BIMProperties.pset_dir) if os.path.isdir(pset_dir): for path in pathlib.Path(pset_dir).glob("*.ifc"): - results.append((str(path), os.path.basename(str(path)).strip(".ifc"), "Project Pset Template")) + results.append((str(path), os.path.splitext(os.path.basename(str(path)))[0], "Project Pset Template")) return sorted(results, key=lambda x: x[1]) From 9be7d951c3f9f68b250d2daa00f197a0c49d5621 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 10 Sep 2023 17:37:35 +1000 Subject: [PATCH 051/190] Improve usability of pset template UI by removing save button and using new explicit applicable entity defaults to mitigate possible invalid pset templates. --- .../bim/module/pset_template/__init__.py | 4 +- .../bim/module/pset_template/operator.py | 55 ++++++++++++++----- .../bim/module/pset_template/prop.py | 3 +- .../blenderbim/bim/module/pset_template/ui.py | 6 +- .../api/pset_template/add_pset_template.py | 6 +- 5 files changed, 53 insertions(+), 21 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/pset_template/__init__.py b/src/blenderbim/blenderbim/bim/module/pset_template/__init__.py index 3bc180c204..766045f8af 100644 --- a/src/blenderbim/blenderbim/bim/module/pset_template/__init__.py +++ b/src/blenderbim/blenderbim/bim/module/pset_template/__init__.py @@ -22,7 +22,7 @@ from . import ui, prop, operator classes = ( operator.AddPropEnum, operator.AddPropTemplate, - operator.AddPsetFile, + operator.AddPsetTemplateFile, operator.AddPsetTemplate, operator.DeletePropEnum, operator.DisableEditingPropTemplate, @@ -33,7 +33,7 @@ classes = ( operator.EnableEditingPsetTemplate, operator.RemovePropTemplate, operator.RemovePsetTemplate, - operator.SavePsetTemplateFile, + operator.RemovePsetTemplateFile, prop.PsetTemplate, prop.EnumerationValues, prop.PropTemplate, diff --git a/src/blenderbim/blenderbim/bim/module/pset_template/operator.py b/src/blenderbim/blenderbim/bim/module/pset_template/operator.py index f2173a51b8..b6eface0e0 100644 --- a/src/blenderbim/blenderbim/bim/module/pset_template/operator.py +++ b/src/blenderbim/blenderbim/bim/module/pset_template/operator.py @@ -44,9 +44,9 @@ class Operator: IfcStore.pset_template_file.redo() -class AddPsetFile(bpy.types.Operator): - bl_idname = "bim.add_pset_file" - bl_label = "Add Pset File" +class AddPsetTemplateFile(bpy.types.Operator): + bl_idname = "bim.add_pset_template_file" + bl_label = "Add Pset Template File" bl_options = {"REGISTER", "UNDO"} def invoke(self, context, event): @@ -64,18 +64,13 @@ class AddPsetFile(bpy.types.Operator): self.props.new_template_filename + ".ifc", ) - template.create_entity( - "IFCPROPERTYSETTEMPLATE", - **{ - "GlobalId": ifcopenshell.guid.new(), - "Name": "Name", - "Description": "Description", - "TemplateType": "PSET_TYPEDRIVENONLY", - "ApplicableEntity": "IfcTypeObject", - } - ) + pset_template = ifcopenshell.api.run("pset_template.add_pset_template", template) + ifcopenshell.api.run("pset_template.add_prop_template", template, pset_template=pset_template) template.write(filepath) self.props.new_template_filename = "" + blenderbim.bim.handler.purge_module_data() + blenderbim.bim.schema.reload(tool.Ifc.get().schema) + context.scene.BIMPsetTemplateProperties.pset_template_files = filepath return {"FINISHED"} @@ -86,7 +81,10 @@ class AddPsetTemplate(bpy.types.Operator, Operator): def _execute(self, context): template = ifcopenshell.api.run("pset_template.add_pset_template", IfcStore.pset_template_file) + ifcopenshell.api.run("pset_template.add_prop_template", IfcStore.pset_template_file, pset_template=template) + IfcStore.pset_template_file.write(IfcStore.pset_template_path) blenderbim.bim.handler.refresh_ui_data() + blenderbim.bim.schema.reload(tool.Ifc.get().schema) context.scene.BIMPsetTemplateProperties.pset_templates = str(template.id()) @@ -104,6 +102,9 @@ class RemovePsetTemplate(bpy.types.Operator, Operator): IfcStore.pset_template_file, **{"pset_template": IfcStore.pset_template_file.by_id(int(props.pset_templates))} ) + IfcStore.pset_template_file.write(IfcStore.pset_template_path) + blenderbim.bim.handler.refresh_ui_data() + blenderbim.bim.schema.reload(tool.Ifc.get().schema) class EnableEditingPsetTemplate(bpy.types.Operator): @@ -215,6 +216,9 @@ class EditPsetTemplate(bpy.types.Operator, Operator): } ) bpy.ops.bim.disable_editing_pset_template() + IfcStore.pset_template_file.write(IfcStore.pset_template_path) + blenderbim.bim.handler.refresh_ui_data() + blenderbim.bim.schema.reload(tool.Ifc.get().schema) class SavePsetTemplateFile(bpy.types.Operator): @@ -228,6 +232,20 @@ class SavePsetTemplateFile(bpy.types.Operator): return {"FINISHED"} +class RemovePsetTemplateFile(bpy.types.Operator): + bl_idname = "bim.remove_pset_template_file" + bl_label = "Remove Pset Template File" + + def execute(self, context): + try: + os.remove(IfcStore.pset_template_path) + except: + pass + blenderbim.bim.handler.purge_module_data() + blenderbim.bim.schema.reload(tool.Ifc.get().schema) + return {"FINISHED"} + + class AddPropTemplate(bpy.types.Operator, Operator): bl_idname = "bim.add_prop_template" bl_label = "Add Prop Template" @@ -239,9 +257,12 @@ class AddPropTemplate(bpy.types.Operator, Operator): ifcopenshell.api.run( "pset_template.add_prop_template", IfcStore.pset_template_file, - **{"pset_template": IfcStore.pset_template_file.by_id(pset_template_id)} + pset_template=IfcStore.pset_template_file.by_id(pset_template_id), ) bpy.ops.bim.disable_editing_prop_template() + IfcStore.pset_template_file.write(IfcStore.pset_template_path) + blenderbim.bim.handler.refresh_ui_data() + blenderbim.bim.schema.reload(tool.Ifc.get().schema) class RemovePropTemplate(bpy.types.Operator, Operator): @@ -257,6 +278,9 @@ class RemovePropTemplate(bpy.types.Operator, Operator): IfcStore.pset_template_file, **{"prop_template": IfcStore.pset_template_file.by_id(self.prop_template)} ) + IfcStore.pset_template_file.write(IfcStore.pset_template_path) + blenderbim.bim.handler.refresh_ui_data() + blenderbim.bim.schema.reload(tool.Ifc.get().schema) class EditPropTemplate(bpy.types.Operator, Operator): @@ -285,6 +309,9 @@ class EditPropTemplate(bpy.types.Operator, Operator): } ) bpy.ops.bim.disable_editing_prop_template() + IfcStore.pset_template_file.write(IfcStore.pset_template_path) + blenderbim.bim.handler.refresh_ui_data() + blenderbim.bim.schema.reload(tool.Ifc.get().schema) # TODO -This will need to go into the # api code at some point - vulevukusej diff --git a/src/blenderbim/blenderbim/bim/module/pset_template/prop.py b/src/blenderbim/blenderbim/bim/module/pset_template/prop.py index d2821ca01a..2dba9b0504 100644 --- a/src/blenderbim/blenderbim/bim/module/pset_template/prop.py +++ b/src/blenderbim/blenderbim/bim/module/pset_template/prop.py @@ -38,6 +38,7 @@ from bpy.props import ( def updatePsetTemplateFiles(self, context): IfcStore.pset_template_file = None + PsetTemplatesData.is_loaded = False PsetTemplatesData.data["pset_template_files"] = PsetTemplatesData.pset_template_files() PsetTemplatesData.data["pset_templates"] = PsetTemplatesData.pset_templates() PsetTemplatesData.data["prop_templates"] = PsetTemplatesData.prop_templates() @@ -179,7 +180,7 @@ class BIMPsetTemplateProperties(PropertyGroup): pset_template_files: EnumProperty( items=getPsetTemplateFiles, name="Pset Template Files", update=updatePsetTemplateFiles ) - pset_templates: EnumProperty(items=getPsetTemplates, name="Pset Template Files", update=updatePsetTemplates) + pset_templates: EnumProperty(items=getPsetTemplates, name="Pset Templates", update=updatePsetTemplates) active_pset_template_id: IntProperty(name="Active Pset Template Id") active_prop_template_id: IntProperty(name="Active Prop Template Id") active_pset_template: PointerProperty(type=PsetTemplate) diff --git a/src/blenderbim/blenderbim/bim/module/pset_template/ui.py b/src/blenderbim/blenderbim/bim/module/pset_template/ui.py index 969a355ed3..2f90bd94dd 100644 --- a/src/blenderbim/blenderbim/bim/module/pset_template/ui.py +++ b/src/blenderbim/blenderbim/bim/module/pset_template/ui.py @@ -38,9 +38,9 @@ class BIM_PT_pset_template(Panel): self.props = context.scene.BIMPsetTemplateProperties row = self.layout.row(align=True) - prop_with_search(row, self.props, "pset_template_files", text="") - row.operator("bim.save_pset_template_file", text="", icon="EXPORT") - row.operator("bim.add_pset_file", icon="ADD", text="") + prop_with_search(row, self.props, "pset_template_files", text="", icon="FILE") + row.operator("bim.add_pset_template_file", icon="ADD", text="") + row.operator("bim.remove_pset_template_file", icon="X", text="") row = self.layout.row(align=True) diff --git a/src/ifcopenshell-python/ifcopenshell/api/pset_template/add_pset_template.py b/src/ifcopenshell-python/ifcopenshell/api/pset_template/add_pset_template.py index ae37efa128..4611dfb48e 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/pset_template/add_pset_template.py +++ b/src/ifcopenshell-python/ifcopenshell/api/pset_template/add_pset_template.py @@ -21,7 +21,11 @@ import ifcopenshell class Usecase: def __init__( - self, file, name="New_Pset", template_type="PSET_TYPEDRIVENOVERRIDE", applicable_entity="IfcTypeObject" + self, + file, + name="New_Pset", + template_type="PSET_TYPEDRIVENOVERRIDE", + applicable_entity="IfcObject,IfcTypeObject", ): """Adds a new property set template From a58a10e93d01f64a015035cbc92b49b01194616d Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 10 Sep 2023 18:27:05 +1000 Subject: [PATCH 052/190] Deprecate purge_module_data in favour of newer refresh_ui_data function. --- src/blenderbim/blenderbim/bim/handler.py | 24 ++++++------------- .../blenderbim/bim/module/csv/operator.py | 4 ++-- .../blenderbim/bim/module/debug/operator.py | 4 ++-- .../bim/module/geometry/operator.py | 8 +++---- .../blenderbim/bim/module/project/operator.py | 4 ++-- .../bim/module/pset_template/operator.py | 12 ++++------ .../blenderbim/bim/module/root/operator.py | 2 +- .../blenderbim/bim/module/root/prop.py | 4 ---- .../blenderbim/bim/module/spatial/operator.py | 2 +- .../blenderbim/bim/module/tester/prop.py | 4 ---- 10 files changed, 23 insertions(+), 45 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/handler.py b/src/blenderbim/blenderbim/bim/handler.py index cc27d81e32..7f2657e5ab 100644 --- a/src/blenderbim/blenderbim/bim/handler.py +++ b/src/blenderbim/blenderbim/bim/handler.py @@ -177,30 +177,20 @@ def refresh_ui_data(): except AttributeError: pass - if isinstance(tool.Ifc.get(), ifcopenshell.sqlite): - tool.Ifc.get().clear_cache() - - -def purge_module_data(): - from blenderbim.bim import modules - - refresh_ui_data() - for name, value in modules.items(): - try: - getattr(getattr(getattr(ifcopenshell.api, name), "data"), "Data").purge() - except AttributeError: - pass - + # TODO: deprecate prop purge functions and refactor into data classes. try: getattr(value, "prop").purge() except AttributeError: pass + if isinstance(tool.Ifc.get(), ifcopenshell.sqlite): + tool.Ifc.get().clear_cache() + @persistent def loadIfcStore(scene): IfcStore.purge() - purge_module_data() + refresh_ui_data() if not IfcStore.get_file(): return IfcStore.get_schema() @@ -212,7 +202,7 @@ def undo_post(scene): if IfcStore.last_transaction != bpy.context.scene.BIMProperties.last_transaction: IfcStore.last_transaction = bpy.context.scene.BIMProperties.last_transaction IfcStore.undo(until_key=bpy.context.scene.BIMProperties.last_transaction) - purge_module_data() + refresh_ui_data() tool.Ifc.rebuild_element_maps() @@ -221,7 +211,7 @@ def redo_post(scene): if IfcStore.last_transaction != bpy.context.scene.BIMProperties.last_transaction: IfcStore.last_transaction = bpy.context.scene.BIMProperties.last_transaction IfcStore.redo(until_key=bpy.context.scene.BIMProperties.last_transaction) - purge_module_data() + refresh_ui_data() tool.Ifc.rebuild_element_maps() diff --git a/src/blenderbim/blenderbim/bim/module/csv/operator.py b/src/blenderbim/blenderbim/bim/module/csv/operator.py index dceeb0a568..0ff49af50c 100644 --- a/src/blenderbim/blenderbim/bim/module/csv/operator.py +++ b/src/blenderbim/blenderbim/bim/module/csv/operator.py @@ -26,7 +26,7 @@ import webbrowser import ifcopenshell import blenderbim.tool as tool from blenderbim.bim.ifc import IfcStore -from blenderbim.bim.handler import purge_module_data +from blenderbim.bim.handler import refresh_ui_data class AddCsvAttribute(bpy.types.Operator): @@ -251,7 +251,7 @@ class ImportIfcCsv(bpy.types.Operator): ) if not props.should_load_from_memory: ifc_file.write(props.csv_ifc_file) - purge_module_data() + refresh_ui_data() return {"FINISHED"} diff --git a/src/blenderbim/blenderbim/bim/module/debug/operator.py b/src/blenderbim/blenderbim/bim/module/debug/operator.py index 2b67815858..9eecda3251 100644 --- a/src/blenderbim/blenderbim/bim/module/debug/operator.py +++ b/src/blenderbim/blenderbim/bim/module/debug/operator.py @@ -116,7 +116,7 @@ class PurgeIfcLinks(bpy.types.Operator): context.scene.BIMProperties.ifc_file = "" context.scene.BIMDebugProperties.attributes.clear() IfcStore.purge() - blenderbim.bim.handler.purge_module_data() + blenderbim.bim.handler.refresh_ui_data() return {"FINISHED"} @@ -136,7 +136,7 @@ class ConvertToBlender(bpy.types.Operator): m.BIMMaterialProperties.ifc_style_id = False bpy.context.scene.BIMProperties.ifc_file = "" IfcStore.purge() - blenderbim.bim.handler.purge_module_data() + blenderbim.bim.handler.refresh_ui_data() return {"FINISHED"} diff --git a/src/blenderbim/blenderbim/bim/module/geometry/operator.py b/src/blenderbim/blenderbim/bim/module/geometry/operator.py index 04f63b3578..60b671b5e3 100644 --- a/src/blenderbim/blenderbim/bim/module/geometry/operator.py +++ b/src/blenderbim/blenderbim/bim/module/geometry/operator.py @@ -720,7 +720,7 @@ class OverrideDuplicateMove(bpy.types.Operator): tool.Boundary.decorate_boundary(new_obj) # Recreate decompositions tool.Root.recreate_decompositions(relationships, old_to_new) - blenderbim.bim.handler.purge_module_data() + blenderbim.bim.handler.refresh_ui_data() class OverrideDuplicateMoveLinkedMacro(bpy.types.Macro): @@ -788,7 +788,7 @@ class OverrideDuplicateMoveLinked(bpy.types.Operator): old_to_new[tool.Ifc.get_entity(obj)] = new # Recreate decompositions tool.Root.recreate_decompositions(relationships, old_to_new) - blenderbim.bim.handler.purge_module_data() + blenderbim.bim.handler.refresh_ui_data() return {"FINISHED"} @@ -1076,7 +1076,7 @@ class OverrideDuplicateMoveAggregate(bpy.types.Operator): tool.Root.recreate_decompositions(relationships, old_to_new) - blenderbim.bim.handler.purge_module_data() + blenderbim.bim.handler.refresh_ui_data() return {"FINISHED"} @@ -1223,7 +1223,7 @@ class RefreshAggregate(bpy.types.Operator): tool.Root.recreate_decompositions(relationships, old_to_new) - blenderbim.bim.handler.purge_module_data() + blenderbim.bim.handler.refresh_ui_data() return {"FINISHED"} diff --git a/src/blenderbim/blenderbim/bim/module/project/operator.py b/src/blenderbim/blenderbim/bim/module/project/operator.py index 6c47978d98..0ee0ac2d58 100644 --- a/src/blenderbim/blenderbim/bim/module/project/operator.py +++ b/src/blenderbim/blenderbim/bim/module/project/operator.py @@ -429,7 +429,7 @@ class AppendLibraryElement(bpy.types.Operator): except: # TODO Remove this terrible code when I refactor this into the core pass - blenderbim.bim.handler.purge_module_data() + blenderbim.bim.handler.refresh_ui_data() return {"FINISHED"} def import_material_from_ifc(self, element, context): @@ -1079,7 +1079,7 @@ class ExportIFC(bpy.types.Operator): save_blend_file = bool(bpy.data.is_saved and bpy.data.is_dirty and bpy.data.filepath) if save_blend_file: bpy.ops.wm.save_mainfile(filepath=bpy.data.filepath) - blenderbim.bim.handler.purge_module_data() + blenderbim.bim.handler.refresh_ui_data() self.report( {"INFO"}, f'IFC Project "{os.path.basename(output_file)}" {"" if not save_blend_file else "And Current Blend File Are"} Saved', diff --git a/src/blenderbim/blenderbim/bim/module/pset_template/operator.py b/src/blenderbim/blenderbim/bim/module/pset_template/operator.py index b6eface0e0..4d4534de80 100644 --- a/src/blenderbim/blenderbim/bim/module/pset_template/operator.py +++ b/src/blenderbim/blenderbim/bim/module/pset_template/operator.py @@ -58,17 +58,13 @@ class AddPsetTemplateFile(bpy.types.Operator): def execute(self, context): template = ifcopenshell.file() - filepath = os.path.join( - context.scene.BIMProperties.data_dir, - "pset", - self.props.new_template_filename + ".ifc", - ) + filepath = os.path.join(context.scene.BIMProperties.data_dir, "pset", self.props.new_template_filename + ".ifc") pset_template = ifcopenshell.api.run("pset_template.add_pset_template", template) ifcopenshell.api.run("pset_template.add_prop_template", template, pset_template=pset_template) template.write(filepath) self.props.new_template_filename = "" - blenderbim.bim.handler.purge_module_data() + blenderbim.bim.handler.refresh_ui_data() blenderbim.bim.schema.reload(tool.Ifc.get().schema) context.scene.BIMPsetTemplateProperties.pset_template_files = filepath return {"FINISHED"} @@ -227,7 +223,7 @@ class SavePsetTemplateFile(bpy.types.Operator): def execute(self, context): IfcStore.pset_template_file.write(IfcStore.pset_template_path) - blenderbim.bim.handler.purge_module_data() + blenderbim.bim.handler.refresh_ui_data() blenderbim.bim.schema.reload(tool.Ifc.get().schema) return {"FINISHED"} @@ -241,7 +237,7 @@ class RemovePsetTemplateFile(bpy.types.Operator): os.remove(IfcStore.pset_template_path) except: pass - blenderbim.bim.handler.purge_module_data() + blenderbim.bim.handler.refresh_ui_data() blenderbim.bim.schema.reload(tool.Ifc.get().schema) return {"FINISHED"} diff --git a/src/blenderbim/blenderbim/bim/module/root/operator.py b/src/blenderbim/blenderbim/bim/module/root/operator.py index 3e90b3385b..b112794595 100644 --- a/src/blenderbim/blenderbim/bim/module/root/operator.py +++ b/src/blenderbim/blenderbim/bim/module/root/operator.py @@ -212,4 +212,4 @@ class CopyClass(bpy.types.Operator, Operator): objects = [bpy.data.objects.get(self.obj)] if self.obj else context.selected_objects for obj in objects: core.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=obj) - blenderbim.bim.handler.purge_module_data() + blenderbim.bim.handler.refresh_ui_data() diff --git a/src/blenderbim/blenderbim/bim/module/root/prop.py b/src/blenderbim/blenderbim/bim/module/root/prop.py index 95456da780..cd0b47949f 100644 --- a/src/blenderbim/blenderbim/bim/module/root/prop.py +++ b/src/blenderbim/blenderbim/bim/module/root/prop.py @@ -33,10 +33,6 @@ from bpy.props import ( ) -def purge(): - pass - - def get_ifc_predefined_types(self, context): if not IfcClassData.is_loaded: IfcClassData.load() diff --git a/src/blenderbim/blenderbim/bim/module/spatial/operator.py b/src/blenderbim/blenderbim/bim/module/spatial/operator.py index 451ee442ac..b9c3f00202 100644 --- a/src/blenderbim/blenderbim/bim/module/spatial/operator.py +++ b/src/blenderbim/blenderbim/bim/module/spatial/operator.py @@ -143,7 +143,7 @@ class CopyToContainer(bpy.types.Operator, tool.Ifc.Operator): old_to_new[tool.Ifc.get_entity(obj)] = result_objs # Recreate decompositions tool.Root.recreate_decompositions(relationships, old_to_new) - blenderbim.bim.handler.purge_module_data() + blenderbim.bim.handler.refresh_ui_data() class SelectContainer(bpy.types.Operator, tool.Ifc.Operator): diff --git a/src/blenderbim/blenderbim/bim/module/tester/prop.py b/src/blenderbim/blenderbim/bim/module/tester/prop.py index b68600f053..53e4c26993 100644 --- a/src/blenderbim/blenderbim/bim/module/tester/prop.py +++ b/src/blenderbim/blenderbim/bim/module/tester/prop.py @@ -31,10 +31,6 @@ from bpy.props import ( ) -def purge(): - pass - - def update_active_specification_index(self, context): TesterData.load() From 098ae2b69f6e800d9057affdfcf3890782b4927b Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 11 Sep 2023 18:31:37 +1000 Subject: [PATCH 053/190] Implement COBie type and component tables --- src/ifcfm/ifcfm/cobie.py | 217 ++++++++++++++++++++++++++++++++++----- 1 file changed, 193 insertions(+), 24 deletions(-) diff --git a/src/ifcfm/ifcfm/cobie.py b/src/ifcfm/ifcfm/cobie.py index 9584c14e4d..151eb815ce 100644 --- a/src/ifcfm/ifcfm/cobie.py +++ b/src/ifcfm/ifcfm/cobie.py @@ -181,10 +181,10 @@ def get_facility_data(ifc_file, element): "Category": get_category(element), "ProjectName": project_name, "SiteName": site_name, - "LinearUnits": get_unit_name(ifc_file, "LENGTHUNIT"), - "AreaUnits": get_unit_name(ifc_file, "AREAUNIT"), - "VolumeUnits": get_unit_name(ifc_file, "VOLUMEUNIT"), - "CurrencyUnit": get_unit_name(ifc_file, "IfcMonetaryUnit"), + "LinearUnits": get_unit_type_name(ifc_file, "LENGTHUNIT"), + "AreaUnits": get_unit_type_name(ifc_file, "AREAUNIT"), + "VolumeUnits": get_unit_type_name(ifc_file, "VOLUMEUNIT"), + "CurrencyUnit": get_unit_type_name(ifc_file, "IfcMonetaryUnit"), "AreaMeasurement": get_area_measurement(element), "ExternalSystem": get_external_system(element), "ExternalProjectObject": "IfcProject", @@ -326,36 +326,191 @@ def get_zone_data(ifc_file, element): def get_type_data(ifc_file, element): + pset_metadata = {} + pset_mapping = { + "manufacturer": {"Manufacturer"}, + "model_number": {"ModelNumber", "ArticleNumber", "ModelLabel"}, + "warranty_guarantor_parts": {"WarrantyGuarantorParts", "PointOfContact"}, + "warranty_guarantor_labor": {"WarrantyGuarantorLabor", "PointOfContact"}, + "warranty_description": {"WarrantyDescription", "WarrantyIdentifier"}, + "replacement_cost": {"ReplacementCost", "Replacement Cost", "Replacement", "Cost"}, + "nominal_length": {"NominalLength", "OverallLength"}, + "nominal_width": {"NominalWidth", "Width"}, + # https://github.com/opensourceBIM/COBie-plugins/blob/master/COBiePlugins/lib/IfcToCobieConfig.xml#L104 + "nominal_height": {"NominalHeight", "Height"}, # Original has a typo "Heght" + "model_reference": {"ModelReference", "Reference"}, + "shape": {"Shape"}, + "size": {"Size"}, + "color": {"Color", "Colour"}, + "finish": {"Finish"}, + "grade": {"Grade"}, + "material": {"Material"}, + "constituents": {"Constituents", "Parts"}, + "features": {"Features"}, + "accessibility_performance": {"AccessibilityPerformance", "Access"}, + "code_performance": {"CodePerformance", "Regulation"}, + "sustainability_performance": {"SustainabilityPerformance", "Environmental"}, + } + asset_type = None + asset_type_names = {"AssetType", "AssetAccountingType"} + warranty_duration_parts = None + warranty_duration_parts_names = {"WarrantyDurationParts", "WarrantyPeriod"} + warranty_duration_labor = None + warranty_duration_labor_names = {"WarrantyDurationLabor", "WarrantyPeriod"} + warranty_duration_unit = None + expected_life = None + expected_life_names = {"ExpectedLife", "Expected Life", "ServiceLifeDuration", "Expected"} + duration_unit = None + + for pset_name, props in ifcopenshell.util.element.get_psets(element): + pset_warranty_type = None + if pset_name == "Pset_Warranty": + if "parts" in (props.get("WarrantyIdentifier", "") or "").lower(): + pset_warranty_type = "parts" + elif "labor" in (props.get("WarrantyIdentifier", "") or "").lower(): + pset_warranty_type = "labor" + + for name, value in props.items(): + for key, prop_names in pset_mapping.items(): + if not pset_metadata.get(key, None) and name in prop_names and val(value): + pset_metadata[key] = str(value) + + if not asset_type and name in asset_type_names and val(value): + value = value.strip().lower() + if value in ("moveable", "nonfixed"): + asset_type = "Moveable" + elif value == "fixed": + asset_type = "Fixed" + if not warranty_duration_parts and name in warranty_duration_parts_names and val(value): + warranty_duration_parts = str(value) + if not warranty_duration_unit: + warranty_duration_unit = get_property_unit(props["id"], name) + if not warranty_duration_labor and name in warranty_duration_labor_names and val(value): + warranty_duration_labor = str(value) + if not warranty_duration_unit: + warranty_duration_unit = get_property_unit(props["id"], name) + if not expected_life and name in expected_life_names and val(value): + expected_life = str(value) + if not duration_unit: + duration_unit = get_property_unit(props["id"], name) + + if pset_warranty_type == "parts" and val(value): + if name == "PointOfContact": + # https://github.com/buildingSMART/IFC4.3.x-development/issues/698 + warranty_guarantor_parts = str(value) + elif name == "WarrantyPeriod": + warranty_duration_parts = str(value) + unit = get_property_unit(props["id"], name) + warranty_duration_unit = unit or warranty_duration_unit + elif pset_warranty_type == "labor" and val(value): + if name == "PointOfContact": + # https://github.com/buildingSMART/IFC4.3.x-development/issues/698 + warranty_guarantor_labor = str(value) + elif name == "WarrantyPeriod": + warranty_duration_labor = str(value) + unit = get_property_unit(props["id"], name) + warranty_duration_unit = unit or warranty_duration_unit + + if warranty_duration_parts or warranty_duration_labor: + if not warranty_duration_unit: + warranty_duration_unit = get_unit_type_name(ifc_file, "TIMEUNIT") + + if not asset_type and element.is_a("IfcFurnitureType"): + asset_type = "Moveable" + return { - "key": element.Name, - "Name": element.Name, - "Description": element.Description, - "Category": get_classification(element), - "AuthorOrganizationName": get_owner_name(element), - "AuthorDate": get_owner_creation_date(element), - "ModelSoftware": get_external_system(element), - "ModelObject": "{}[{}]".format(element.is_a(), ifcopenshell.util.element.get_predefined_type(element)), - "ModelTag": element.Tag, - "ModelID": element.GlobalId, + "key": var(element.Name), + "Name": var(element.Name), + "CreatedBy": get_created_by(element), + "CreatedOn": get_created_on(element), + "Category": get_category(element), + "Description": var(element.Description) or var(element.Name), + "AssetType": asset_type, + "Manufacturer": pset_metadata.get("manufacturer", None), + "ModelNumber": pset_metadata.get("model_number", None), + "WarrantyGuarantorParts": pset_metadata.get("warranty_guarantor_parts", None), + "WarrantyDurationParts": warranty_duration_parts, + "WarrantyGuarantorLabor": pset_metadata.get("warranty_guarantor_labor", None), + "WarrantyDurationLabor": warranty_duration_labor, + "WarrantyDurationUnit": warranty_duration_unit, + "ExternalSystem": get_external_system(element), + "ExternalObject": element.is_a(), + "ExternalIdentifier": element.GlobalId, + "ReplacementCost": pset_metadata.get("replacement_cost", None), + "ExpectedLife": expected_life, + "DurationUnit": duration_unit, + "WarrantyDescription": pset_metadata.get("warranty_description", None), + "NominalLength": pset_metadata.get("nominal_length", None), + "NominalWidth": pset_metadata.get("nominal_width", None), + "NominalHeight": pset_metadata.get("nominal_height", None), + "ModelReference": pset_metadata.get("model_reference", None), + "Shape": pset_metadata.get("shape", None), + "Size": pset_metadata.get("size", None), + "Color": pset_metadata.get("color", None), + "Finish": pset_metadata.get("finish", None), + "Grade": pset_metadata.get("grade", None), + "Material": pset_metadata.get("material", None), + "Constituents": pset_metadata.get("constituents", None), + "Features": pset_metadata.get("features", None), + "AccessibilityPerformance": pset_metadata.get("accessibility_performance", None), + "CodePerformance": pset_metadata.get("code_performance", None), + "SustainabilityPerformance": pset_metadata.get("sustainability_performance", None), } -def get_element_data(ifc_file, element): +def get_component_data(ifc_file, element): space = ifcopenshell.util.element.get_container(element) space_name = space.Name if space.is_a("IfcSpace") else None systems = ifcopenshell.util.system.get_element_systems(element) system = systems[0].Name if systems else None + + type_name = None + relating_type = ifcopenshell.util.element.get_type(element) + if relating_type and val(relating_type.Name): + type_name = relating_type.Name + else: + material = ifcopenshell.util.element.get_material(element, should_skip_usage=True) + type_name = getattr(material, "Name", None) or getattr(material, "LayerSetName", None) + + serial_number = None + installation_date = None + warranty_start_date = None + tag_number = None + bar_code = None + asset_identifier = None + + for _, props in ifcopenshell.util.element.get_psets(element): + for name, value in props.items(): + if not serial_number and name == "SerialNumber" and val(value): + serial_number = str(value) + if not installation_date and name == "InstallationDate" and val(value): + installation_date = str(value) + if not warranty_start_date and name == "WarrantyStartDate" and val(value): + warranty_start_date = str(value) + if not tag_number and name == "TagNumber" and val(value): + tag_number = str(value) + if not bar_code and name == "BarCode" and val(value): + bar_code = str(value) + if not asset_identifier and name == "AssetIdentifier" and val(value): + asset_identifier = str(value) + return { "key": element.Name, "Name": element.Name, - "TypeName": ifcopenshell.util.element.get_type(element).Name, - "SpaceName": space_name, - "SystemName": system, - "AuthorOrganizationName": get_owner_name(element), - "AuthorDate": get_owner_creation_date(element), - "ModelSoftware": get_external_system(element), - "ModelObject": "{}[{}]".format(element.is_a(), ifcopenshell.util.element.get_predefined_type(element)), - "ModelID": element.GlobalId, + "CreatedBy": get_created_by(element), + "CreatedOn": get_created_on(element), + "TypeName": type_name, + "Space": space_name, + "Description": var(element.Description) or var(element.Name), + "ExternalSystem": get_external_system(element), + "ExternalObject": element.is_a(), + "ExternalIdentifier": element.GlobalId, + "SerialNumber": serial_number, + "InstallationDate": installation_date, + "WarrantyStartDate": warranty_start_date, + "TagNumber": tag_number, + "BarCode": bar_code, + "AssetIdentifier": asset_identifier, } @@ -372,7 +527,7 @@ def get_system_data(ifc_file, element): } -def get_unit_name(ifc_file, unit_type): +def get_unit_type_name(ifc_file, unit_type): for unit in ifc_file.by_type("IfcUnitAssignment")[0].Units: if unit.is_a("IfcNamedUnit") and unit.UnitType == unit_type: if unit.is_a("IfcSIUnit"): @@ -389,6 +544,11 @@ def get_unit_name(ifc_file, unit_type): return val(unit.Currency) +def get_unit_name(ifc_file, unit): + if unit.is_a("IfcNamedUnit"): + return val(unit.Name) + + def get_created_by(element): if getattr(element, "OwnerHistory", None): return get_email_from_history(element.OwnerHistory) @@ -538,6 +698,15 @@ def get_history(ifc_file): return sorted(histories, key=lambda x: x.id())[-1] +def get_property_unit(pset_id, prop_name): + pset = ifc_file.by_id(pset_id) + for prop in getattr(pset, "HasProperties", []) or []: + if prop.Name == prop_name: + unit = getattr(prop, "Unit", None) + if unit: + return get_unit_name(unit) + + get_category_elements = { "Contact": get_contacts, "Facility": get_facilities, From e93ff7a6b12e9d931feff6e92802faae453990f5 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 11 Sep 2023 10:29:25 +0500 Subject: [PATCH 054/190] MEP bends - more robust start/end points detection also support for bends with angles > 90 degrees and some other bugs fixed --- .../blenderbim/bim/module/model/mep.py | 39 ++++++++++++------- .../blenderbim/bim/module/model/wall.py | 4 +- src/blenderbim/blenderbim/tool/cad.py | 12 ++++++ .../ifcopenshell/util/shape_builder.py | 17 ++++++-- 4 files changed, 50 insertions(+), 22 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/model/mep.py b/src/blenderbim/blenderbim/bim/module/model/mep.py index 934902cf91..88d3e7a708 100644 --- a/src/blenderbim/blenderbim/bim/module/model/mep.py +++ b/src/blenderbim/blenderbim/bim/module/model/mep.py @@ -35,7 +35,7 @@ import blenderbim.core.type import blenderbim.core.root import blenderbim.core.geometry import blenderbim.tool as tool -from math import pi, degrees, radians, sin, cos, asin +from math import pi, degrees, radians, sin, cos, asin, tan from copy import copy from mathutils import Vector, Matrix from ifcopenshell.util.shape_builder import ShapeBuilder @@ -973,10 +973,20 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator): end_segment_data["start_point"]: end_segment_data["start_port"], end_segment_data["end_point"]: end_segment_data["end_port"], } - (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"]), + + get_z_basis = lambda o: o.matrix_world.col[2].normalized().to_3d() + 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_port = points_ports_map[start_point] end_port = points_ports_map[end_point] start_point_on_origin = start_point == start_segment_data["start_point"] @@ -1032,7 +1042,6 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator): return {"CANCELLED"} O = V(0, 0, 0) - get_z_basis = lambda o: o.matrix_world.col[2].normalized().to_3d() angle = tool.Cad.angle_edges((get_z_basis(start_object), O), (get_z_basis(end_object), O)) lateral_sign = tool.Cad.sign(profile_offset[lateral_axis]) @@ -1042,18 +1051,18 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator): radial_offset.z = ref_point_radius * sin(angle) def get_segments_extend(): - end_segment_z_local = to_start_object_space @ get_z_basis(end_object) - segments_intersection = tool.Cad.intersect_edges( - (V(0, 0, 1), V(0, 0, 0)), (profile_offset + end_segment_z_local, profile_offset) - )[0] + segments_intersection = segments_intersection_ws - start_point + segments_intersection = to_start_object_space @ segments_intersection - curent_start_offset = segments_intersection.length - required_start_offset = abs(radial_offset.z) + # since tangent segments are equal + # if drawn for the circle from the same point + required_offset = ref_point_radius * tan(angle / 2) + + current_start_offset = segments_intersection.length current_end_offset = (segments_intersection - profile_offset).length - required_end_offset = abs(radial_offset[lateral_axis]) - start_extend = curent_start_offset - required_start_offset - end_extend = current_end_offset - required_end_offset + start_extend = current_start_offset - required_offset + end_extend = current_end_offset - required_offset return start_extend, end_extend @@ -1076,7 +1085,6 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator): # adjust segments to fit the radius and angle start_segment_extend, end_segment_extend = get_segments_extend() - start_segment_extend_point = start_point + start_segment_sign * start_segment_extend * get_z_basis(start_object) projection = check_new_segment_length(first_segment_start, start_point, start_segment_extend_point) if projection is not None: @@ -1108,6 +1116,7 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator): angle, self.radius / si_conversion, profile_offset / si_conversion, + flip_z_axis=start_segment_sign == -1, ) bpy.ops.bim.create_shape_from_step_id(step_id=rep.id(), should_include_curves=True) diff --git a/src/blenderbim/blenderbim/bim/module/model/wall.py b/src/blenderbim/blenderbim/bim/module/model/wall.py index 2544234f64..e2aa7e6a56 100644 --- a/src/blenderbim/blenderbim/bim/module/model/wall.py +++ b/src/blenderbim/blenderbim/bim/module/model/wall.py @@ -1367,9 +1367,7 @@ class DumbWallJoiner: clamp_axis = clamp_axis[::-1] vectors = tuple([clamp_point_by_direction(v, clamp_axis) for v in vectors]) - closest = tool.Cad.closest_vector(ref_point_2d.to_3d(), vectors) - farthest = vectors[1] if closest == vectors[0] else vectors[0] - return closest, farthest + return tool.Cad.closest_and_furthest_vectors(ref_point_2d.to_3d(), vectors) bbn, bbf = get_closest_and_furthest_vectors(axis1["base"][i], (bb1, bb2), bba1) bsn, bsf = get_closest_and_furthest_vectors(axis1["side"][i], (bs1, bs2)) diff --git a/src/blenderbim/blenderbim/tool/cad.py b/src/blenderbim/blenderbim/tool/cad.py index d45921ecab..aca5ee6e06 100644 --- a/src/blenderbim/blenderbim/tool/cad.py +++ b/src/blenderbim/blenderbim/tool/cad.py @@ -192,6 +192,18 @@ class Cad: distance_test = (v1 - pt).length >= (v2 - pt).length return v1 if distance_test else v2 + @classmethod + def closest_and_furthest_vectors(cls, pt, e): + """ + > pt: vector + > e: 2 vector tuple + < returns the two vectors closest to and furthest from pt. + """ + if isinstance(e, tuple) and all([isinstance(co, Vector) for co in e]): + closest = cls.closest_vector(pt, e) + furthest = e[1] if closest == e[0] else e[0] + return closest, furthest + @classmethod def coords_tuple_from_edge_idx(cls, bm, idx): """bm is a bmesh representation""" diff --git a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py index 1a6c5b9c12..7b8586a19b 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py +++ b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py @@ -1278,7 +1278,14 @@ class ShapeBuilder: return angle def mep_bend_shape( - self, segment, start_length: float, end_length: float, angle: float, radius: float, profile_offset: Vector + self, + segment, + start_length: float, + end_length: float, + angle: float, + radius: float, + profile_offset: Vector, + flip_z_axis: bool, ): """ @@ -1290,9 +1297,11 @@ class ShapeBuilder: :param radius: bend radius :param type: float :param profile_offset: offset between start and end segments in local space of start segment - used mainly to determine the bend axes and their direction. - Values themselves are replaced by the radius. + used mainly to determine the seconn bend axis and it's direction. :param type: Vector + :param flip_z_axis: since we cannot determine z axis direction from the profile offset, + there is an option to flip it if bend is going by start segment Z- axis. + :param type: bool :return: tuple of Model/Body/MODEL_VIEW IfcRepresentation and transition shape data """ @@ -1318,7 +1327,7 @@ class ShapeBuilder: lateral_axis = next(i for i in range(2) if not is_x(rounded_offset[i], 0)) non_lateral_axis = 1 if lateral_axis == 0 else 0 lateral_sign = sign(profile_offset[lateral_axis]) - z_sign = sign(profile_offset.z) + z_sign = -1 if flip_z_axis else 1 rep_items = [] From 507ab8ec110a21f112054bbe41bf6f7ca2e0cd09 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 11 Sep 2023 17:30:14 +0500 Subject: [PATCH 055/190] Fixed top and center alignment in the schedule Solves #3599 Before - https://i.imgur.com/9s7MxH5.png After - https://i.imgur.com/ojDj09G.png Inkscape - https://i.imgur.com/pWF4Ouy.png --- .../bim/module/drawing/scheduler.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/drawing/scheduler.py b/src/blenderbim/blenderbim/bim/module/drawing/scheduler.py index a703075772..7050ea9ec4 100644 --- a/src/blenderbim/blenderbim/bim/module/drawing/scheduler.py +++ b/src/blenderbim/blenderbim/bim/module/drawing/scheduler.py @@ -402,12 +402,27 @@ class Scheduler: else: wrapped_lines = text_lines - for line_number, text_line in enumerate(wrapped_lines[::-1]): + if box_alignment.startswith("top"): + dy_dir = 1 + line_number = 0 + elif box_alignment.startswith("bot"): + dy_dir = -1 + line_number = 0 + else: # middle row + # the idea is that the middle row should always stay in the center + # e.g. dy offset for 3 lines is: 1, 0, -1 + # for 2 lines: 0.5, -0.5 + dy_dir = -1 + line_number = (len(wrapped_lines) - 1) / 2 + + for text_line in wrapped_lines[::dy_dir]: # position has to be inserted at tspan to avoid x offset between tspans tspan = self.svg.tspan(text_line, insert=(x, y), **text_params) # doing it here and not in tspan constructor because constructor adds unnecessary spaces - tspan.update({"dy": f"-{line_number}em"}) + tspan.update({"dy": f"{line_number}em"}) text_tag.add(tspan) + line_number += dy_dir + self.svg.add(text_tag) def convert_to_mm(self, value): From da7d4dac7a132300a55f015b8585735d41df5238 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 11 Sep 2023 23:24:25 +1000 Subject: [PATCH 056/190] Implement COBie systems --- src/ifcfm/ifcfm/cobie.py | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/ifcfm/ifcfm/cobie.py b/src/ifcfm/ifcfm/cobie.py index 151eb815ce..27b82fd1e7 100644 --- a/src/ifcfm/ifcfm/cobie.py +++ b/src/ifcfm/ifcfm/cobie.py @@ -95,7 +95,14 @@ def get_components(ifc_file): def get_systems(ifc_file): - return ifc_file.by_type("IfcSystem") + results = [] + components = get_components(ifc_file) + systems = ifc_file.by_type("IfcSystem", include_subtypes=False) + ifc_file.by_type("IfcDistributionSystem") + for system in systems: + for element in ifcopenshell.util.system.get_system_elements(system): + if element in components: + results.append((system, element)) + return results def get_contact_data(ifc_file, element): @@ -515,15 +522,18 @@ def get_component_data(ifc_file, element): def get_system_data(ifc_file, element): + system, component = element return { - "key": element.Name, - "Name": element.Name, - "Description": element.Description, - "Category": get_classification(element), - "AuthorOrganizationName": get_owner_name(element), - "AuthorDate": get_owner_creation_date(element), - "ModelSoftware": get_external_system(element), - "ModelID": element.GlobalId, + "key": val(system.Name), + "Name": val(system.Name), + "CreatedBy": get_created_by(system), + "CreatedOn": get_created_on(system), + "Category": get_category(system), + "ComponentNames": val(component.Name), + "ExternalSystem": get_external_system(system), + "ExternalObject": system.is_a(), + "ExternalIdentifier": system.GlobalId, + "Description": val(system.Description) or val(system.Name), } From 7541293a3c06fff31b9ebf8502bdb49f9ee04bd1 Mon Sep 17 00:00:00 2001 From: Thomas Krijnen Date: Mon, 11 Sep 2023 21:27:54 +0200 Subject: [PATCH 057/190] Case sensitivity on entity str comparison from cmd line https://sourceforge.net/p/ifcopenshell/discussion/1782717/thread/2bf3324a7f --- src/ifcparse/IfcSchema.cpp | 8 ++++---- src/ifcparse/IfcSchema.h | 12 ------------ 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/src/ifcparse/IfcSchema.cpp b/src/ifcparse/IfcSchema.cpp index 2d26efa0da..a21023a865 100644 --- a/src/ifcparse/IfcSchema.cpp +++ b/src/ifcparse/IfcSchema.cpp @@ -13,8 +13,8 @@ bool IfcParse::declaration::is(const std::string& name) const { if (name_upper_ == *name_ptr) return true; - if (this->as_entity()) { - return this->as_entity()->is(name); + if (this->as_entity() && this->as_entity()->supertype()) { + return this->as_entity()->supertype()->is(name); } else if (this->as_type_declaration()) { const IfcParse::named_type* nt = this->as_type_declaration()->declared_type()->as_named_type(); if (nt) return nt->is(name); @@ -26,8 +26,8 @@ bool IfcParse::declaration::is(const std::string& name) const { bool IfcParse::declaration::is(const IfcParse::declaration& decl) const { if (this == &decl) return true; - if (this->as_entity()) { - return this->as_entity()->is(decl); + if (this->as_entity() && this->as_entity()->supertype()) { + return this->as_entity()->supertype()->is(decl); } else if (this->as_type_declaration()) { const IfcParse::named_type* nt = this->as_type_declaration()->declared_type()->as_named_type(); if (nt) return nt->is(decl); diff --git a/src/ifcparse/IfcSchema.h b/src/ifcparse/IfcSchema.h index 4ec3e93b7d..5661530844 100644 --- a/src/ifcparse/IfcSchema.h +++ b/src/ifcparse/IfcSchema.h @@ -293,18 +293,6 @@ namespace IfcParse { virtual ~entity(); - bool is(const std::string& name) const { - if (name == name_) return true; - else if (supertype_) return supertype_->is(name); - else return false; - } - - bool is(const IfcParse::declaration& decl) const { - if (this == &decl) return true; - else if (supertype_) return supertype_->is(decl); - else return false; - } - bool is_abstract() const { return is_abstract_; } void set_subtypes(const std::vector& subtypes) { From 5c33034455f8fa1bfc04d7df1748c696b6e4a156 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 12 Sep 2023 17:46:57 +1000 Subject: [PATCH 058/190] Implement COBie assemblies, connections, spares, resources, and jobs. --- src/ifcfm/ifcfm/cobie.py | 282 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 278 insertions(+), 4 deletions(-) diff --git a/src/ifcfm/ifcfm/cobie.py b/src/ifcfm/ifcfm/cobie.py index 27b82fd1e7..10458ae5a2 100644 --- a/src/ifcfm/ifcfm/cobie.py +++ b/src/ifcfm/ifcfm/cobie.py @@ -105,12 +105,66 @@ def get_systems(ifc_file): return results +def get_assemblies(ifc_file): + results = [] + layer_sets = ifc_file.by_type("IfcMaterialLayerSet") + layer_sets = [] # This is temporarily overridden because it is unclear exactly how this is stored in Type. + for layer_set in layer_sets: + for layer in layer_set.MaterialLayers: + results.append((None, layer_set, layer.Material)) + rels = ifc_file.by_type("IfcRelAggregates") + ifc_file.by_type("IfcRelNests") + types = get_types(ifc_file) + components = get_components(ifc_file) + for rel in rels: + if rel.RelatingObject.is_a("IfcSpace"): + for related_object in rel.RelatedObjects: + if related_object.is_a("IfcSpace"): + results.append((rel, rel.RelatingObject, related_object)) + elif rel.RelatingObject.is_a("IfcZone"): + for related_object in rel.RelatedObjects: + if related_object.is_a("IfcZone"): + results.append((rel, rel.RelatingObject, related_object)) + elif rel.RelatingObject in types: + for related_object in rel.RelatedObjects: + if related_object in types: + results.append((rel, rel.RelatingObject, related_object)) + elif rel.RelatingObject in components: + for related_object in rel.RelatedObjects: + if related_object in components: + results.append((rel, rel.RelatingObject, related_object)) + elif rel.RelatingObject.is_a() in ("IfcSystem", "IfcDistributionSystem"): + for related_object in rel.RelatedObjects: + if related_object.is_a() in ("IfcSystem", "IfcDistributionSystem"): + results.append((rel, rel.RelatingObject, related_object)) + return results + + +def get_connections(ifc_file): + return ifc_file.by_type("IfcRelConnectsPorts") + + +def get_spares(ifc_file): + return ifc_file.by_type("IfcConstructionProductResource") + + +def get_resources(ifc_file): + return ifc_file.by_type("IfcConstructionEquipmentResource") + + +def get_jobs(ifc_file): + return ifc_file.by_type("IfcTask") + + +def get_documents(ifc_file): + return [ + r for r in ifc_file.by_type("IfcRelAssociatesDocument") if r.RelatingDocument.is_a("IfcDocumentInformation") + ] + + def get_contact_data(ifc_file, element): email = get_email_from_pao(element) history = get_history(ifc_file) - created_by = None - created_on = None roles = [] for actor in [element, element.ThePerson, element.TheOrganization]: @@ -523,13 +577,15 @@ def get_component_data(ifc_file, element): def get_system_data(ifc_file, element): system, component = element + category = get_category(system) + component_name = val(component.Name) return { - "key": val(system.Name), + "key": str(val(system.Name)) + str(category) + str(component_name), "Name": val(system.Name), "CreatedBy": get_created_by(system), "CreatedOn": get_created_on(system), "Category": get_category(system), - "ComponentNames": val(component.Name), + "ComponentNames": component_name, "ExternalSystem": get_external_system(system), "ExternalObject": system.is_a(), "ExternalIdentifier": system.GlobalId, @@ -537,6 +593,201 @@ def get_system_data(ifc_file, element): } +def get_assembly_data(ifc_file, element): + rel, relating_object, related_object = element + + if relating_object.is_a("IfcMaterialLayerSet"): + name = val(relating_object.LayerSetName) + parent_name = name + if name: + name += " assembly" + assembly_type = "Layer" + sheet_name = "Type" + description = val(relating_object.LayerSetName) + else: + name = val(relating_object.Name) + parent_name = name + assembly_type = "Fixed" + sheet_name = "Component" + if relating_object.is_a("IfcSpace"): + sheet_name = "Space" + elif relating_object.is_a("IfcZone"): + sheet_name = "Zone" + elif relating_object.is_a("IfcSystem"): + sheet_name = "System" + elif relating_object.is_a("IfcElementType"): + sheet_name = "Type" + description = val(rel.Description) or val(rel.Name) + + child_name = val(related_object.Name) + history = get_history(ifc_file) + + return { + "key": str(name) + str(sheet_name) + str(parent_name), + "Name": name, + "CreatedBy": get_email_from_history(history) if history else None, + "CreatedOn": ifcopenshell.util.date.ifc2datetime(history.CreationDate).isoformat() if history else None, + "SheetName": sheet_name, + "ParentName": parent_name, + "ChildNames": child_name, + "AssemblyType": assembly_type, + "ExternalSystem": history.OwningApplication.ApplicationFullName if history else None, + "ExternalObject": rel.is_a() if rel else relating_object.is_a(), + "ExternalIdentifier": rel.GlobalId if rel else None, + "Description": description, + } + + +def get_connection_data(ifc_file, element): + connection_type = ( + val(element.RelatingPort.ObjectType) + or val(element.RelatedPort.ObjectType) + or val(element.Description) + or val(element.Name) + ) + name = val(element.Name) + row_name1 = val(ifcopenshell.util.system.get_port_element(element.RelatingPort).Name) + row_name2 = val(ifcopenshell.util.system.get_port_element(element.RelatedPort).Name) + return { + "key": str(name) + str(connection_type) + str(row_name1) + str(row_name2), + "Name": name, + "CreatedBy": get_created_by(element), + "CreatedOn": get_created_on(element), + "ConnectionType": connection_type, + "SheetName": "Component", + "RowName1": row_name1, + "RowName2": row_name2, + "RealizingElement": val(element.RealizingElement.Name) if element.RealizingElement else None, + "PortName1": val(element.RelatingPort.Name), + "PortName2": val(element.RelatedPort.Name), + "ExternalSystem": get_external_system(element), + "ExternalObject": element.is_a(), + "ExternalIdentifier": element.GlobalId, + "Description": val(element.Description) or val(element.Name), + } + + +def get_spare_data(ifc_file, element): + type_name = None + for rel in element.ResourceOf or []: + for related_object in rel.RelatedObjects or []: + if val(related_object.Name): + type_name = val(related_object.Name) + + suppliers = None + set_number = None + part_number = None + for _, props in ifcopenshell.util.element.get_psets(element): + for name, value in props.items(): + if name == "Suppliers" and val(value): + suppliers = str(value) + if name == "SetNumber" and val(value): + set_number = str(value) + if name == "PartNumber" and val(value): + part_number = str(value) + + return { + "key": val(element), + "Name": val(element.Name), + "CreatedBy": get_created_by(element), + "CreatedOn": get_created_on(element), + "Category": get_category(element), + "TypeName": type_name, + "Suppliers": suppliers, + "ExternalSystem": get_external_system(element), + "ExternalObject": element.is_a(), + "ExternalIdentifier": element.GlobalId, + "Description": val(element.Description) or val(element.Name), + "SetNumber": set_number, + "PartNumber": part_number, + } + + +def get_resource_data(ifc_file, element): + return { + "key": val(element.Name), + "Name": val(element.Name), + "CreatedBy": get_created_by(element), + "CreatedOn": get_created_on(element), + "Category": val(element.ObjectType), + "ExternalSystem": get_external_system(element), + "ExternalObject": element.is_a(), + "ExternalIdentifier": element.GlobalId, + "Description": val(element.Description) or val(element.Name), + } + + +def get_job_data(ifc_file, element): + type_names = [] + resource_names = [] + for rel in element.OperatesOn or []: + for related_object in rel.RelatedObjects or []: + if not val(related_object.Name): + continue + if related_object.is_a("IfcTypeObject"): + type_names.append(related_object.Name) + elif related_object.is_a("IfcConstructionEquipmentResource"): + resource_names.append(related_object.Name) + type_name = ",".join(type_names) if type_names else None + resource_names = ",".join(resource_names) if resource_names else None + + duration = None + duration_unit = None + start = None + task_start_unit = None + frequency = None + frequency_unit = None + + for _, props in ifcopenshell.util.element.get_psets(element): + for name, value in props.items(): + if not duration and name == "TaskDuration" and val(value): + duration = str(value) + if not duration_unit: + duration_unit = get_property_unit(props["id"], name) + if not start and name == "TaskStartDate" and val(value): + start = str(value) + if not task_start_unit: + task_start_unit = get_property_unit(props["id"], name) + if not frequency and name == "TaskInterval" and val(value): + frequency = str(value) + if not frequency_unit: + frequency_unit = get_property_unit(props["id"], name) + + task_number = val(getattr(element, "Id", None)) or val(getattr(element, "Identification", None)) + + priors = [] + for rel in element.IsSuccessorFrom or []: + if rel.RelatedProcess.is_a("IfcTask"): + prior_task = rel.RelatedProcess + prior_id = val(getattr(prior_task, "Id", None)) or val(getattr(prior_task, "Identification", None)) + if prior_id: + priors.append(prior_id) + priors = ",".join(priors) if priors else task_number + + return { + "key": val(element.Name), + "Name": val(element.Name), + "CreatedBy": get_created_by(element), + "CreatedOn": get_created_on(element), + "Category": val(element.ObjectType), + "Status": val(element.Status), + "TypeName": type_name, + "Description": val(element.Description) or val(element.Name), + "Duration": duration, + "DurationUnit": duration_unit, + "Start": start, + "TaskStartUnit": task_start_unit, + "Frequency": frequency, + "FrequencyUnit": frequency_unit, + "ExternalSystem": get_external_system(element), + "ExternalObject": element.is_a(), + "ExternalIdentifier": element.GlobalId, + "TaskNumber": task_number, + "Priors": priors, + "ResourceNames": resource_names, + } + + def get_unit_type_name(ifc_file, unit_type): for unit in ifc_file.by_type("IfcUnitAssignment")[0].Units: if unit.is_a("IfcNamedUnit") and unit.UnitType == unit_type: @@ -726,6 +977,18 @@ get_category_elements = { "Type": get_types, "Component": get_components, "System": get_systems, + "Assembly": get_assemblies, + "Connection": get_connections, + "Spare": get_spares, + "Resource": get_resources, + "Job": get_jobs, + # "Impact": get_impacts, # Not implemented for some reason in BIMServer COBie-Plugins + "Document": get_documents, + "Attribute": get_attributes, + # Not implemented for some reason in BIMServer COBie-Plugins + # "Coordinate": get_coordinates, + # "Issue": get_issues, + # "Picklist": get_picklists, } get_element_data = { @@ -737,6 +1000,17 @@ get_element_data = { "Type": get_type_data, "Component": get_component_data, "System": get_system_data, + "Assembly": get_assembly_data, + "Connection": get_connection_data, + "Spare": get_spare_data, + "Resource": get_resource_data, + "Job": get_job_data, + # "Impact": get_impact_data, + "Document": get_document_data, + "Attribute": get_attribute_data, + # "Coordinate": get_coordinate_data, + # "Issue": get_issue_data, + # "Picklist": get_picklist_data, } config = { From 02902d9a41c4c7919a304a6cbf574fd44e160e59 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 12 Sep 2023 17:47:35 +1000 Subject: [PATCH 059/190] Allow optional second arguments to substr formatting. --- src/ifcopenshell-python/ifcopenshell/util/selector.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/util/selector.py b/src/ifcopenshell-python/ifcopenshell/util/selector.py index b568e6b476..cde17eee33 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/selector.py +++ b/src/ifcopenshell-python/ifcopenshell/util/selector.py @@ -128,7 +128,7 @@ format_grammar = lark.Lark( upper: "upper(" function ")" title: "title(" function ")" concat: "concat(" function ("," function)* ")" - substr: "substr(" function "," SIGNED_INT "," SIGNED_INT ")" + substr: "substr(" function "," SIGNED_INT ["," SIGNED_INT] ")" // Embed common.lark for packaging DIGIT: "0".."9" @@ -186,7 +186,12 @@ class FormatTransformer(lark.Transformer): return "".join(args) def substr(self, args): - return str(args[0])[int(args[1]) : int(args[2])] + if len(args) == 3: + if args[2] is None: + return str(args[0])[int(args[1]) :] + return str(args[0])[int(args[1]) : int(args[2])] + elif len(args) == 2: + return str(args[0])[int(args[1]) :] def round(self, args): value = Decimal(args[0] or 0.0) From b243e04fd2366e6e338520a81f75e428ee87d03b Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 12 Sep 2023 17:47:51 +1000 Subject: [PATCH 060/190] New util.system.get_port_element function. --- .../ifcopenshell/util/system.py | 46 ++++++++++--------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/util/system.py b/src/ifcopenshell-python/ifcopenshell/util/system.py index 51be9062f5..0c8789a098 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/system.py +++ b/src/ifcopenshell-python/ifcopenshell/util/system.py @@ -92,35 +92,37 @@ def get_connected_port(port): return rel.RelatingPort +def get_port_element(port): + if hasattr(port, "Nests"): + for rel in port.Nests: + return rel.RelatingObject + # IFC2X3 only, deprecated in IFC4 + elif hasattr(port, "ContainedIn"): + for rel in port.ContainedIn: + return rel.RelatedElement + + def get_connected_to(element, flow_direction=None): results = [] for port in ifcopenshell.util.system.get_ports(element, flow_direction=flow_direction): - for relConnectsPort in port.ConnectedTo: - for disPort in [relConnectsPort.RelatedPort, relConnectsPort.RelatingPort]: - if hasattr(disPort, "Nests"): - for relNest in disPort.Nests: - if relNest.RelatingObject != element: - results.append(relNest.RelatingObject) - # IFC2X3 only, deprecated in IFC4 - elif hasattr(disPort, "ContainedIn"): - for relConPortToElement in disPort.ContainedIn: - if relConPortToElement.RelatedElement != element: - results.append(relConPortToElement.RelatedElement) + for rel in port.ConnectedTo: + for other_port in [rel.RelatedPort, rel.RelatingPort]: + if other_port == port: + continue + other_element = get_port_element(other_port) + if other_element: + results.append(other_element) return results def get_connected_from(element, flow_direction=None): results = [] for port in ifcopenshell.util.system.get_ports(element, flow_direction=flow_direction): - for relConnectsPort in port.ConnectedFrom: - for disPort in [relConnectsPort.RelatedPort, relConnectsPort.RelatingPort]: - if hasattr(disPort, "Nests"): - for relNest in disPort.Nests: - if relNest.RelatingObject != element: - results.append(relNest.RelatingObject) - # IFC2X3 only, deprecated in IFC4 - elif hasattr(disPort, "ContainedIn"): - for relConPortToElement in disPort.ContainedIn: - if relConPortToElement.RelatedElement != element: - results.append(relConPortToElement.RelatedElement) + for rel in port.ConnectedFrom: + for other_port in [rel.RelatedPort, rel.RelatingPort]: + if other_port == port: + continue + other_element = get_port_element(other_port) + if other_element: + results.append(other_element) return results From 02801b32a64b113982880d70149ba2c20237d1d9 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 12 Sep 2023 17:48:11 +1000 Subject: [PATCH 061/190] Minor fix in IfcCSV where strings didn't get auto quoted. --- src/ifccsv/ifccsv.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/ifccsv/ifccsv.py b/src/ifccsv/ifccsv.py index e1bbcfbd5d..de505d676a 100755 --- a/src/ifccsv/ifccsv.py +++ b/src/ifccsv/ifccsv.py @@ -240,8 +240,7 @@ class IfcCsv: for index, format_query in formatting_indices.items(): if row[index] == null: continue - if not isinstance(row[index], str): - row[index] = '"' + str(row[index]).replace('"', '\\"') + '"' + row[index] = '"' + str(row[index]).replace('"', '\\"') + '"' row[index] = ifcopenshell.util.selector.format(format_query.replace("{{value}}", row[index])) def sort_results(self, sort, attributes, include_global_id): From 295172188612c41fa3bfed89f7aa4c4febafd23c Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 12 Sep 2023 20:45:24 +1000 Subject: [PATCH 062/190] Don't strip selector query keys to allow for explicit "mistakes" in key names. --- src/ifcopenshell-python/ifcopenshell/util/selector.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/util/selector.py b/src/ifcopenshell-python/ifcopenshell/util/selector.py index cde17eee33..0cc3fe537c 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/selector.py +++ b/src/ifcopenshell-python/ifcopenshell/util/selector.py @@ -272,8 +272,6 @@ def set_element_value(ifc_file, element, query, value): keys = GetElementTransformer().transform(get_element_grammar.parse(query)) for i, key in enumerate(keys): - if isinstance(key, str): - key = key.strip() if element is None: return if key == "type": @@ -789,8 +787,6 @@ class Selector: def get_element_value(cls, element, keys): value = element for key in keys: - if isinstance(key, str): - key = key.strip() if value is None: return if key == "type": From d854984dda18792406895dc6f71309aa92a080de Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Tue, 12 Sep 2023 09:40:14 +0500 Subject: [PATCH 063/190] MEP bends - support for circular profiles Demo - https://imgur.com/a/XQBOvGz --- .../blenderbim/bim/module/model/mep.py | 13 ++--- .../ifcopenshell/util/shape_builder.py | 54 ++++++++++++------- 2 files changed, 39 insertions(+), 28 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/model/mep.py b/src/blenderbim/blenderbim/bim/module/model/mep.py index 88d3e7a708..54e62e54cc 100644 --- a/src/blenderbim/blenderbim/bim/module/model/mep.py +++ b/src/blenderbim/blenderbim/bim/module/model/mep.py @@ -940,19 +940,16 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator): 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.", + "Segments types do not match or one of the segments doesn't have type which is required for a bend.", ) return {"CANCELLED"} - # TODO: support circular profiles profile = tool.Model.get_flow_segment_profile(start_element) - if not profile.is_a("IfcRectangleProfileDef"): + if not profile.is_a("IfcRectangleProfileDef") and not profile.is_a("IfcCircleProfileDef"): self.report( - { - "ERROR", - "For now Only IfcRectangleProfileDef profiles supported for a bend, " - f"the segments are {profile.is_a()}", - } + {"ERROR"}, + "For now Only IfcRectangleProfileDef/IfcCircleProfileDef profiles supported for a bend, " + f"the segments are {profile.is_a()}", ) return {"CANCELLED"} diff --git a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py index 7b8586a19b..a3e811dc1e 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py +++ b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py @@ -1321,6 +1321,7 @@ class ShapeBuilder: # TODO: test with 0 radius si_conversion = ifcopenshell.util.unit.calculate_unit_scale(self.file) profile = get_profile(segment) + is_circular_profile = profile.is_a("IfcCircleProfileDef") profile_dim = get_dim(profile, start_length) rounded_offset = round_vector_to_precision(profile_offset, si_conversion) @@ -1334,19 +1335,23 @@ class ShapeBuilder: # bend circle center O = V(0, 0, 0) O[lateral_axis] = (radius + profile_dim[lateral_axis]) * lateral_sign - theta = angle - def get_circle_extrusion(): - # get as much segment_length segments as possible - segment_length = pi / 20 - num_segments = ceil(theta / segment_length) - theta_segments = [i * segment_length for i in range(num_segments)] - if not is_x(theta_segments[-1], theta): - theta_segments.append(theta) + def get_bend_representation_item(): + if is_circular_profile: + theta_segments = [0, theta / 2, theta] + else: + # get as much segment_length segments as possible + segment_length = pi / 20 + num_segments = ceil(theta / segment_length) + theta_segments = [i * segment_length for i in range(num_segments)] + if not is_x(theta_segments[-1], theta): + theta_segments.append(theta) inner_points, outer_points = [], [] r = radius + if is_circular_profile: + r += profile_dim[lateral_axis] for cur_theta in theta_segments: cur_theta -= pi / 2 @@ -1356,24 +1361,33 @@ class ShapeBuilder: inner[lateral_axis] = lateral_sign * sin(cur_theta) * r inner_points.append(inner) - outer = V(0, 0, 0) - outer.z = z_sign * cos(cur_theta) * (r + 2 * profile_dim[lateral_axis]) - outer[lateral_axis] = lateral_sign * sin(cur_theta) * (r + 2 * profile_dim[lateral_axis]) - outer_points.append(outer) + if not is_circular_profile: + outer = V(0, 0, 0) + outer.z = z_sign * cos(cur_theta) * (r + 2 * profile_dim[lateral_axis]) + outer[lateral_axis] = lateral_sign * sin(cur_theta) * (r + 2 * profile_dim[lateral_axis]) + outer_points.append(outer) # fmt: on points = inner_points + outer_points[::-1] points = [p + O for p in points] offset = V(0, 0, 0) - offset[non_lateral_axis] = -profile_dim[non_lateral_axis] - extrusion_vector = V(0, 0, 0) - extrusion_vector[non_lateral_axis] = 1 - extrusion = self.extrude_face_set( - points, magnitude=profile_dim[non_lateral_axis] * 2, offset=offset, extrusion_vector=extrusion_vector - ) - return extrusion - rep_items.append(get_circle_extrusion()) + if is_circular_profile: + bend_path = self.polyline(points, closed=False, arc_points=[1]) + bend = self.create_swept_disk_solid(bend_path, profile_dim[lateral_axis]) + else: + offset[non_lateral_axis] = -profile_dim[non_lateral_axis] + extrusion_vector = V(0, 0, 0) + extrusion_vector[non_lateral_axis] = 1 + bend = self.extrude_face_set( + points, + magnitude=profile_dim[non_lateral_axis] * 2, + offset=offset, + extrusion_vector=extrusion_vector, + ) + return bend + + rep_items.append(get_bend_representation_item()) body = ifcopenshell.util.representation.get_context(self.file, "Model", "Body", "MODEL_VIEW") rep = self.get_representation(body, rep_items) From 6f9e14ceb005dde9fbf24adabf49f0738485156d Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Tue, 12 Sep 2023 13:16:17 +0500 Subject: [PATCH 064/190] Bends - replace faceset with simple extrusion --- .../geometry/add_railing_representation.py | 2 +- .../ifcopenshell/util/shape_builder.py | 51 ++++++++++++------- 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_railing_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_railing_representation.py index 33ea7b7b20..b597633678 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_railing_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_railing_representation.py @@ -111,7 +111,7 @@ class Usecase: support_disk_circle = builder.circle(radius=support_disk_radius) angle = V(0, 1).angle_signed(ortho_dir.xy) - y_extrusion_kwargs = builder.rotate_extrusion_kwargs_by_z(builder.extrude_by_y_kwargs(), angle) + y_extrusion_kwargs = builder.rotate_extrusion_kwargs_by_z(builder.extrude_kwargs("Y"), angle) support_disk = builder.extrude( support_disk_circle, support_disk_depth, position=support_points[-1], **y_extrusion_kwargs ) diff --git a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py index a3e811dc1e..f825b92db4 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py +++ b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py @@ -624,17 +624,36 @@ class ShapeBuilder: return ifcopenshell.util.element.copy_deep(self.file, element) # UTILITIES - def extrude_by_y_kwargs(self): - """Shortcut to get kwargs for `ShapeBuilder.extrude` to extrude by Y axis. + def extrude_kwargs(self, axis): + """Shortcut to get kwargs for `ShapeBuilder.extrude` to extrude by some axis. - It assumes you have 2D profile in XZ plane and trying to extrude it by Y axis. + It assumes you have 2D profile in: + XZ plane for Y axis extrusion, \n + YZ plane for X axis extrusion, \n + XY plane for Z axis extrusion, \n - Extruding by Y using other kwargs might break ValidExtrusionDirection.""" - return { - "position_x_axis": Vector((1, 0, 0)), - "position_z_axis": Vector((0, -1, 0)), - "extrusion_vector": Vector((0, 0, -1)), - } + Extruding by X/Y using other kwargs might break ValidExtrusionDirection.""" + + axis = axis.upper() + + if axis == "Y": + return { + "position_x_axis": Vector((1, 0, 0)), + "position_z_axis": Vector((0, -1, 0)), + "extrusion_vector": Vector((0, 0, -1)), + } + elif axis == "X": + return { + "position_x_axis": Vector((0, 1, 0)), + "position_z_axis": Vector((1, 0, 0)), + "extrusion_vector": Vector((0, 0, 1)), + } + elif axis == "Z": + return { + "position_x_axis": Vector((1, 0, 0)), + "position_z_axis": Vector((0, 0, 1)), + "extrusion_vector": Vector((0, 0, 1)), + } def rotate_extrusion_kwargs_by_z(self, kwargs, angle, counter_clockwise=False): """shortcut to rotate extrusion kwargs by z axis @@ -1370,20 +1389,18 @@ class ShapeBuilder: points = inner_points + outer_points[::-1] points = [p + O for p in points] - offset = V(0, 0, 0) if is_circular_profile: bend_path = self.polyline(points, closed=False, arc_points=[1]) bend = self.create_swept_disk_solid(bend_path, profile_dim[lateral_axis]) else: + main_axes = lambda v: getattr(v, "xy"[lateral_axis] + "z") + offset = V(0, 0, 0) offset[non_lateral_axis] = -profile_dim[non_lateral_axis] - extrusion_vector = V(0, 0, 0) - extrusion_vector[non_lateral_axis] = 1 - bend = self.extrude_face_set( - points, - magnitude=profile_dim[non_lateral_axis] * 2, - offset=offset, - extrusion_vector=extrusion_vector, + extrusion_kwargs = self.extrude_kwargs("XY"[non_lateral_axis]) + profile_curve = self.polyline([main_axes(p) for p in points], closed=True) + bend = self.extrude( + self.profile(profile_curve), profile_dim[non_lateral_axis] * 2, position=offset, **extrusion_kwargs ) return bend From d8b9b09d14ddf3ca9d1cecca6bd6da0bcbae6030 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Tue, 12 Sep 2023 13:30:50 +0500 Subject: [PATCH 065/190] shapebuilder - replace y extrusion args with shortcut for readibility --- src/blenderbim/blenderbim/bim/module/model/opening.py | 8 ++------ src/blenderbim/blenderbim/bim/module/model/window.py | 4 +--- src/blenderbim/scripts/generate_furniture_library.py | 3 +-- .../ifcopenshell/api/geometry/add_door_representation.py | 4 +--- .../api/geometry/add_window_representation.py | 8 ++------ 5 files changed, 7 insertions(+), 20 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/model/opening.py b/src/blenderbim/blenderbim/bim/module/model/opening.py index 714f88f73f..4b4fe6c8e4 100644 --- a/src/blenderbim/blenderbim/bim/module/model/opening.py +++ b/src/blenderbim/blenderbim/bim/module/model/opening.py @@ -274,9 +274,7 @@ class FilledOpeningGenerator: get_curve_2d_from_3d(profile), magnitude=thickness / unit_scale, position=Vector([0.0, -thickness * 0.5 / unit_scale, 0.0]), - position_x_axis=Vector((1, 0, 0)), - position_z_axis=Vector((0, -1, 0)), - extrusion_vector=Vector((0, 0, -1)), + **shape_builder.extrude_kwargs("Y") ) return shape_builder.get_representation(context, [extrusion]) @@ -309,9 +307,7 @@ class FilledOpeningGenerator: shape_builder.rectangle(size=opening_size), magnitude=thickness / unit_scale, position=opening_position, - position_z_axis=Vector((0.0, -1.0, 0.0)), - position_x_axis=Vector((1.0, 0.0, 0.0)), - extrusion_vector=Vector((0.0, 0.0, -1.0)), + **shape_builder.extrude_kwargs("Y") ) return shape_builder.get_representation(context, [extrusion]) diff --git a/src/blenderbim/blenderbim/bim/module/model/window.py b/src/blenderbim/blenderbim/bim/module/model/window.py index 96d3cd5a6b..24499ee6b6 100644 --- a/src/blenderbim/blenderbim/bim/module/model/window.py +++ b/src/blenderbim/blenderbim/bim/module/model/window.py @@ -68,9 +68,7 @@ def update_simple_openings(element, opening_width, opening_height): shape_builder.rectangle(size=Vector([opening_width, 0.0, opening_height]).xz), magnitude=thickness / unit_scale, position=Vector([0.0, -0.1 / unit_scale, 0.0]), - position_x_axis=V(1, 0, 0), - position_z_axis=V(0, -1, 0), - extrusion_vector=V(0, 0, -1), + **shape_builder.extrude_kwargs("Y") ) new_representation = shape_builder.get_representation(context, extrusion) diff --git a/src/blenderbim/scripts/generate_furniture_library.py b/src/blenderbim/scripts/generate_furniture_library.py index d18920e900..a6153a30a3 100644 --- a/src/blenderbim/scripts/generate_furniture_library.py +++ b/src/blenderbim/scripts/generate_furniture_library.py @@ -1134,8 +1134,7 @@ class LibraryGenerator: back_wall_extruded = builder.extrude( back_wall, back_wall_depth + back_wall_border_mask_depth, - position_z_axis=V(0, -1, 0), - extrusion_vector=V(0, 0, -1), + **builder.extrude_kwargs("Y") ) items_3d_to_center.append(back_wall_extruded) diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_door_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_door_representation.py index df01f767db..4da8d5b1be 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_door_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_door_representation.py @@ -66,9 +66,7 @@ def create_ifc_door_lining( door_lining = builder.extrude( door_lining, size.y, - position_x_axis=V(1, 0, 0), - position_z_axis=V(0, -1, 0), - extrusion_vector=V(0, 0, -1), + **builder.extrude_kwargs("Y") ) builder.translate(door_lining, position) diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_window_representation.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_window_representation.py index 47b2f3745b..94b97636aa 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/add_window_representation.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/add_window_representation.py @@ -59,10 +59,8 @@ def create_ifc_window_frame_simple( return builder.extrude( profile, size.y, - position_x_axis=V(1, 0, 0), - position_z_axis=V(0, -1, 0), - extrusion_vector=V(0, 0, -1), position=position, + **builder.extrude_kwargs("Y") ) # if all lining sides are present then we can just use two rectangles @@ -212,10 +210,8 @@ def create_ifc_window( glass = builder.extrude( glass_rect, glass_thickness, - position_x_axis=V(1, 0, 0), - position_z_axis=V(0, -1, 0), - extrusion_vector=V(0, 0, -1), position=glass_position, + **builder.extrude_kwargs("Y") ) output_items = [lining_items, frame_extruded_items, [glass]] From 257208d55b143bef3b8c21a64153ca1674818498 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Tue, 12 Sep 2023 15:25:54 +0500 Subject: [PATCH 066/190] bends - support for start and end segments --- .../blenderbim/bim/module/model/mep.py | 12 +--- src/blenderbim/blenderbim/tool/cad.py | 4 ++ .../ifcopenshell/util/shape_builder.py | 61 ++++++++++++++----- 3 files changed, 54 insertions(+), 23 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/model/mep.py b/src/blenderbim/blenderbim/bim/module/model/mep.py index 54e62e54cc..75c74aa89d 100644 --- a/src/blenderbim/blenderbim/bim/module/model/mep.py +++ b/src/blenderbim/blenderbim/bim/module/model/mep.py @@ -882,12 +882,6 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator): ifc_file = tool.Ifc.get() si_conversion = ifcopenshell.util.unit.calculate_unit_scale(ifc_file) - self.start_length, self.end_length = 0, 0 - - if not (self.start_length == 0 and self.end_length == 0): - self.report({"ERROR"}, f"Only zero lengths are now supported.") - return {"CANCELLED"} - 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) @@ -971,7 +965,7 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator): end_segment_data["end_point"]: end_segment_data["end_port"], } - get_z_basis = lambda o: o.matrix_world.col[2].normalized().to_3d() + 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)), @@ -1058,8 +1052,8 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator): current_start_offset = segments_intersection.length current_end_offset = (segments_intersection - profile_offset).length - start_extend = current_start_offset - required_offset - end_extend = current_end_offset - required_offset + start_extend = current_start_offset - (required_offset + self.start_length) + end_extend = current_end_offset - (required_offset + self.end_length) return start_extend, end_extend diff --git a/src/blenderbim/blenderbim/tool/cad.py b/src/blenderbim/blenderbim/tool/cad.py index aca5ee6e06..41a66db0c2 100644 --- a/src/blenderbim/blenderbim/tool/cad.py +++ b/src/blenderbim/blenderbim/tool/cad.py @@ -526,3 +526,7 @@ class Cad: if cls.is_x(value, 0): return 0 return 1 if value > 0 else -1 + + @classmethod + def get_basis_vector(cls, object, axis_i): + return object.matrix_world.col[axis_i].normalized().to_3d() diff --git a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py index f825b92db4..c94581b71c 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py +++ b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py @@ -1356,10 +1356,26 @@ class ShapeBuilder: O[lateral_axis] = (radius + profile_dim[lateral_axis]) * lateral_sign theta = angle + def get_circle_point(angle, radius): + point = V(0, 0, 0) + angle -= pi / 2 + # fmt: off + point.z = z_sign * cos(angle) * radius + point[lateral_axis] = lateral_sign * sin(angle) * radius + # fmt: on + return point + + def get_circle_tangent(angle): + tangent = V(0, 0, 0) + tangent.z = cos(angle) * z_sign + tangent[lateral_axis] = sin(angle) * lateral_sign + return tangent + def get_bend_representation_item(): if is_circular_profile: theta_segments = [0, theta / 2, theta] else: + # TODO: try to replace with curves instead of segments # get as much segment_length segments as possible segment_length = pi / 20 num_segments = ceil(theta / segment_length) @@ -1371,32 +1387,27 @@ class ShapeBuilder: r = radius if is_circular_profile: r += profile_dim[lateral_axis] + else: + outer_r = r + 2 * profile_dim[lateral_axis] for cur_theta in theta_segments: - cur_theta -= pi / 2 - inner = V(0, 0, 0) - # fmt: off - inner.z = z_sign * cos(cur_theta) * r - inner[lateral_axis] = lateral_sign * sin(cur_theta) * r - inner_points.append(inner) - + inner_points.append(get_circle_point(cur_theta, r)) if not is_circular_profile: - outer = V(0, 0, 0) - outer.z = z_sign * cos(cur_theta) * (r + 2 * profile_dim[lateral_axis]) - outer[lateral_axis] = lateral_sign * sin(cur_theta) * (r + 2 * profile_dim[lateral_axis]) - outer_points.append(outer) - # fmt: on + outer_points.append(get_circle_point(cur_theta, outer_r)) points = inner_points + outer_points[::-1] points = [p + O for p in points] + offset = V(0, 0, 0) + offset.z = z_sign * start_length if is_circular_profile: - bend_path = self.polyline(points, closed=False, arc_points=[1]) + bend_path = self.polyline(points, closed=False, arc_points=[1], position_offset=offset) bend = self.create_swept_disk_solid(bend_path, profile_dim[lateral_axis]) else: main_axes = lambda v: getattr(v, "xy"[lateral_axis] + "z") - offset = V(0, 0, 0) + offset[non_lateral_axis] = -profile_dim[non_lateral_axis] + extrusion_kwargs = self.extrude_kwargs("XY"[non_lateral_axis]) profile_curve = self.polyline([main_axes(p) for p in points], closed=True) bend = self.extrude( @@ -1405,6 +1416,28 @@ class ShapeBuilder: return bend rep_items.append(get_bend_representation_item()) + if start_length: + rep_items.append(self.extrude(profile, start_length, extrusion_vector=V(0, 0, z_sign))) + if end_length: + end_position = O + get_circle_point(theta, radius + profile_dim[lateral_axis]) + end_position.z += start_length * z_sign + + # define extrusion space for the segment after the bend + z_axis = get_circle_tangent(theta) + extrude_kwargs = { + "position_z_axis": z_axis, + "extrusion_vector": Vector((0, 0, 1)), + } + # since we are sure that tangent involves only two axis + # it's safe to assume that non lateral axis is untouched + if lateral_axis == 0: + x_axis = z_axis.cross(Vector((0, 1, 0))) + else: + x_axis = Vector((1, 0, 0)) + extrude_kwargs["position_x_axis"] = x_axis + + rep_items.append(self.extrude(profile, end_length, end_position, **extrude_kwargs)) + body = ifcopenshell.util.representation.get_context(self.file, "Model", "Body", "MODEL_VIEW") rep = self.get_representation(body, rep_items) From d3df22b44bd0299456a61a67a112ef4762a408fd Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Tue, 12 Sep 2023 16:28:54 +0500 Subject: [PATCH 067/190] bends - use IfcArcIndex to define bends curves instead of manually generating polylines of segments --- .../ifcopenshell/util/shape_builder.py | 53 ++++++++++--------- 1 file changed, 29 insertions(+), 24 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py index c94581b71c..d106a0a29d 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py +++ b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py @@ -22,6 +22,7 @@ import ifcopenshell.api from math import cos, sin, pi, tan, radians, degrees, atan, sqrt, ceil from mathutils import Vector, Matrix from itertools import chain +from typing import List V = lambda *x: Vector([float(i) for i in x]) sign = lambda x: x and (1, -1)[x < 0] @@ -48,9 +49,25 @@ class ShapeBuilder: def __init__(self, ifc_file): self.file = ifc_file - def polyline(self, points, closed=False, position_offset=None, arc_points=[]): - # > points - list of points formatted like ( (x0, y0), (x1, y1) ) - # < IfcIndexedPolyCurve + def polyline( + self, points: List[Vector], closed: bool = False, position_offset: Vector = None, arc_points: List[int] = [] + ): + """ + Generate an IfcIndexedPolyCurve based on the provided points. + + :param points: List of points formatted as ( (x0, y0), (x1, y1) ) + :type: List[Vector] + :param closed: Whether polyline should be closed. Default is False. + :type: bool, optional + :param position_offset: Optional offset to be applied to all points. + :type: Optional[Vector] + :param arc_points: Indices of the middle points for arcs. For creating an arc segment, + provide 3 points: `arc_start`, `arc_middle` and `arc_end` and add the `arc_middle` + point's index to this list. + :type: List[int] + + :return: IfcIndexedPolyCurve + """ if arc_points and self.file.schema == "IFC2X3": raise Exception("Arcs are not supported for IFC2X3.") @@ -1372,44 +1389,32 @@ class ShapeBuilder: return tangent def get_bend_representation_item(): - if is_circular_profile: - theta_segments = [0, theta / 2, theta] - else: - # TODO: try to replace with curves instead of segments - # get as much segment_length segments as possible - segment_length = pi / 20 - num_segments = ceil(theta / segment_length) - theta_segments = [i * segment_length for i in range(num_segments)] - if not is_x(theta_segments[-1], theta): - theta_segments.append(theta) - - inner_points, outer_points = [], [] r = radius + theta_segments = [0, theta / 2, theta] if is_circular_profile: r += profile_dim[lateral_axis] + points = [get_circle_point(cur_theta, r) for cur_theta in theta_segments] + arc_points = (1,) else: outer_r = r + 2 * profile_dim[lateral_axis] + inner_points = [get_circle_point(cur_theta, r) for cur_theta in theta_segments] + outer_points = [get_circle_point(cur_theta, outer_r) for cur_theta in theta_segments[::-1]] + points = inner_points + outer_points + arc_points = (1, 4) - for cur_theta in theta_segments: - inner_points.append(get_circle_point(cur_theta, r)) - if not is_circular_profile: - outer_points.append(get_circle_point(cur_theta, outer_r)) - - points = inner_points + outer_points[::-1] points = [p + O for p in points] offset = V(0, 0, 0) offset.z = z_sign * start_length if is_circular_profile: - bend_path = self.polyline(points, closed=False, arc_points=[1], position_offset=offset) + bend_path = self.polyline(points, closed=False, arc_points=arc_points, position_offset=offset) bend = self.create_swept_disk_solid(bend_path, profile_dim[lateral_axis]) else: main_axes = lambda v: getattr(v, "xy"[lateral_axis] + "z") - offset[non_lateral_axis] = -profile_dim[non_lateral_axis] extrusion_kwargs = self.extrude_kwargs("XY"[non_lateral_axis]) - profile_curve = self.polyline([main_axes(p) for p in points], closed=True) + profile_curve = self.polyline([main_axes(p) for p in points], arc_points=arc_points, closed=True) bend = self.extrude( self.profile(profile_curve), profile_dim[non_lateral_axis] * 2, position=offset, **extrusion_kwargs ) From 5f6e003e4e3c1a8e7e65aa354ffee853a94cf768 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 13 Sep 2023 17:06:05 +1000 Subject: [PATCH 068/190] Implement COBie documents and attributes --- src/ifcfm/ifcfm/__init__.py | 32 +- src/ifcfm/ifcfm/basic.py | 238 ++++++------ src/ifcfm/ifcfm/cobie.py | 697 ++++++++++++++++++++++++++++-------- 3 files changed, 680 insertions(+), 287 deletions(-) diff --git a/src/ifcfm/ifcfm/__init__.py b/src/ifcfm/ifcfm/__init__.py index ce8f0960b8..e7a1960bc4 100644 --- a/src/ifcfm/ifcfm/__init__.py +++ b/src/ifcfm/ifcfm/__init__.py @@ -86,22 +86,8 @@ class Parser: class Writer: - def __init__(self, parser, colours=None): + def __init__(self, parser): self.parser = parser - if colours: - self.colours = colours - else: - self.colours = { - "h": "dddddd", # Header data - "p": "dc8774", # Primary identification data - "s": "b8dd73", # Secondary asset data - "r": "eda786", # Internal reference - "e": "96c7d0", # External / autogenerated data - "o": "ddb873", # Conditional / optional data - "n": "eeeeee", # Other data - "b": "000000", # Not in scope - } - if isinstance(self.parser.preset, str): module = importlib.import_module(f"ifcfm.{self.parser.preset}") self.config = getattr(module, "config") @@ -111,8 +97,12 @@ class Writer: def write(self, null="N/A", empty="-", bool_true="YES", bool_false="NO"): self.categories = {} + null = self.config.get("null", null) + empty = self.config.get("empty", empty) + bool_true = self.config.get("bool_true", bool_true) + bool_false = self.config.get("bool_false", bool_false) for category, data in self.parser.categories.items(): - headers = self.config.get(category, {}).get("headers", []) + headers = self.config.get("categories", {}).get(category, {}).get("headers", []) if not data: self.categories[category] = {"headers": headers, "rows": []} @@ -137,7 +127,7 @@ class Writer: processed_row.append(value) rows.append(processed_row) - sort = self.config.get(category, {}).get("sort", None) + sort = self.config.get("categories", {}).get(category, {}).get("sort", None) if sort: def natural_sort(value): @@ -169,13 +159,13 @@ class Writer: def write_ods(self, output): doc = OpenDocumentSpreadsheet() - for key, value in self.colours.items(): + for key, value in self.config.get("colours", {}).items(): style = Style(name=key, family="table-cell") style.addElement(TableCellProperties(backgroundcolor="#" + value)) doc.automaticstyles.addElement(style) for category, data in self.categories.items(): - colours = self.config.get(category, {}).get("colours", []) + colours = self.config.get("categories", {}).get(category, {}).get("colours", []) table = Table(name=category) tr = TableRow() @@ -208,12 +198,12 @@ class Writer: workbook = Workbook() cell_formats = {} - for key, value in self.colours.items(): + for key, value in self.config.get("colours", {}).items(): fill = PatternFill(start_color=value, end_color=value, fill_type="solid") cell_formats[key] = fill for category, data in self.categories.items(): - colours = self.config.get(category, {}).get("colours", []) + colours = self.config.get("categories", {}).get(category, {}).get("colours", []) if category in workbook.sheetnames: worksheet = workbook[category] diff --git a/src/ifcfm/ifcfm/basic.py b/src/ifcfm/ifcfm/basic.py index 74c3074d75..167ef4c5e2 100644 --- a/src/ifcfm/ifcfm/basic.py +++ b/src/ifcfm/ifcfm/basic.py @@ -273,119 +273,131 @@ get_element_data = { } config = { - "Actors": { - "headers": [ - "Name", - "Category", - "Email", - "Phone", - "CompanyURL", - "Department", - "Address1", - "Address2", - "StateRegion", - "PostalCode", - "Country", - ], - "colours": "ppssssssss", - "sort": [{"name": "Name", "order": "ASC"}], + "colours": { + "h": "dddddd", # Header data + "p": "dc8774", # Primary identification data + "s": "b8dd73", # Secondary asset data + "r": "eda786", # Internal reference + "e": "96c7d0", # External / autogenerated data + "o": "ddb873", # Conditional / optional data + "n": "eeeeee", # Other data + "b": "000000", # Not in scope }, - "Facilities": { - "headers": [ - "Name", - "ProjectName", - "SiteName", - "Category", - "AuthorOrganizationName", - "AuthorDate", - "ModelSoftware", - "ModelProjectID", - "ModelSiteID", - "ModelBuildingID", - "LinearUnits", - "AreaUnits", - "AreaMeasurement", - "Phase", - ], - "colours": "ppppreeeeessss", - "sort": [{"name": "Name", "order": "ASC"}], - }, - "Storeys": { - "headers": [ - "Name", - "Category", - "AuthorOrganizationName", - "AuthorDate", - "ModelSoftware", - "ModelObject", - "ModelID", - "Elevation", - ], - "colours": "ppreeees", - "sort": [{"name": "Elevation", "order": "ASC"}, {"name": "Name", "order": "ASC"}], - }, - "Spaces": { - "headers": [ - "Name", - "Description", - "Category", - "LevelName", - "AuthorOrganizationName", - "AuthorDate", - "ModelSoftware", - "ModelID", - "AreaGross", - "AreaNet", - ], - "colours": "ppprreeess", - "sort": [{"name": "LevelName", "order": "ASC"}, {"name": "Name", "order": "ASC"}], - }, - "Zones": { - "headers": ["Name", "SpaceName", "AuthorOrganizationName", "AuthorDate", "ModelSoftware", "ModelID"], - "colours": "prreee", - "sort": [{"name": "Name", "order": "ASC"}], - }, - "Types": { - "headers": [ - "Name", - "Description", - "Category", - "AuthorOrganizationName", - "AuthorDate", - "ModelSoftware", - "ModelObject", - "ModelTag", - "ModelID", - ], - "colours": "pppreeeee", - "sort": [{"name": "ModelObject", "order": "ASC"}, {"name": "Name", "order": "ASC"}], - }, - "Elements": { - "headers": [ - "Name", - "TypeName", - "SpaceName", - "SystemName", - "AuthorOrganizationName", - "AuthorDate", - "ModelSoftware", - "ModelObject", - "ModelID", - ], - "colours": "prrrreeee", - "sort": [{"name": "TypeName", "order": "ASC"}, {"name": "Name", "order": "ASC"}], - }, - "Systems": { - "headers": [ - "Name", - "Description", - "Category", - "AuthorOrganizationName", - "AuthorDate", - "ModelSoftware", - "ModelID", - ], - "colours": "pppreee", - "sort": [{"name": "Name", "order": "ASC"}], + "categories": { + "Actors": { + "headers": [ + "Name", + "Category", + "Email", + "Phone", + "CompanyURL", + "Department", + "Address1", + "Address2", + "StateRegion", + "PostalCode", + "Country", + ], + "colours": "ppssssssss", + "sort": [{"name": "Name", "order": "ASC"}], + }, + "Facilities": { + "headers": [ + "Name", + "ProjectName", + "SiteName", + "Category", + "AuthorOrganizationName", + "AuthorDate", + "ModelSoftware", + "ModelProjectID", + "ModelSiteID", + "ModelBuildingID", + "LinearUnits", + "AreaUnits", + "AreaMeasurement", + "Phase", + ], + "colours": "ppppreeeeessss", + "sort": [{"name": "Name", "order": "ASC"}], + }, + "Storeys": { + "headers": [ + "Name", + "Category", + "AuthorOrganizationName", + "AuthorDate", + "ModelSoftware", + "ModelObject", + "ModelID", + "Elevation", + ], + "colours": "ppreeees", + "sort": [{"name": "Elevation", "order": "ASC"}, {"name": "Name", "order": "ASC"}], + }, + "Spaces": { + "headers": [ + "Name", + "Description", + "Category", + "LevelName", + "AuthorOrganizationName", + "AuthorDate", + "ModelSoftware", + "ModelID", + "AreaGross", + "AreaNet", + ], + "colours": "ppprreeess", + "sort": [{"name": "LevelName", "order": "ASC"}, {"name": "Name", "order": "ASC"}], + }, + "Zones": { + "headers": ["Name", "SpaceName", "AuthorOrganizationName", "AuthorDate", "ModelSoftware", "ModelID"], + "colours": "prreee", + "sort": [{"name": "Name", "order": "ASC"}], + }, + "Types": { + "headers": [ + "Name", + "Description", + "Category", + "AuthorOrganizationName", + "AuthorDate", + "ModelSoftware", + "ModelObject", + "ModelTag", + "ModelID", + ], + "colours": "pppreeeee", + "sort": [{"name": "ModelObject", "order": "ASC"}, {"name": "Name", "order": "ASC"}], + }, + "Elements": { + "headers": [ + "Name", + "TypeName", + "SpaceName", + "SystemName", + "AuthorOrganizationName", + "AuthorDate", + "ModelSoftware", + "ModelObject", + "ModelID", + ], + "colours": "prrrreeee", + "sort": [{"name": "TypeName", "order": "ASC"}, {"name": "Name", "order": "ASC"}], + }, + "Systems": { + "headers": [ + "Name", + "Description", + "Category", + "AuthorOrganizationName", + "AuthorDate", + "ModelSoftware", + "ModelID", + ], + "colours": "pppreee", + "sort": [{"name": "Name", "order": "ASC"}], + }, }, } diff --git a/src/ifcfm/ifcfm/cobie.py b/src/ifcfm/ifcfm/cobie.py index 10458ae5a2..3b4b5d47df 100644 --- a/src/ifcfm/ifcfm/cobie.py +++ b/src/ifcfm/ifcfm/cobie.py @@ -97,7 +97,10 @@ def get_components(ifc_file): def get_systems(ifc_file): results = [] components = get_components(ifc_file) - systems = ifc_file.by_type("IfcSystem", include_subtypes=False) + ifc_file.by_type("IfcDistributionSystem") + if ifc_file.schema == "IFC2X3": + systems = ifc_file.by_type("IfcSystem", include_subtypes=False) + else: + systems = ifc_file.by_type("IfcSystem", include_subtypes=False) + ifc_file.by_type("IfcDistributionSystem") for system in systems: for element in ifcopenshell.util.system.get_system_elements(system): if element in components: @@ -156,9 +159,107 @@ def get_jobs(ifc_file): def get_documents(ifc_file): - return [ - r for r in ifc_file.by_type("IfcRelAssociatesDocument") if r.RelatingDocument.is_a("IfcDocumentInformation") - ] + # The original COBie-Plugins assumes a single related object per rel. I think this was wrong. + results = [] + for rel in ifc_file.by_type("IfcRelAssociatesDocument"): + doc = rel.RelatingDocument + if doc.is_a("IfcDocumentInformation"): + for related_object in rel.RelatedObjects: + results.append((rel, doc, related_object)) + elif doc.is_a("IfcDocumentReference") and doc.ReferencedDocument: + for related_object in rel.RelatedObjects: + results.append((rel, doc.ReferencedDocument, related_object)) + return results + + +def get_attributes(ifc_file): + results = [] + history = get_history(ifc_file) + created_by = get_email_from_history(history) if history else None + created_on = ifcopenshell.util.date.ifc2datetime(history.CreationDate).isoformat() if history else None + external_system = history.OwningApplication.ApplicationFullName if history else None + get_sheets = { + "Facility": get_facilities, + "Floor": get_floors, + "Space": get_spaces, + "Type": get_types, + "Component": get_components, + } + + # COBie-Plugins includes what seems like a whole bunch of arbitrary "at the + # time it seemed to help" exclusion names. I don't like that strategy. If + # you've got garbage in your model, clean it out first. + + # fmt: off + excluded_names = { + "Manufacturer", + "ModelNumber", "ArticleNumber", "ModelLabel", + "WarrantyGuarantorParts", "PointOfContact", + "WarrantyGuarantorLabor", "PointOfContact", + "WarrantyDescription", "WarrantyIdentifier", + "ReplacementCost", "Replacement Cost", "Replacement", "Cost", + "NominalLength", "OverallLength", + "NominalWidth", "Width", + "NominalHeight", "Height", + "ModelReference", "Reference", + "Shape", + "Size", + "Color", "Colour", + "Finish", + "Grade", + "Material", + "Constituents", "Parts", + "Features", + "AccessibilityPerformance", "Access", + "CodePerformance", "Regulation", + "SustainabilityPerformance", "Environmental", + "SerialNumber", "InstallationDate", "WarrantyStartDate", "TagNumber", "BarCode", "AssetIdentifier" + } + # fmt: on + + for sheet_name, get_sheet in get_sheets.items(): + for element in get_sheet(ifc_file): + for pset_name, props in ifcopenshell.util.element.get_psets(element).items(): + pset = ifc_file.by_id(props["id"]) + pset_created_by = get_created_by(pset) or created_by + pset_created_on = get_created_on(pset) or created_on + pset_external_system = get_external_system(element) or external_system + pset_description = val(pset.Description) or pset_name + category = get_category(pset) + for name, value in props.items(): + if value == "default" or not val(value): + continue + elif name in excluded_names: + continue + elif name == "id": + continue + + unit = None + if isinstance(value, (int, float)): + unit = get_property_unit(props["id"], name) + + allowed_values = None + if isinstance(value, (tuple, list)): + allowed_values = get_property_unit(props["id"], name) + + data = { + "key": str(val(name)) + str(sheet_name) + str(val(element.Name)), + "Name": val(name), + "CreatedBy": pset_created_by, + "CreatedOn": pset_created_on, + "Category": category, + "SheetName": sheet_name, + "RowName": val(element.Name), + "Value": value, + "Unit": unit, + "ExternalSystem": pset_external_system, + "ExternalObject": pset_name, + "ExternalIdentifier": pset.GlobalId, + "Description": pset_description, + "AllowedValues": allowed_values, + } + results.append(data) + return results def get_contact_data(ifc_file, element): @@ -263,7 +364,7 @@ def get_facility_data(ifc_file, element): def get_floor_data(ifc_file, element): external_object = element.is_a() - if external_object.ObjectType and external_object.ObjectType.lower() in ("site", "ifcsite"): + if element.ObjectType and element.ObjectType.lower() in ("site", "ifcsite"): external_object = "IfcSite" height_names = { @@ -279,7 +380,7 @@ def get_floor_data(ifc_file, element): } height = None - for _, props in ifcopenshell.util.element.get_psets(element): + for _, props in ifcopenshell.util.element.get_psets(element).items(): if height is not None: break for name, value in props.items(): @@ -287,9 +388,12 @@ def get_floor_data(ifc_file, element): height = str(value) break + elevation = getattr(element, "Elevation", "") + elevation = "" if elevation is None else str(elevation) + return { - "key": var(element.Name), - "Name": var(element.Name), + "key": val(element.Name), + "Name": val(element.Name), "CreatedBy": get_created_by(element), "CreatedOn": get_created_on(element), "Category": get_category(element), @@ -297,7 +401,7 @@ def get_floor_data(ifc_file, element): "ExternalObject": external_object, "ExternalIdentifier": element.GlobalId, "Description": val(element.Description) or val(element.LongName) or val(element.Name), - "Elevation": val(str(getattr(element, "Elevation", ""))), + "Elevation": val(elevation), "Height": height, } @@ -316,7 +420,7 @@ def get_space_data(ifc_file, element): gross_area_names = {"GrossFloorArea", "GSA"} net_area = None net_area_names = {"NetFloorArea", "GSA"} - for _, props in ifcopenshell.util.element.get_psets(element): + for _, props in ifcopenshell.util.element.get_psets(element).items(): for name, value in props.items(): if not room_tag and name in room_tag_names and val(value): room_tag = str(value) @@ -423,7 +527,7 @@ def get_type_data(ifc_file, element): expected_life_names = {"ExpectedLife", "Expected Life", "ServiceLifeDuration", "Expected"} duration_unit = None - for pset_name, props in ifcopenshell.util.element.get_psets(element): + for pset_name, props in ifcopenshell.util.element.get_psets(element).items(): pset_warranty_type = None if pset_name == "Pset_Warranty": if "parts" in (props.get("WarrantyIdentifier", "") or "").lower(): @@ -431,6 +535,8 @@ def get_type_data(ifc_file, element): elif "labor" in (props.get("WarrantyIdentifier", "") or "").lower(): pset_warranty_type = "labor" + pset = ifc_file.by_id(props["id"]) + for name, value in props.items(): for key, prop_names in pset_mapping.items(): if not pset_metadata.get(key, None) and name in prop_names and val(value): @@ -445,15 +551,15 @@ def get_type_data(ifc_file, element): if not warranty_duration_parts and name in warranty_duration_parts_names and val(value): warranty_duration_parts = str(value) if not warranty_duration_unit: - warranty_duration_unit = get_property_unit(props["id"], name) + warranty_duration_unit = get_property_unit(pset, name) if not warranty_duration_labor and name in warranty_duration_labor_names and val(value): warranty_duration_labor = str(value) if not warranty_duration_unit: - warranty_duration_unit = get_property_unit(props["id"], name) + warranty_duration_unit = get_property_unit(pset, name) if not expected_life and name in expected_life_names and val(value): expected_life = str(value) if not duration_unit: - duration_unit = get_property_unit(props["id"], name) + duration_unit = get_property_unit(pset, name) if pset_warranty_type == "parts" and val(value): if name == "PointOfContact": @@ -461,7 +567,7 @@ def get_type_data(ifc_file, element): warranty_guarantor_parts = str(value) elif name == "WarrantyPeriod": warranty_duration_parts = str(value) - unit = get_property_unit(props["id"], name) + unit = get_property_unit(pset, name) warranty_duration_unit = unit or warranty_duration_unit elif pset_warranty_type == "labor" and val(value): if name == "PointOfContact": @@ -469,7 +575,7 @@ def get_type_data(ifc_file, element): warranty_guarantor_labor = str(value) elif name == "WarrantyPeriod": warranty_duration_labor = str(value) - unit = get_property_unit(props["id"], name) + unit = get_property_unit(pset, name) warranty_duration_unit = unit or warranty_duration_unit if warranty_duration_parts or warranty_duration_labor: @@ -480,12 +586,12 @@ def get_type_data(ifc_file, element): asset_type = "Moveable" return { - "key": var(element.Name), - "Name": var(element.Name), + "key": val(element.Name), + "Name": val(element.Name), "CreatedBy": get_created_by(element), "CreatedOn": get_created_on(element), "Category": get_category(element), - "Description": var(element.Description) or var(element.Name), + "Description": val(element.Description) or val(element.Name), "AssetType": asset_type, "Manufacturer": pset_metadata.get("manufacturer", None), "ModelNumber": pset_metadata.get("model_number", None), @@ -540,7 +646,7 @@ def get_component_data(ifc_file, element): bar_code = None asset_identifier = None - for _, props in ifcopenshell.util.element.get_psets(element): + for _, props in ifcopenshell.util.element.get_psets(element).items(): for name, value in props.items(): if not serial_number and name == "SerialNumber" and val(value): serial_number = str(value) @@ -562,7 +668,7 @@ def get_component_data(ifc_file, element): "CreatedOn": get_created_on(element), "TypeName": type_name, "Space": space_name, - "Description": var(element.Description) or var(element.Name), + "Description": val(element.Description) or val(element.Name), "ExternalSystem": get_external_system(element), "ExternalObject": element.is_a(), "ExternalIdentifier": element.GlobalId, @@ -608,15 +714,7 @@ def get_assembly_data(ifc_file, element): name = val(relating_object.Name) parent_name = name assembly_type = "Fixed" - sheet_name = "Component" - if relating_object.is_a("IfcSpace"): - sheet_name = "Space" - elif relating_object.is_a("IfcZone"): - sheet_name = "Zone" - elif relating_object.is_a("IfcSystem"): - sheet_name = "System" - elif relating_object.is_a("IfcElementType"): - sheet_name = "Type" + sheet_name = get_sheet_name(relating_object) description = val(rel.Description) or val(rel.Name) child_name = val(related_object.Name) @@ -677,7 +775,7 @@ def get_spare_data(ifc_file, element): suppliers = None set_number = None part_number = None - for _, props in ifcopenshell.util.element.get_psets(element): + for _, props in ifcopenshell.util.element.get_psets(element).items(): for name, value in props.items(): if name == "Suppliers" and val(value): suppliers = str(value) @@ -738,20 +836,21 @@ def get_job_data(ifc_file, element): frequency = None frequency_unit = None - for _, props in ifcopenshell.util.element.get_psets(element): + for _, props in ifcopenshell.util.element.get_psets(element).items(): + pset = ifc_file.by_id(props["id"]) for name, value in props.items(): if not duration and name == "TaskDuration" and val(value): duration = str(value) if not duration_unit: - duration_unit = get_property_unit(props["id"], name) + duration_unit = get_property_unit(pset, name) if not start and name == "TaskStartDate" and val(value): start = str(value) if not task_start_unit: - task_start_unit = get_property_unit(props["id"], name) + task_start_unit = get_property_unit(pset, name) if not frequency and name == "TaskInterval" and val(value): frequency = str(value) if not frequency_unit: - frequency_unit = get_property_unit(props["id"], name) + frequency_unit = get_property_unit(pset, name) task_number = val(getattr(element, "Id", None)) or val(getattr(element, "Identification", None)) @@ -765,7 +864,7 @@ def get_job_data(ifc_file, element): priors = ",".join(priors) if priors else task_number return { - "key": val(element.Name), + "key": str(val(element.Name)) + str(type_name) + str(task_number), "Name": val(element.Name), "CreatedBy": get_created_by(element), "CreatedOn": get_created_on(element), @@ -788,6 +887,48 @@ def get_job_data(ifc_file, element): } +def get_document_data(ifc_file, element): + rel, doc, related_object = element + directory = getattr(doc, "Location", None) + file = None + if not directory: + references = getattr(doc, "DocumentReferences", []) or getattr(doc, "HasDocumentReferences", []) + for reference in references or []: + if val(reference.Location): + directory = reference.Location + identification = getattr(reference, "ItemReference", None) or getattr(reference, "Identification", None) + if val(reference.Name) and doc.Name != reference.Name: + file = reference.Name + elif val(identification): + file = identification + name = val(doc.Name) + stage = val(doc.Scope) or "Requirement" + sheet_name = get_sheet_name(related_object) + row_name = val(related_object.Name) + return { + "key": str(name) + str(stage) + str(sheet_name) + str(row_name), + "Name": name, + "CreatedBy": get_created_by(rel), + "CreatedOn": get_created_on(rel), + "Category": val(doc.Purpose), + "ApprovalBy": val(doc.IntendedUse) or "Information Only", + "Stage": stage, + "SheetName": sheet_name, + "RowName": row_name, + "Directory": directory, + "File": file, + "ExternalSystem": get_external_system(rel), + "ExternalObject": rel.is_a(), + "ExternalIdentifier": rel.GlobalId, + "Description": val(doc.Description), + "Reference": val(doc.Description) or val(doc.Name), + } + + +def get_attribute_data(ifc_file, element): + return element + + def get_unit_type_name(ifc_file, unit_type): for unit in ifc_file.by_type("IfcUnitAssignment")[0].Units: if unit.is_a("IfcNamedUnit") and unit.UnitType == unit_type: @@ -934,7 +1075,7 @@ def get_category(element): def get_pao_address(element, name): - for actor in [element.TheActor.ThePerson, element.TheActor.TheOrganization]: + for actor in [element.ThePerson, element.TheOrganization]: for address in actor.Addresses or []: if hasattr(address, name) and getattr(address, name, None): result = getattr(address, name) @@ -959,8 +1100,7 @@ def get_history(ifc_file): return sorted(histories, key=lambda x: x.id())[-1] -def get_property_unit(pset_id, prop_name): - pset = ifc_file.by_id(pset_id) +def get_property_unit(pset, prop_name): for prop in getattr(pset, "HasProperties", []) or []: if prop.Name == prop_name: unit = getattr(prop, "Unit", None) @@ -968,6 +1108,37 @@ def get_property_unit(pset_id, prop_name): return get_unit_name(unit) +def get_allowed_values(pset_id, prop_name): + pset = ifc_file.by_id(pset_id) + for prop in getattr(pset, "HasProperties", []) or []: + if prop.Name == prop_name: + if prop.is_a("IfcPropertyEnumeratedValue") and prop.EnumerationValues: + return ",".join([v.wrappedValue for v in prop.EnumerationValues]) + + +def get_sheet_name(element): + if element.is_a("IfcBuilding"): + return "Facility" + elif element.is_a("IfcBuildingStorey"): + return "Floor" + elif element.is_a("IfcSpace"): + return "Space" + elif element.is_a("IfcZone"): + return "Zone" + elif element.is_a("IfcSystem"): + return "System" + elif element.is_a("IfcElementType"): + return "Type" + elif element.is_a("IfcElement"): + return "Component" + elif element.is_a("IfcTask"): + return "Job" + elif element.is_a("IfcConstructionProductResource"): + return "Spare" + elif element.is_a("IfcConstructionEquipmentResource"): + return "Resource" + + get_category_elements = { "Contact": get_contacts, "Facility": get_facilities, @@ -1013,120 +1184,340 @@ get_element_data = { # "Picklist": get_picklist_data, } + config = { - "Actors": { - "headers": [ - "Name", - "Category", - "Email", - "Phone", - "CompanyURL", - "Department", - "Address1", - "Address2", - "StateRegion", - "PostalCode", - "Country", - ], - "colours": "ppssssssss", - "sort": [{"name": "Name", "order": "ASC"}], - }, - "Facilities": { - "headers": [ - "Name", - "ProjectName", - "SiteName", - "Category", - "AuthorOrganizationName", - "AuthorDate", - "ModelSoftware", - "ModelProjectID", - "ModelSiteID", - "ModelBuildingID", - "LinearUnits", - "AreaUnits", - "AreaMeasurement", - "Phase", - ], - "colours": "ppppreeeeessss", - "sort": [{"name": "Name", "order": "ASC"}], - }, - "Storeys": { - "headers": [ - "Name", - "Category", - "AuthorOrganizationName", - "AuthorDate", - "ModelSoftware", - "ModelObject", - "ModelID", - "Elevation", - ], - "colours": "ppreeees", - "sort": [{"name": "Elevation", "order": "ASC"}, {"name": "Name", "order": "ASC"}], - }, - "Spaces": { - "headers": [ - "Name", - "Description", - "Category", - "LevelName", - "AuthorOrganizationName", - "AuthorDate", - "ModelSoftware", - "ModelID", - "AreaGross", - "AreaNet", - ], - "colours": "ppprreeess", - "sort": [{"name": "LevelName", "order": "ASC"}, {"name": "Name", "order": "ASC"}], - }, - "Zones": { - "headers": ["Name", "SpaceName", "AuthorOrganizationName", "AuthorDate", "ModelSoftware", "ModelID"], - "colours": "prreee", - "sort": [{"name": "Name", "order": "ASC"}], - }, - "Types": { - "headers": [ - "Name", - "Description", - "Category", - "AuthorOrganizationName", - "AuthorDate", - "ModelSoftware", - "ModelObject", - "ModelTag", - "ModelID", - ], - "colours": "pppreeeee", - "sort": [{"name": "ModelObject", "order": "ASC"}, {"name": "Name", "order": "ASC"}], - }, - "Elements": { - "headers": [ - "Name", - "TypeName", - "SpaceName", - "SystemName", - "AuthorOrganizationName", - "AuthorDate", - "ModelSoftware", - "ModelObject", - "ModelID", - ], - "colours": "prrrreeee", - "sort": [{"name": "TypeName", "order": "ASC"}, {"name": "Name", "order": "ASC"}], - }, - "Systems": { - "headers": [ - "Name", - "Description", - "Category", - "AuthorOrganizationName", - "AuthorDate", - "ModelSoftware", - "ModelID", - ], - "colours": "pppreee", - "sort": [{"name": "Name", "order": "ASC"}], + "colours": { + "h": "c0c0c0", # Header data + "r": "ffff99", # Required + "i": "ffcc99", # Internal reference + "e": "cc99ff", # External reference + "o": "ccffcc", # Optionally specified + "s": "c0c0c0", # Secondary product data + "b": "99ccff", # Bespoke data + "x": "000000", # Not in scope }, + "null": "n/a", + "empty": "n/a", + "bool_true": "Yes", + "bool_false": "No", + "categories": { + "Contact": { + "headers": [ + "Email", + "CreatedBy", + "CreatedOn", + "Category", + "Company", + "Phone", + "ExternalSystem", + "ExternalObject", + "ExternalIdentifier", + "Department", + "OrganizationCode", + "GivenName", + "FamilyName", + "Street", + "PostalBox", + "Town", + "StateRegion", + "PostalCode", + "Country", + ], + "colours": "rrrrrreeeoooooooooo", + "sort": [{"name": "Email", "order": "ASC"}], + }, + "Facility": { + "headers": [ + "Name", + "CreatedBy", + "CreatedOn", + "Category", + "ProjectName", + "SiteName", + "LinearUnits", + "AreaUnits", + "VolumeUnits", + "CurrencyUnit", + "AreaMeasurement", + "ExternalSystem", + "ExternalProjectObject", + "ExternalProjectIdentifier", + "ExternalSiteObject", + "ExternalSiteIdentifier", + "ExternalFacilityObject", + "ExternalFacilityIdentifier", + "Description", + "ProjectDescription", + "SiteDescription", + "Phase", + ], + "colours": "ririrriiiireeeeeeeoooo", + "sort": [{"name": "Name", "order": "ASC"}], + }, + "Floor": { + "headers": [ + "Name", + "CreatedBy", + "CreatedOn", + "Category", + "ExternalSystem", + "ExternalObject", + "ExternalIdentifier", + "Description", + "Elevation", + "Height", + ], + "colours": "ririeeeooo", + "sort": [{"name": "Elevation", "order": "ASC"}, {"name": "Name", "order": "ASC"}], + }, + "Space": { + "headers": [ + "Name", + "CreatedBy", + "CreatedOn", + "Category", + "FloorName", + "Description", + "ExternalSystem", + "ExternalObject", + "ExternalIdentifier", + "RoomTag", + "UsableHeight", + "GrossArea", + "NetArea", + ], + "colours": "ririrreeeoooo", + "sort": [{"name": "FloorName", "order": "ASC"}, {"name": "Name", "order": "ASC"}], + }, + "Zone": { + "headers": [ + "Name", + "CreatedBy", + "CreatedOn", + "Category", + "SpaceNames", + "ExternalSystem", + "ExternalObject", + "ExternalIdentifier", + "Description", + ], + "colours": "ririreeeo", + "sort": [{"name": "Name", "order": "ASC"}], + }, + "Type": { + "headers": [ + "Name", + "CreatedBy", + "CreatedOn", + "Category", + "Description", + "AssetType", + "Manufacturer", + "ModelNumber", + "WarrantyGuarantorParts", + "WarrantyDurationParts", + "WarrantyGuarantorLabor", + "WarrantyDurationLabor", + "WarrantyDurationUnit", + "ExternalSystem", + "ExternalObject", + "ExternalIdentifier", + "ReplacementCost", + "ExpectedLife", + "DurationUnit", + "WarrantyDescription", + "NominalLength", + "NominalWidth", + "NominalHeight", + "ModelReference", + "Shape", + "Size", + "Color", + "Finish", + "Grade", + "Material", + "Constituents", + "Features", + "AccessibilityPerformance", + "CodePerformance", + "SustainabilityPerformance", + ], + "colours": "riririiriririeeeooiorrroooooooooooo", + "sort": [{"name": "ExternalObject", "order": "ASC"}, {"name": "Name", "order": "ASC"}], + }, + "Component": { + "headers": [ + "Name", + "CreatedBy", + "CreatedOn", + "TypeName", + "Space", + "Description", + "ExternalSystem", + "ExternalObject", + "ExternalIdentifier", + "SerialNumber", + "InstallationDate", + "WarrantyStartDate", + "TagNumber", + "BarCode", + "AssetIdentifier", + ], + "colours": "ririireeeoooooo", + "sort": [ + {"name": "ExternalObject", "order": "ASC"}, + {"name": "TypeName", "order": "ASC"}, + {"name": "Name", "order": "ASC"}, + ], + }, + "System": { + "headers": [ + "Name", + "CreatedBy", + "CreatedOn", + "Category", + "ComponentNames", + "ExternalSystem", + "ExternalObject", + "ExternalIdentifier", + "Description", + ], + "colours": "ririieeeo", + "sort": [{"name": "Name", "order": "ASC"}], + }, + "Assembly": { + "headers": [ + "Name", + "CreatedBy", + "CreatedOn", + "SheetName", + "ParentName", + "ChildNames", + "AssemblyType", + "ExternalSystem", + "ExternalObject", + "ExternalIdentifier", + "Description", + ], + "colours": "ririiiieeeo", + "sort": [{"name": "Name", "order": "ASC"}], + }, + "Connection": { + "headers": [ + "Name", + "CreatedBy", + "CreatedOn", + "ConnectionType", + "SheetName", + "RowName1", + "RowName2", + "RealizingElement", + "PortName1", + "PortName2", + "ExternalSystem", + "ExternalObject", + "ExternalIdentifier", + "Description", + ], + "colours": "ririiiiiiieeeo", + "sort": [{"name": "Name", "order": "ASC"}], + }, + "Spare": { + "headers": [ + "Name", + "CreatedBy", + "CreatedOn", + "Category", + "TypeName", + "Suppliers", + "ExternalSystem", + "ExternalObject", + "ExternalIdentifier", + "Description", + "SetNumber", + "PartNumber", + ], + "colours": "ririiieeeooo", + "sort": [{"name": "Name", "order": "ASC"}], + }, + "Resource": { + "headers": [ + "Name", + "CreatedBy", + "CreatedOn", + "Category", + "ExternalSystem", + "ExternalObject", + "ExternalIdentifier", + "Description", + ], + "colours": "ririeeeo", + "sort": [{"name": "Name", "order": "ASC"}], + }, + "Job": { + "headers": [ + "Name", + "CreatedBy", + "CreatedOn", + "Category", + "Status", + "TypeName", + "Description", + "Duration", + "DurationUnit", + "Start", + "TaskStartUnit", + "Frequency", + "FrequencyUnit", + "ExternalSystem", + "ExternalObject", + "ExternalIdentifier", + "TaskNumber", + "Priors", + "ResourceNames", + ], + "colours": "ririiirriririeeeoii", + "sort": [{"name": "TypeName", "order": "ASC"}, {"name": "TaskNumber", "order": "ASC"}], + }, + "Document": { + "headers": [ + "Name", + "CreatedBy", + "CreatedOn", + "Category", + "ApprovalBy", + "Stage", + "SheetName", + "RowName", + "Directory", + "File", + "ExternalSystem", + "ExternalObject", + "ExternalIdentifier", + "Description", + "Reference", + ], + "colours": "ririiiiirreeeoo", + "sort": [{"name": "Name", "order": "ASC"}], + }, + "Attribute": { + "headers": [ + "Name", + "CreatedBy", + "CreatedOn", + "Category", + "SheetName", + "RowName", + "Value", + "Unit", + "ExternalSystem", + "ExternalObject", + "ExternalIdentifier", + "Description", + "AllowedValues", + ], + "colours": "ririiirreeeoo", + "sort": [{"name": "Category", "order": "ASC"}, {"name": "Name", "order": "ASC"}], + }, + } } From 74dec650a7e648ea0bb3c3f380bcf45dbadbc20b Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 13 Sep 2023 17:06:42 +1000 Subject: [PATCH 069/190] New FM module in BlenderBIM Add-on for interacting with IfcFM. --- src/blenderbim/Makefile | 2 + src/blenderbim/blenderbim/bim/__init__.py | 1 + .../blenderbim/bim/module/fm/__init__.py | 35 ++++++++ .../blenderbim/bim/module/fm/operator.py | 83 +++++++++++++++++++ .../blenderbim/bim/module/fm/prop.py | 54 ++++++++++++ src/blenderbim/blenderbim/bim/module/fm/ui.py | 54 ++++++++++++ 6 files changed, 229 insertions(+) create mode 100644 src/blenderbim/blenderbim/bim/module/fm/__init__.py create mode 100644 src/blenderbim/blenderbim/bim/module/fm/operator.py create mode 100644 src/blenderbim/blenderbim/bim/module/fm/prop.py create mode 100644 src/blenderbim/blenderbim/bim/module/fm/ui.py diff --git a/src/blenderbim/Makefile b/src/blenderbim/Makefile index 736a2277a1..5d3ceeeee3 100644 --- a/src/blenderbim/Makefile +++ b/src/blenderbim/Makefile @@ -199,6 +199,8 @@ endif cp -r dist/working/IfcOpenShell-0.7.0/src/ifcbimtester/bimtester dist/blenderbim/libs/site/packages/ # Provides IFCTester functionality cp -r dist/working/IfcOpenShell-0.7.0/src/ifctester/ifctester dist/blenderbim/libs/site/packages/ + # Provides IFCFM functionality + cp -r dist/working/IfcOpenShell-0.7.0/src/ifcfm/ifcfm dist/blenderbim/libs/site/packages/ # Provides IFCCOBie functionality cp -r dist/working/IfcOpenShell-0.7.0/src/ifccobie/* dist/blenderbim/libs/site/packages/ # Provides IFCDiff functionality diff --git a/src/blenderbim/blenderbim/bim/__init__.py b/src/blenderbim/blenderbim/bim/__init__.py index de25096754..5f2e0fe87e 100644 --- a/src/blenderbim/blenderbim/bim/__init__.py +++ b/src/blenderbim/blenderbim/bim/__init__.py @@ -44,6 +44,7 @@ modules = { "void": None, "aggregate": None, "geometry": None, + "fm": None, "cobie": None, "resource": None, "cost": None, diff --git a/src/blenderbim/blenderbim/bim/module/fm/__init__.py b/src/blenderbim/blenderbim/bim/module/fm/__init__.py new file mode 100644 index 0000000000..de3f0f9448 --- /dev/null +++ b/src/blenderbim/blenderbim/bim/module/fm/__init__.py @@ -0,0 +1,35 @@ +# BlenderBIM Add-on - OpenBIM Blender Add-on +# Copyright (C) 2023 Dion Moult +# +# This file is part of BlenderBIM Add-on. +# +# BlenderBIM Add-on 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. +# +# BlenderBIM Add-on 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 BlenderBIM Add-on. If not, see . + +import bpy +from . import ui, prop, operator + +classes = ( + operator.SelectFMIfcFile, + operator.ExecuteIfcFM, + prop.BIMFMProperties, + ui.BIM_PT_fm, +) + + +def register(): + bpy.types.Scene.BIMFMProperties = bpy.props.PointerProperty(type=prop.BIMFMProperties) + + +def unregister(): + del bpy.types.Scene.BIMFMProperties diff --git a/src/blenderbim/blenderbim/bim/module/fm/operator.py b/src/blenderbim/blenderbim/bim/module/fm/operator.py new file mode 100644 index 0000000000..a53c92dc0c --- /dev/null +++ b/src/blenderbim/blenderbim/bim/module/fm/operator.py @@ -0,0 +1,83 @@ +# BlenderBIM Add-on - OpenBIM Blender Add-on +# Copyright (C) 2023 Dion Moult +# +# This file is part of BlenderBIM Add-on. +# +# BlenderBIM Add-on 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. +# +# BlenderBIM Add-on 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 BlenderBIM Add-on. If not, see . + +import os +import bpy +import json +import logging +import tempfile +import webbrowser +import ifcopenshell +import blenderbim.tool as tool + + +class SelectFMIfcFile(bpy.types.Operator): + bl_idname = "bim.select_fm_ifc_file" + bl_label = "Select FM IFC File" + bl_options = {"REGISTER", "UNDO"} + filename_ext = ".ifc" + filter_glob: bpy.props.StringProperty(default="*.ifc;*.ifczip;*.ifcxml", options={"HIDDEN"}) + filepath: bpy.props.StringProperty(subtype="FILE_PATH") + + def execute(self, context): + context.scene.BIMFMProperties.ifc_file = self.filepath + return {"FINISHED"} + + def invoke(self, context, event): + context.window_manager.fileselect_add(self) + return {"RUNNING_MODAL"} + + +class ExecuteIfcFM(bpy.types.Operator): + bl_idname = "bim.execute_ifcfm" + bl_label = "Execute IfcFM" + file_format: bpy.props.StringProperty() + filter_glob: bpy.props.StringProperty(default="*.csv;*.ods;*.xlsx", options={"HIDDEN"}) + filepath: bpy.props.StringProperty(subtype="FILE_PATH") + + @classmethod + def poll(cls, context): + props = context.scene.BIMFMProperties + return props.should_load_from_memory or props.ifc_file + + def invoke(self, context, event): + props = context.scene.BIMFMProperties + self.filepath = bpy.path.ensure_ext(bpy.data.filepath, f".{props.format}") + WindowManager = context.window_manager + WindowManager.fileselect_add(self) + return {"RUNNING_MODAL"} + + def execute(self, context): + import ifcfm + + props = context.scene.BIMFMProperties + ifc_file = tool.Ifc.get() + if not (ifc_file and props.should_load_from_memory): + ifc_file = ifcopenshell.open(props.ifc_file) + + parser = ifcfm.Parser(preset=props.engine) + parser.parse(ifc_file) + writer = ifcfm.Writer(parser) + writer.write() + if props.format == "csv": + writer.write_csv('tmp/') + elif props.format == "ods": + writer.write_ods(self.filepath) + elif props.format == "xlsx": + writer.write_xlsx(self.filepath) + return {"FINISHED"} diff --git a/src/blenderbim/blenderbim/bim/module/fm/prop.py b/src/blenderbim/blenderbim/bim/module/fm/prop.py new file mode 100644 index 0000000000..120de1a747 --- /dev/null +++ b/src/blenderbim/blenderbim/bim/module/fm/prop.py @@ -0,0 +1,54 @@ +# BlenderBIM Add-on - OpenBIM Blender Add-on +# Copyright (C) 2023 Dion Moult +# +# This file is part of BlenderBIM Add-on. +# +# BlenderBIM Add-on 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. +# +# BlenderBIM Add-on 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 BlenderBIM Add-on. If not, see . + +import bpy +from bpy.types import PropertyGroup +from bpy.props import ( + PointerProperty, + StringProperty, + EnumProperty, + BoolProperty, + IntProperty, + FloatProperty, + FloatVectorProperty, + CollectionProperty, +) + + +class BIMFMProperties(PropertyGroup): + ifc_file: StringProperty(default="", name="IFC File") + should_load_from_memory: BoolProperty(default=False, name="Load from Memory") + engine: EnumProperty( + items=[ + ("aohbsem", "AOH-BSEM", ""), + ("basic", "Basic", ""), + ("cobie", "COBie 2.4", ""), + ("cobie3", "COBie 3", ""), + ], + name="Engine", + default="cobie", + ) + format: EnumProperty( + items=[ + ("csv", "csv", ""), + ("xlsx", "xlsx", ""), + ("ods", "ods", ""), + ], + name="Format", + default="ods", + ) diff --git a/src/blenderbim/blenderbim/bim/module/fm/ui.py b/src/blenderbim/blenderbim/bim/module/fm/ui.py new file mode 100644 index 0000000000..8ef8a4dd43 --- /dev/null +++ b/src/blenderbim/blenderbim/bim/module/fm/ui.py @@ -0,0 +1,54 @@ +# BlenderBIM Add-on - OpenBIM Blender Add-on +# Copyright (C) 2023 Dion Moult +# +# This file is part of BlenderBIM Add-on. +# +# BlenderBIM Add-on 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. +# +# BlenderBIM Add-on 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 BlenderBIM Add-on. If not, see . + +import blenderbim.tool as tool +from bpy.types import Panel +from blenderbim.bim.ifc import IfcStore + + +class BIM_PT_fm(Panel): + bl_label = "Facility Management" + bl_idname = "BIM_PT_fm" + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "scene" + bl_parent_id = "BIM_PT_tab_handover" + + def draw(self, context): + layout = self.layout + layout.use_property_split = True + + scene = context.scene + props = scene.BIMFMProperties + + if IfcStore.get_file(): + row = layout.row() + row.prop(props, "should_load_from_memory") + + if not IfcStore.get_file() or not props.should_load_from_memory: + row = layout.row(align=True) + row.prop(props, "ifc_file") + row.operator("bim.select_fm_ifc_file", icon="FILE_FOLDER", text="") + + row = layout.row() + row.prop(props, "engine") + row = layout.row() + row.prop(props, "format") + + row = layout.row() + op = row.operator("bim.execute_ifcfm", text="Convert To Spreadsheet") From 148d6e7b1be86b83b5290c83f72f7b3685f3f1b0 Mon Sep 17 00:00:00 2001 From: Thomas Krijnen Date: Wed, 13 Sep 2023 10:11:01 +0200 Subject: [PATCH 070/190] Update IDS.xsd --- src/ifctester/ifctester/ids.py | 2 +- src/ifctester/ifctester/ids.xsd | 6 +++--- src/ifctester/test/test_ids.py | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/ifctester/ifctester/ids.py b/src/ifctester/ifctester/ids.py index 38f0fc3f95..21bf6670b0 100644 --- a/src/ifctester/ifctester/ids.py +++ b/src/ifctester/ifctester/ids.py @@ -81,7 +81,7 @@ class Ids: "@xmlns": "http://standards.buildingsmart.org/IDS", "@xmlns:xs": "http://www.w3.org/2001/XMLSchema", "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", - "@xsi:schemaLocation": "http://standards.buildingsmart.org/IDS/ids_09.xsd", + "@xsi:schemaLocation": "http://standards.buildingsmart.org/IDS/0.9.6/ids.xsd", "info": self.info, "specifications": {"specification": []}, } diff --git a/src/ifctester/ifctester/ids.xsd b/src/ifctester/ifctester/ids.xsd index 5ac58daf18..0d2a595e72 100644 --- a/src/ifctester/ifctester/ids.xsd +++ b/src/ifctester/ifctester/ids.xsd @@ -51,9 +51,9 @@ - + - + @@ -212,7 +212,7 @@ - Author of the IDS can provide an identifier to the specification. This is intended to be a machine readable identifier. Beware: because of the possibility to combine different 'requirement' elements from several ids files this cannot be enforced/assumed as (global) unique. + Author of the IDS can provide an identifier to the specification. This is intended to be a machine readable identifier. Beware: because of the possibility to combine different 'specification' elements from several ids files this cannot be enforced/assumed as (global) unique. diff --git a/src/ifctester/test/test_ids.py b/src/ifctester/test/test_ids.py index bf392ed8af..98217c5a7a 100644 --- a/src/ifctester/test/test_ids.py +++ b/src/ifctester/test/test_ids.py @@ -52,7 +52,7 @@ class TestIds: "@xmlns": "http://standards.buildingsmart.org/IDS", "@xmlns:xs": "http://www.w3.org/2001/XMLSchema", "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", - "@xsi:schemaLocation": "http://standards.buildingsmart.org/IDS/ids_09.xsd", + "@xsi:schemaLocation": "http://standards.buildingsmart.org/IDS/0.9.6/ids.xsd", "info": {"title": "Untitled"}, "specifications": {"specification": []}, } @@ -72,7 +72,7 @@ class TestIds: "@xmlns": "http://standards.buildingsmart.org/IDS", "@xmlns:xs": "http://www.w3.org/2001/XMLSchema", "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", - "@xsi:schemaLocation": "http://standards.buildingsmart.org/IDS/ids_09.xsd", + "@xsi:schemaLocation": "http://standards.buildingsmart.org/IDS/0.9.6/ids.xsd", "info": { "title": "title", "copyright": "copyright", @@ -92,7 +92,7 @@ class TestIds: "@xmlns": "http://standards.buildingsmart.org/IDS", "@xmlns:xs": "http://www.w3.org/2001/XMLSchema", "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", - "@xsi:schemaLocation": "http://standards.buildingsmart.org/IDS/ids_09.xsd", + "@xsi:schemaLocation": "http://standards.buildingsmart.org/IDS/0.9.6/ids.xsd", "info": {"title": "Untitled"}, "specifications": {"specification": []}, } From f400973557be0c1e521396fc3a000e370b926599 Mon Sep 17 00:00:00 2001 From: Thomas Krijnen Date: Wed, 13 Sep 2023 12:47:33 +0200 Subject: [PATCH 071/190] Correct xsi:schemaLocation --- src/ifctester/ifctester/ids.py | 2 +- src/ifctester/test/test_ids.py | 7 ++++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/ifctester/ifctester/ids.py b/src/ifctester/ifctester/ids.py index 21bf6670b0..c3ebfed16c 100644 --- a/src/ifctester/ifctester/ids.py +++ b/src/ifctester/ifctester/ids.py @@ -81,7 +81,7 @@ class Ids: "@xmlns": "http://standards.buildingsmart.org/IDS", "@xmlns:xs": "http://www.w3.org/2001/XMLSchema", "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", - "@xsi:schemaLocation": "http://standards.buildingsmart.org/IDS/0.9.6/ids.xsd", + "@xsi:schemaLocation": "http://standards.buildingsmart.org/IDS http://standards.buildingsmart.org/IDS/0.9.6/ids.xsd", "info": self.info, "specifications": {"specification": []}, } diff --git a/src/ifctester/test/test_ids.py b/src/ifctester/test/test_ids.py index 98217c5a7a..10f2cb2beb 100644 --- a/src/ifctester/test/test_ids.py +++ b/src/ifctester/test/test_ids.py @@ -48,11 +48,12 @@ class TestIds: def test_create_an_ids_with_minimal_information(self): specs = ids.Ids() + print('AAA', specs.asdict()) assert specs.asdict() == { "@xmlns": "http://standards.buildingsmart.org/IDS", "@xmlns:xs": "http://www.w3.org/2001/XMLSchema", "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", - "@xsi:schemaLocation": "http://standards.buildingsmart.org/IDS/0.9.6/ids.xsd", + "@xsi:schemaLocation": "http://standards.buildingsmart.org/IDS http://standards.buildingsmart.org/IDS/0.9.6/ids.xsd", "info": {"title": "Untitled"}, "specifications": {"specification": []}, } @@ -72,7 +73,7 @@ class TestIds: "@xmlns": "http://standards.buildingsmart.org/IDS", "@xmlns:xs": "http://www.w3.org/2001/XMLSchema", "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", - "@xsi:schemaLocation": "http://standards.buildingsmart.org/IDS/0.9.6/ids.xsd", + "@xsi:schemaLocation": "http://standards.buildingsmart.org/IDS http://standards.buildingsmart.org/IDS/0.9.6/ids.xsd", "info": { "title": "title", "copyright": "copyright", @@ -92,7 +93,7 @@ class TestIds: "@xmlns": "http://standards.buildingsmart.org/IDS", "@xmlns:xs": "http://www.w3.org/2001/XMLSchema", "@xmlns:xsi": "http://www.w3.org/2001/XMLSchema-instance", - "@xsi:schemaLocation": "http://standards.buildingsmart.org/IDS/0.9.6/ids.xsd", + "@xsi:schemaLocation": "http://standards.buildingsmart.org/IDS http://standards.buildingsmart.org/IDS/0.9.6/ids.xsd", "info": {"title": "Untitled"}, "specifications": {"specification": []}, } From d5c46514d3b4895f90acdbba80c35beea4017411 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 13 Sep 2023 09:36:10 +0500 Subject: [PATCH 072/190] bends - considering parametric data searching for compatible fitting --- src/blenderbim/blenderbim/bim/module/model/mep.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/model/mep.py b/src/blenderbim/blenderbim/bim/module/model/mep.py index 75c74aa89d..45a3354871 100644 --- a/src/blenderbim/blenderbim/bim/module/model/mep.py +++ b/src/blenderbim/blenderbim/bim/module/model/mep.py @@ -366,6 +366,7 @@ class MEPGenerator: ifc_file = tool.Ifc.get() si_conversion = ifcopenshell.util.unit.calculate_unit_scale(ifc_file) precision = VTX_PRECISION / si_conversion + angle_precision = degrees(precision) segments_data = [] for segment, port in zip(segments, ports, strict=True): @@ -388,8 +389,9 @@ class MEPGenerator: for key in bbim_data: requested_value = bbim_data[key] fitting_value = fitting_bbim_data[key] + if isinstance(requested_value, float): - compare_precision = None if key == "angle" else precision + 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) @@ -1112,9 +1114,15 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator): bpy.ops.bim.create_shape_from_step_id(step_id=rep.id(), should_include_curves=True) + parametric_data = { + "start_length": self.start_length / si_conversion, + "end_length": self.end_length / si_conversion, + "radius": self.radius / si_conversion, + "angle": degrees(angle), + } # find the compatible fitting type fitting_data = MEPGenerator().get_compatible_fitting_type( - [start_element, end_element], [start_port, end_port], "BEND" + [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 From 58199ef7c9239c5f796549c5a55f256d3ffb738c Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 13 Sep 2023 11:16:50 +0500 Subject: [PATCH 073/190] Replace currently selected objects with occurences of type Added operator to replace currently selected objects with occurences of the chosen type. I kept doing this from the code and thought it could be generally useful. It's working pretty naive now and just replacing objects with occurences preserving the original transform (location/rotation/scale), nothing else is preserved. Demo - https://imgur.com/a/qm8a6Ns PS Hopefully I'm not reinventing the wheel and not missed some other similar operator --- .../blenderbim/bim/module/model/__init__.py | 1 + .../blenderbim/bim/module/model/product.py | 43 +++++++++++++++++++ .../blenderbim/bim/module/model/workspace.py | 2 + 3 files changed, 46 insertions(+) diff --git a/src/blenderbim/blenderbim/bim/module/model/__init__.py b/src/blenderbim/blenderbim/bim/module/model/__init__.py index bdb5f5fe01..50acd23d39 100644 --- a/src/blenderbim/blenderbim/bim/module/model/__init__.py +++ b/src/blenderbim/blenderbim/bim/module/model/__init__.py @@ -63,6 +63,7 @@ classes = ( product.EnableAddType, product.LoadTypeThumbnails, product.MirrorElements, + product.ReplaceSelectedWithTypeOccurences, workspace.Hotkey, wall.AlignWall, wall.ChangeExtrusionDepth, diff --git a/src/blenderbim/blenderbim/bim/module/model/product.py b/src/blenderbim/blenderbim/bim/module/model/product.py index 0d56fc63e4..6f74ca1733 100644 --- a/src/blenderbim/blenderbim/bim/module/model/product.py +++ b/src/blenderbim/blenderbim/bim/module/model/product.py @@ -283,6 +283,49 @@ class AddConstrTypeInstance(bpy.types.Operator): return True +class ReplaceSelectedWithTypeOccurences(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.replace_selected_with_type_occurences" + bl_label = "Replace Selected With Type Occurences" + bl_options = {"REGISTER", "UNDO"} + bl_description = ( + "\nReplace selected objects with type either chosen in BIM tools or provided in the argument.\n\n" + "NOTE: Currently it's just replacing original object by occurence with the same transform, \n" + "no other data from original object is preserved." + ) + relating_type_id: bpy.props.IntProperty(default=0) + + @classmethod + def poll(cls, context): + if not context.selected_objects: + cls.poll_message_set("No objects selected") + return False + return True + + def _execute(self, context): + props = context.scene.BIMModelProperties + relating_type_id = self.relating_type_id or int(props.relating_type_id) + + if not relating_type_id: + self.report({"ERROR"}, "No type provided.") + return {"FINISHED"} + + originally_active_object = context.active_object + new_active_object = None + objs = bpy.context.selected_objects[:] + new_objects = [] + for replaced_obj in objs: + bpy.ops.bim.add_constr_type_instance(relating_type_id=relating_type_id) + new_objects.append(obj := bpy.context.active_object) + + obj.matrix_world = replaced_obj.matrix_world + if replaced_obj == originally_active_object: + new_active_object = obj + + bpy.data.objects.remove(replaced_obj) + tool.Blender.set_objects_selection(context, new_active_object, new_objects) + return {"FINISHED"} + + class ChangeTypePage(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.change_type_page" bl_label = "Change Type Page" diff --git a/src/blenderbim/blenderbim/bim/module/model/workspace.py b/src/blenderbim/blenderbim/bim/module/model/workspace.py index 282f311d3c..a85a48956f 100644 --- a/src/blenderbim/blenderbim/bim/module/model/workspace.py +++ b/src/blenderbim/blenderbim/bim/module/model/workspace.py @@ -461,6 +461,7 @@ class BimToolUI: else: row.label(text="No Construction Type", icon="FILE_3D") row.operator("bim.launch_type_manager", icon="LIGHTPROBE_GRID", text="") + row.operator("bim.replace_selected_with_type_occurences", icon="ARROW_LEFTRIGHT", text="") else: if AuthoringData.data["ifc_element_type"]: row.label(text=f"No {AuthoringData.data['ifc_element_type']} Found", icon="ERROR") @@ -474,6 +475,7 @@ class BimToolUI: else: row.label(text="No Element Types Found", icon="ERROR") row.operator("bim.launch_type_manager", icon="LIGHTPROBE_GRID", text="Launch Type Manager") + row.operator("bim.replace_selected_with_type_occurences", icon="ARROW_LEFTRIGHT", text="") @classmethod def draw_basic_bim_tool_interface(cls): From 7ec8f8b03e4bb5ebfc92f368221a6675cae1787a Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 13 Sep 2023 16:16:56 +0500 Subject: [PATCH 074/190] bends - adjusting compatible fitting rotation to match the segments Demo - https://imgur.com/a/cYdWy9X (note that no bend types created in the process - it's just adjusting the type created for the first two segments). --- .../blenderbim/bim/module/model/mep.py | 33 +++++++++++++++++-- .../ifcopenshell/util/shape_builder.py | 10 +++++- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/model/mep.py b/src/blenderbim/blenderbim/bim/module/model/mep.py index 45a3354871..540cbf0e43 100644 --- a/src/blenderbim/blenderbim/bim/module/model/mep.py +++ b/src/blenderbim/blenderbim/bim/module/model/mep.py @@ -1035,7 +1035,9 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator): return {"CANCELLED"} O = V(0, 0, 0) - angle = tool.Cad.angle_edges((get_z_basis(start_object), O), (get_z_basis(end_object), O)) + angle = pi - tool.Cad.angle_edges( + (get_z_basis(start_object) * start_segment_sign, O), (get_z_basis(end_object) * end_segment_sign, O) + ) lateral_sign = tool.Cad.sign(profile_offset[lateral_axis]) radial_offset = V(0, 0, 0) @@ -1126,7 +1128,29 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator): ) bend_type = fitting_data["fitting_type"] if fitting_data else None start_port_match = fitting_data["start_port_match"] if fitting_data else True + + rotate_lateral_axis = None + flip_z_axis_type = None 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"] + flip_z_axis_type = bbim_data["flip_z_axis"] + + # if the lateral axis of the compatible fitting type doesn't match + # with the current lateral bend lateral axis, we'll adjust the rotation + if (lateral_axis_type != lateral_axis) or (lateral_sign_type != lateral_sign): + m = Matrix.Identity(3) + current_lateral_axis = m[lateral_axis] * lateral_sign + type_lateral_axis = m[lateral_axis_type] * lateral_sign_type + if lateral_axis_type == lateral_axis: + rotation_axis = m[2] # just V(0,0,1) + else: + rotation_axis = type_lateral_axis.cross(current_lateral_axis) + rotation_angle = current_lateral_axis.angle(type_lateral_axis) + # rotation axis is always Z but need to consider the rotation direction + rotate_lateral_axis = Matrix.Rotation(rotation_angle, 4, rotation_axis) + # TODO: handle the case without creating a representation in the first place? ifcopenshell.api.run("geometry.remove_representation", ifc_file, representation=rep) else: # create new fitting type if nothing is compatible @@ -1165,10 +1189,13 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator): # direction can be different depending on: # - order of the current segments # - order of the segments that were used with the same fitting type before - direction_match = tool.Cad.are_vectors_equal(get_z_basis(start_object), get_z_basis(fitting_obj)) + + direction_match = True if flip_z_axis_type is None else (flip_z_axis_type == (start_segment_sign == -1)) # if there are no mismatches or everything matches up we don't need to flip the transition if start_port_match != direction_match: - fitting_obj.matrix_world = start_object.matrix_world @ Matrix.Rotation(radians(180), 4, "X") + fitting_obj.matrix_world = start_object.matrix_world @ Matrix.Rotation(radians(180), 4, "XY"[lateral_axis]) + if rotate_lateral_axis: + fitting_obj.matrix_world = fitting_obj.matrix_world @ rotate_lateral_axis fitting_obj.location = start_segment_extend_point if start_port_match else end_segment_extend_point # add ports and connect them diff --git a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py index d106a0a29d..def232d795 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py +++ b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py @@ -1446,5 +1446,13 @@ class ShapeBuilder: body = ifcopenshell.util.representation.get_context(self.file, "Model", "Body", "MODEL_VIEW") rep = self.get_representation(body, rep_items) - bend_data = {"start_length": start_length, "end_length": end_length, "radius": radius, "angle": degrees(theta)} + bend_data = { + "start_length": start_length, + "end_length": end_length, + "radius": radius, + "angle": degrees(theta), + "lateral_axis": lateral_axis, + "lateral_sign": lateral_sign, + "flip_z_axis": flip_z_axis, + } return rep, bend_data From d79b912e0ed8679aec72c3a470446a658a5e6c34 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 13 Sep 2023 16:30:01 +0500 Subject: [PATCH 075/190] bends - consider profile dimensions searching for compatible fitting So basically now it won't reuse a bend fitting created for the same profile but with different dimension. --- src/blenderbim/blenderbim/bim/module/model/mep.py | 1 + src/ifcopenshell-python/ifcopenshell/util/shape_builder.py | 1 + 2 files changed, 2 insertions(+) diff --git a/src/blenderbim/blenderbim/bim/module/model/mep.py b/src/blenderbim/blenderbim/bim/module/model/mep.py index 540cbf0e43..7eceb0d574 100644 --- a/src/blenderbim/blenderbim/bim/module/model/mep.py +++ b/src/blenderbim/blenderbim/bim/module/model/mep.py @@ -1121,6 +1121,7 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator): "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( diff --git a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py index def232d795..ccedb3f107 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py +++ b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py @@ -1454,5 +1454,6 @@ class ShapeBuilder: "lateral_axis": lateral_axis, "lateral_sign": lateral_sign, "flip_z_axis": flip_z_axis, + "main_profile_dimension": profile_dim[lateral_axis], } return rep, bend_data From 97683a14efdadb073bdd738c30e67cec015cc570 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 13 Sep 2023 18:44:00 +0500 Subject: [PATCH 076/190] Revert "Replace currently selected objects with occurences of type" This reverts commit 58199ef7c9239c5f796549c5a55f256d3ffb738c. --- .../blenderbim/bim/module/model/__init__.py | 1 - .../blenderbim/bim/module/model/product.py | 43 ------------------- .../blenderbim/bim/module/model/workspace.py | 2 - 3 files changed, 46 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/model/__init__.py b/src/blenderbim/blenderbim/bim/module/model/__init__.py index 50acd23d39..bdb5f5fe01 100644 --- a/src/blenderbim/blenderbim/bim/module/model/__init__.py +++ b/src/blenderbim/blenderbim/bim/module/model/__init__.py @@ -63,7 +63,6 @@ classes = ( product.EnableAddType, product.LoadTypeThumbnails, product.MirrorElements, - product.ReplaceSelectedWithTypeOccurences, workspace.Hotkey, wall.AlignWall, wall.ChangeExtrusionDepth, diff --git a/src/blenderbim/blenderbim/bim/module/model/product.py b/src/blenderbim/blenderbim/bim/module/model/product.py index 6f74ca1733..0d56fc63e4 100644 --- a/src/blenderbim/blenderbim/bim/module/model/product.py +++ b/src/blenderbim/blenderbim/bim/module/model/product.py @@ -283,49 +283,6 @@ class AddConstrTypeInstance(bpy.types.Operator): return True -class ReplaceSelectedWithTypeOccurences(bpy.types.Operator, tool.Ifc.Operator): - bl_idname = "bim.replace_selected_with_type_occurences" - bl_label = "Replace Selected With Type Occurences" - bl_options = {"REGISTER", "UNDO"} - bl_description = ( - "\nReplace selected objects with type either chosen in BIM tools or provided in the argument.\n\n" - "NOTE: Currently it's just replacing original object by occurence with the same transform, \n" - "no other data from original object is preserved." - ) - relating_type_id: bpy.props.IntProperty(default=0) - - @classmethod - def poll(cls, context): - if not context.selected_objects: - cls.poll_message_set("No objects selected") - return False - return True - - def _execute(self, context): - props = context.scene.BIMModelProperties - relating_type_id = self.relating_type_id or int(props.relating_type_id) - - if not relating_type_id: - self.report({"ERROR"}, "No type provided.") - return {"FINISHED"} - - originally_active_object = context.active_object - new_active_object = None - objs = bpy.context.selected_objects[:] - new_objects = [] - for replaced_obj in objs: - bpy.ops.bim.add_constr_type_instance(relating_type_id=relating_type_id) - new_objects.append(obj := bpy.context.active_object) - - obj.matrix_world = replaced_obj.matrix_world - if replaced_obj == originally_active_object: - new_active_object = obj - - bpy.data.objects.remove(replaced_obj) - tool.Blender.set_objects_selection(context, new_active_object, new_objects) - return {"FINISHED"} - - class ChangeTypePage(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.change_type_page" bl_label = "Change Type Page" diff --git a/src/blenderbim/blenderbim/bim/module/model/workspace.py b/src/blenderbim/blenderbim/bim/module/model/workspace.py index a85a48956f..282f311d3c 100644 --- a/src/blenderbim/blenderbim/bim/module/model/workspace.py +++ b/src/blenderbim/blenderbim/bim/module/model/workspace.py @@ -461,7 +461,6 @@ class BimToolUI: else: row.label(text="No Construction Type", icon="FILE_3D") row.operator("bim.launch_type_manager", icon="LIGHTPROBE_GRID", text="") - row.operator("bim.replace_selected_with_type_occurences", icon="ARROW_LEFTRIGHT", text="") else: if AuthoringData.data["ifc_element_type"]: row.label(text=f"No {AuthoringData.data['ifc_element_type']} Found", icon="ERROR") @@ -475,7 +474,6 @@ class BimToolUI: else: row.label(text="No Element Types Found", icon="ERROR") row.operator("bim.launch_type_manager", icon="LIGHTPROBE_GRID", text="Launch Type Manager") - row.operator("bim.replace_selected_with_type_occurences", icon="ARROW_LEFTRIGHT", text="") @classmethod def draw_basic_bim_tool_interface(cls): From ea3d489c24ad9e66e51e0005cd7d3e69e65b15a1 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 14 Sep 2023 12:10:25 +1000 Subject: [PATCH 077/190] Minor fixes to IfcFM --- src/ifcfm/ifcfm/__init__.py | 5 +++++ src/ifcfm/ifcfm/basic.py | 2 +- src/ifcfm/ifcfm/cobie.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/ifcfm/ifcfm/__init__.py b/src/ifcfm/ifcfm/__init__.py index e7a1960bc4..abe8f28e3f 100644 --- a/src/ifcfm/ifcfm/__init__.py +++ b/src/ifcfm/ifcfm/__init__.py @@ -50,6 +50,9 @@ class Parser: module = importlib.import_module(f"ifcfm.{preset}") self.get_category_elements = getattr(module, "get_category_elements") self.get_element_data = getattr(module, "get_element_data") + elif isinstance(preset, dict): + self.get_category_elements = preset["get_category_elements"] + self.get_element_data = preset["get_element_data"] else: self.get_category_elements = getattr(preset, "get_category_elements") self.get_element_data = getattr(preset, "get_element_data") @@ -91,6 +94,8 @@ class Writer: if isinstance(self.parser.preset, str): module = importlib.import_module(f"ifcfm.{self.parser.preset}") self.config = getattr(module, "config") + elif isinstance(self.parser.preset, dict): + self.config = self.parser.preset["config"] else: self.config = getattr(self.parser.preset, "config") diff --git a/src/ifcfm/ifcfm/basic.py b/src/ifcfm/ifcfm/basic.py index 167ef4c5e2..222295be81 100644 --- a/src/ifcfm/ifcfm/basic.py +++ b/src/ifcfm/ifcfm/basic.py @@ -134,7 +134,7 @@ def get_space_data(ifc_file, element): def get_zone_data(ifc_file, element): zone, space = element return { - "key": (element.Name or "Unnamed") + (space.Name or "Unnamed"), + "key": (zone.Name or "Unnamed") + (space.Name or "Unnamed"), "Name": zone.Name, "SpaceName": space.Name, "AuthorOrganizationName": get_owner_name(zone), diff --git a/src/ifcfm/ifcfm/cobie.py b/src/ifcfm/ifcfm/cobie.py index 3b4b5d47df..9eb6eb0d7c 100644 --- a/src/ifcfm/ifcfm/cobie.py +++ b/src/ifcfm/ifcfm/cobie.py @@ -89,7 +89,7 @@ def get_types(ifc_file): def get_components(ifc_file): elements = set() - for element_type in ifcopenshell.util.fm.get_cobie_types(ifc_file): + for element_type in get_types(ifc_file): elements.update(ifcopenshell.util.element.get_types(element_type)) return elements From c22d7d2f417a3fd1b4bbadd9e031a82d967e60f9 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 14 Sep 2023 14:24:06 +1000 Subject: [PATCH 078/190] More minor fixes to IfcFM basic preset --- src/ifcfm/ifcfm/__init__.py | 1 - src/ifcfm/ifcfm/basic.py | 25 ++++++++++++++----------- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/src/ifcfm/ifcfm/__init__.py b/src/ifcfm/ifcfm/__init__.py index abe8f28e3f..8eaefb279b 100644 --- a/src/ifcfm/ifcfm/__init__.py +++ b/src/ifcfm/ifcfm/__init__.py @@ -99,7 +99,6 @@ class Writer: else: self.config = getattr(self.parser.preset, "config") - def write(self, null="N/A", empty="-", bool_true="YES", bool_false="NO"): self.categories = {} null = self.config.get("null", null) diff --git a/src/ifcfm/ifcfm/basic.py b/src/ifcfm/ifcfm/basic.py index 222295be81..64d0dc582e 100644 --- a/src/ifcfm/ifcfm/basic.py +++ b/src/ifcfm/ifcfm/basic.py @@ -48,7 +48,7 @@ def get_zones(ifc_file): return zones -def get_types(ifc_file): +def get_element_types(ifc_file): return ifcopenshell.util.fm.get_fmhem_types(ifc_file) @@ -144,7 +144,7 @@ def get_zone_data(ifc_file, element): } -def get_type_data(ifc_file, element): +def get_element_type_data(ifc_file, element): return { "key": element.Name, "Name": element.Name, @@ -154,8 +154,8 @@ def get_type_data(ifc_file, element): "AuthorDate": get_owner_creation_date(element), "ModelSoftware": get_owner_application(element), "ModelObject": "{}[{}]".format(element.is_a(), ifcopenshell.util.element.get_predefined_type(element)), - "ModelTag": element.Tag, "ModelID": element.GlobalId, + "ModelTag": element.Tag, } @@ -175,6 +175,7 @@ def get_element_data(ifc_file, element): "ModelSoftware": get_owner_application(element), "ModelObject": "{}[{}]".format(element.is_a(), ifcopenshell.util.element.get_predefined_type(element)), "ModelID": element.GlobalId, + "ModelTag": element.Tag, } @@ -256,7 +257,7 @@ get_category_elements = { "Storeys": get_storeys, "Spaces": get_spaces, "Zones": get_zones, - "Types": get_types, + "ElementTypes": get_element_types, "Elements": get_elements, "Systems": get_systems, } @@ -267,7 +268,7 @@ get_element_data = { "Storeys": get_storey_data, "Spaces": get_space_data, "Zones": get_zone_data, - "Types": get_type_data, + "ElementTypes": get_element_type_data, "Elements": get_element_data, "Systems": get_system_data, } @@ -292,9 +293,9 @@ config = { "Phone", "CompanyURL", "Department", - "Address1", - "Address2", - "StateRegion", + "Address", + "Town", + "Region", "PostalCode", "Country", ], @@ -356,7 +357,7 @@ config = { "colours": "prreee", "sort": [{"name": "Name", "order": "ASC"}], }, - "Types": { + "ElementTypes": { "headers": [ "Name", "Description", @@ -365,8 +366,8 @@ config = { "AuthorDate", "ModelSoftware", "ModelObject", - "ModelTag", "ModelID", + "ModelTag", ], "colours": "pppreeeee", "sort": [{"name": "ModelObject", "order": "ASC"}, {"name": "Name", "order": "ASC"}], @@ -382,6 +383,7 @@ config = { "ModelSoftware", "ModelObject", "ModelID", + "ModelTag", ], "colours": "prrrreeee", "sort": [{"name": "TypeName", "order": "ASC"}, {"name": "Name", "order": "ASC"}], @@ -395,8 +397,9 @@ config = { "AuthorDate", "ModelSoftware", "ModelID", + "ModelTag", ], - "colours": "pppreee", + "colours": "pppreeee", "sort": [{"name": "Name", "order": "ASC"}], }, }, From 2b067b9dec2c91352139ba24491e7432d9194004 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 14 Sep 2023 14:27:07 +1000 Subject: [PATCH 079/190] Minor fix --- src/ifcfm/ifcfm/basic.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/ifcfm/ifcfm/basic.py b/src/ifcfm/ifcfm/basic.py index 64d0dc582e..bf41a0ed20 100644 --- a/src/ifcfm/ifcfm/basic.py +++ b/src/ifcfm/ifcfm/basic.py @@ -385,7 +385,7 @@ config = { "ModelID", "ModelTag", ], - "colours": "prrrreeee", + "colours": "prrrreeeee", "sort": [{"name": "TypeName", "order": "ASC"}, {"name": "Name", "order": "ASC"}], }, "Systems": { @@ -397,9 +397,8 @@ config = { "AuthorDate", "ModelSoftware", "ModelID", - "ModelTag", ], - "colours": "pppreeee", + "colours": "pppreee", "sort": [{"name": "Name", "order": "ASC"}], }, }, From 66e07c2f69de61a42a3ad2e05e6883ca5742fcdc Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 14 Sep 2023 15:17:52 +1000 Subject: [PATCH 080/190] Include buildingSMART psets in IfcFM Basic preset --- src/ifcfm/ifcfm/basic.py | 89 +++++++++++++--------------------------- 1 file changed, 29 insertions(+), 60 deletions(-) diff --git a/src/ifcfm/ifcfm/basic.py b/src/ifcfm/ifcfm/basic.py index bf41a0ed20..5e3d81d3dd 100644 --- a/src/ifcfm/ifcfm/basic.py +++ b/src/ifcfm/ifcfm/basic.py @@ -24,10 +24,6 @@ import ifcopenshell.util.placement import ifcopenshell.util.classification -def get_actors(ifc_file): - return ifc_file.by_type("IfcActor") - - def get_facilities(ifc_file): return ifc_file.by_type("IfcBuilding") @@ -63,23 +59,6 @@ def get_systems(ifc_file): return ifc_file.by_type("IfcSystem") -def get_actor_data(ifc_file, element): - return { - "key": element.TheActor.Name, - "Name": element.TheActor.Name, - "Category": get_classification(element), - "Email": get_actor_address(element, "ElectronicMailAddresses"), - "Phone": get_actor_address(element, "TelephoneNumbers"), - "CompanyURL": get_actor_address(element, "WWWHomePageURL"), - "Department": get_actor_address(element, "InternalLocation"), - "Address": get_actor_address(element, "AddressLines"), - "Town": get_actor_address(element, "Town"), - "Region": get_actor_address(element, "Region"), - "PostalCode": get_actor_address(element, "PostalCode"), - "Country": get_actor_address(element, "Country"), - } - - def get_facility_data(ifc_file, element): return { "key": element.Name, @@ -95,7 +74,6 @@ def get_facility_data(ifc_file, element): "ModelBuildingID": element.GlobalId, "LinearUnits": "millimeters", "AreaUnits": "square meters", - "AreaMeasurement": "BIM Software", "Phase": ifc_file.by_type("IfcProject")[0].Phase, } @@ -145,6 +123,7 @@ def get_zone_data(ifc_file, element): def get_element_type_data(ifc_file, element): + psets = ifcopenshell.util.element.get_psets(element) return { "key": element.Name, "Name": element.Name, @@ -156,6 +135,11 @@ def get_element_type_data(ifc_file, element): "ModelObject": "{}[{}]".format(element.is_a(), ifcopenshell.util.element.get_predefined_type(element)), "ModelID": element.GlobalId, "ModelTag": element.Tag, + "Manufacturer": get_property(psets, "Pset_ManufacturerTypeInformation", "Manufacturer"), + "ModelReference": get_property(psets, "Pset_ManufacturerTypeInformation", "ModelReference"), + "ModelLabel": get_property(psets, "Pset_ManufacturerTypeInformation", "ModelLabel"), + "PointOfContact": get_property(psets, "Pset_Warranty", "PointOfContact"), + "WarrantyPeriod": get_property(psets, "Pset_Warranty", "WarrantyPeriod"), } @@ -164,6 +148,7 @@ def get_element_data(ifc_file, element): space_name = space.Name if space.is_a("IfcSpace") else None systems = ifcopenshell.util.system.get_element_systems(element) system = systems[0].Name if systems else None + psets = ifcopenshell.util.element.get_psets(element) return { "key": element.Name, "Name": element.Name, @@ -176,6 +161,13 @@ def get_element_data(ifc_file, element): "ModelObject": "{}[{}]".format(element.is_a(), ifcopenshell.util.element.get_predefined_type(element)), "ModelID": element.GlobalId, "ModelTag": element.Tag, + "SerialNumber": get_property(psets, "Pset_ManufacturerOccurrence", "SerialNumber"), + "BarCode": get_property(psets, "Pset_ManufacturerOccurrence", "BarCode"), + "BatchReference": get_property(psets, "Pset_ManufacturerOccurrence", "BatchReference"), + "TagNumber": get_property(psets, "Pset_ConstructionOccurrence", "TagNumber"), + "AssetIdentifier": get_property(psets, "Pset_ConstructionOccurrence", "AssetIdentifier"), + "InstallationDate": get_property(psets, "Pset_ConstructionOccurrence", "InstallationDate"), + "WarrantyStartDate": get_property(psets, "Pset_Warranty", "WarrantyStartDate"), } @@ -228,21 +220,6 @@ def get_classification(element): return "{}:{}".format(references[0].ItemReference, references[0].Name) -def get_actor_address(element, name): - actors = [] - if element.TheActor.is_a("IfcOrganization") or element.TheActor.is_a("IfcPerson"): - actors = [element.TheActor] - elif element.TheActor.is_a("IfcPersonAndOrganization"): - actors = [element.TheActor.TheOrganization, element.TheActor.ThePerson] - for actor in actors: - for address in actor.Addresses or []: - if hasattr(address, name) and getattr(address, name, None): - result = getattr(address, name) - if isinstance(result, tuple): - return result[0] - return result - - def get_property(psets, pset_name, prop_name, decimals=None): if pset_name in psets: result = psets[pset_name].get(prop_name, None) @@ -252,7 +229,6 @@ def get_property(psets, pset_name, prop_name, decimals=None): get_category_elements = { - "Actors": get_actors, "Facilities": get_facilities, "Storeys": get_storeys, "Spaces": get_spaces, @@ -263,7 +239,6 @@ get_category_elements = { } get_element_data = { - "Actors": get_actor_data, "Facilities": get_facility_data, "Storeys": get_storey_data, "Spaces": get_space_data, @@ -285,23 +260,6 @@ config = { "b": "000000", # Not in scope }, "categories": { - "Actors": { - "headers": [ - "Name", - "Category", - "Email", - "Phone", - "CompanyURL", - "Department", - "Address", - "Town", - "Region", - "PostalCode", - "Country", - ], - "colours": "ppssssssss", - "sort": [{"name": "Name", "order": "ASC"}], - }, "Facilities": { "headers": [ "Name", @@ -316,10 +274,9 @@ config = { "ModelBuildingID", "LinearUnits", "AreaUnits", - "AreaMeasurement", "Phase", ], - "colours": "ppppreeeeessss", + "colours": "ppppreeeeesss", "sort": [{"name": "Name", "order": "ASC"}], }, "Storeys": { @@ -368,8 +325,13 @@ config = { "ModelObject", "ModelID", "ModelTag", + "Manufacturer", + "ModelReference", + "ModelLabel", + "PointOfContact", + "WarrantyPeriod", ], - "colours": "pppreeeee", + "colours": "pppreeeeesssss", "sort": [{"name": "ModelObject", "order": "ASC"}, {"name": "Name", "order": "ASC"}], }, "Elements": { @@ -384,8 +346,15 @@ config = { "ModelObject", "ModelID", "ModelTag", + "SerialNumber", + "BarCode", + "BatchReference", + "TagNumber", + "AssetIdentifier", + "InstallationDate", + "WarrantyStartDate", ], - "colours": "prrrreeeee", + "colours": "prrrreeeeesssssss", "sort": [{"name": "TypeName", "order": "ASC"}, {"name": "Name", "order": "ASC"}], }, "Systems": { From 42e683375cc287e5ed27e331935a251090f73a7f Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Thu, 14 Sep 2023 11:06:21 +0500 Subject: [PATCH 081/190] Fixed bug using tab editing arbitrary profile It was assuming we were editing extrusion profile and therefore was giving errors. --- src/blenderbim/blenderbim/bim/module/geometry/operator.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/blenderbim/blenderbim/bim/module/geometry/operator.py b/src/blenderbim/blenderbim/bim/module/geometry/operator.py index 60b671b5e3..b8ed85a5d8 100644 --- a/src/blenderbim/blenderbim/bim/module/geometry/operator.py +++ b/src/blenderbim/blenderbim/bim/module/geometry/operator.py @@ -1549,6 +1549,9 @@ class OverrideModeSetObject(bpy.types.Operator): bpy.ops.bim.finish_editing_roof_path() elif tool.Model.get_usage_type(element) == "PROFILE": bpy.ops.bim.edit_extrusion_axis() + # if in the process of editing arbitrary profile + elif context.scene.BIMProfileProperties.active_arbitrary_profile_id: + bpy.ops.bim.edit_arbitrary_profile() else: bpy.ops.bim.edit_extrusion_profile() return self.execute(context) From dca60b83a944e74dbf33dec5fe76acc6142cbabe Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 14 Sep 2023 22:27:46 +1000 Subject: [PATCH 082/190] Make IfcDiff more forgiving when invalid geometry occurs. See #3755. --- src/ifcdiff/ifcdiff.py | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/src/ifcdiff/ifcdiff.py b/src/ifcdiff/ifcdiff.py index 4c61782a0f..32286882d0 100755 --- a/src/ifcdiff/ifcdiff.py +++ b/src/ifcdiff/ifcdiff.py @@ -190,19 +190,20 @@ class IfcDiff: shape = iterator.get() element = ifc.by_id(shape.id) geometry = shape.geometry - shapes[element.GlobalId] = { - "total_verts": len(geometry.verts), - "sum_verts": sum(geometry.verts), - "min_vert": min(geometry.verts), - "max_vert": max(geometry.verts), - "matrix": tuple(shape.transformation.matrix.data), - "openings": sorted( - [o.RelatedOpeningElement.GlobalId for o in getattr(element, "HasOpenings", []) or []] - ), - "projections": sorted( - [o.RelatedFeatureElement.GlobalId for o in getattr(element, "HasProjections", []) or []] - ), - } + if geometry.verts: + shapes[element.GlobalId] = { + "total_verts": len(geometry.verts), + "sum_verts": sum(geometry.verts), + "min_vert": min(geometry.verts), + "max_vert": max(geometry.verts), + "matrix": tuple(shape.transformation.matrix.data), + "openings": sorted( + [o.RelatedOpeningElement.GlobalId for o in getattr(element, "HasOpenings", []) or []] + ), + "projections": sorted( + [o.RelatedFeatureElement.GlobalId for o in getattr(element, "HasProjections", []) or []] + ), + } if not iterator.next(): break return shapes From 0140b4ded1a093142a1b07fc687c49cbac9020fd Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 14 Sep 2023 22:50:57 +1000 Subject: [PATCH 083/190] IfcDiff can now visualise across linked IFCs --- .../blenderbim/bim/module/clash/operator.py | 11 +++++++-- .../blenderbim/bim/module/diff/operator.py | 24 +++++++++++++++++-- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/clash/operator.py b/src/blenderbim/blenderbim/bim/module/clash/operator.py index 933cc06b48..6013caf2b4 100644 --- a/src/blenderbim/blenderbim/bim/module/clash/operator.py +++ b/src/blenderbim/blenderbim/bim/module/clash/operator.py @@ -336,8 +336,15 @@ class SelectIfcClashResults(bpy.types.Operator): else: element_file = self.file - element = element_file.by_id(obj.BIMObjectProperties.ifc_definition_id) - if element.GlobalId in global_ids: + try: + element = element_file.by_id(obj.BIMObjectProperties.ifc_definition_id) + except: + continue + + global_id = getattr(element, "GlobalId", None) + if not global_id: + continue + if global_id in global_ids: obj.select_set(True) return {"FINISHED"} diff --git a/src/blenderbim/blenderbim/bim/module/diff/operator.py b/src/blenderbim/blenderbim/bim/module/diff/operator.py index 350f28867f..b11aa9c288 100644 --- a/src/blenderbim/blenderbim/bim/module/diff/operator.py +++ b/src/blenderbim/blenderbim/bim/module/diff/operator.py @@ -22,6 +22,7 @@ import ifccsv import ifcopenshell import blenderbim.bim.handler import blenderbim.tool as tool +from blenderbim.bim.ifc import IfcStore class SelectDiffJsonFile(bpy.types.Operator): @@ -51,8 +52,27 @@ class VisualiseDiff(bpy.types.Operator): diff = json.load(file) for obj in context.visible_objects: obj.color = (1.0, 1.0, 1.0, 1.0) - element = tool.Ifc.get_entity(obj) - if not element: + + if not obj.BIMObjectProperties.ifc_definition_id: + continue + + ifc_file = "" + for scene in obj.users_scene: + if scene.BIMProperties.ifc_file: + ifc_file = scene.BIMProperties.ifc_file + if scene.library: + break + + if ifc_file: + if ifc_file not in IfcStore.session_files: + IfcStore.session_files[ifc_file] = ifcopenshell.open(ifc_file) + element_file = IfcStore.session_files[ifc_file] + else: + element_file = ifc_file + + try: + element = element_file.by_id(obj.BIMObjectProperties.ifc_definition_id) + except: continue global_id = getattr(element, "GlobalId", None) if not global_id: From 9778253d21d886639310ed4367a91e4af2062f4d Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Thu, 14 Sep 2023 13:15:43 +0500 Subject: [PATCH 084/190] bends - support for zero radius bends --- .../ifcopenshell/util/shape_builder.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py index ccedb3f107..b85ab7b411 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py +++ b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py @@ -1354,7 +1354,6 @@ class ShapeBuilder: return V(profile.Radius, profile.Radius, depth) return None - # TODO: test with 0 radius si_conversion = ifcopenshell.util.unit.calculate_unit_scale(self.file) profile = get_profile(segment) is_circular_profile = profile.is_a("IfcCircleProfileDef") @@ -1397,10 +1396,14 @@ class ShapeBuilder: arc_points = (1,) else: outer_r = r + 2 * profile_dim[lateral_axis] - inner_points = [get_circle_point(cur_theta, r) for cur_theta in theta_segments] outer_points = [get_circle_point(cur_theta, outer_r) for cur_theta in theta_segments[::-1]] - points = inner_points + outer_points - arc_points = (1, 4) + if is_x(r, 0): + points = [get_circle_point(theta, r)] + outer_points + arc_points = (2,) + else: + inner_points = [get_circle_point(cur_theta, r) for cur_theta in theta_segments] + points = inner_points + outer_points + arc_points = (1, 4) points = [p + O for p in points] offset = V(0, 0, 0) From 63223172b48ecc6b3e16fe8e3e2f797ecd405edc Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Thu, 14 Sep 2023 13:16:03 +0500 Subject: [PATCH 085/190] mep operators - clamp properties below zero --- src/blenderbim/blenderbim/bim/module/model/mep.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/model/mep.py b/src/blenderbim/blenderbim/bim/module/model/mep.py index 7eceb0d574..108ccb45e0 100644 --- a/src/blenderbim/blenderbim/bim/module/model/mep.py +++ b/src/blenderbim/blenderbim/bim/module/model/mep.py @@ -626,13 +626,13 @@ class MEPAddTransition(bpy.types.Operator, tool.Ifc.Operator): ) bl_options = {"REGISTER", "UNDO"} start_length: bpy.props.FloatProperty( - name="Start Length", description="Transition start length in SI units", default=0.1, subtype="DISTANCE" + 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" + 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" + 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) @@ -868,15 +868,15 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator): bl_description = "Adds a bend between two MEP elements. Elements are either provided by ID or selected in Blender" bl_options = {"REGISTER", "UNDO"} start_length: bpy.props.FloatProperty( - name="Start Length", description="Bend start length in SI units", default=0.1, subtype="DISTANCE" + 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" + 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( - "Bend Inner Radius", description="Bend inner radius in SI units", default=0.2, subtype="DISTANCE" + "Bend Inner Radius", description="Bend inner radius in SI units", default=0.2, subtype="DISTANCE", min=0 ) def _execute(self, context): From ec0873d5279fbd6a0e88bfa33ba001e3f7cc6fae Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Thu, 14 Sep 2023 14:46:05 +0500 Subject: [PATCH 086/190] transitions - fixed bug finding compatible fitting For example, there was a fitting from segment of type A to the segment of type B with start/end transition lengths equal a/b respectively. And then if you'd try to create a transition from B to A and start/end transition lengths being equal to b/a, previously BBIM wouldn't be able to find that already existing fitting is still compatible. --- .../blenderbim/bim/module/model/mep.py | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/model/mep.py b/src/blenderbim/blenderbim/bim/module/model/mep.py index 108ccb45e0..c55ee85061 100644 --- a/src/blenderbim/blenderbim/bim/module/model/mep.py +++ b/src/blenderbim/blenderbim/bim/module/model/mep.py @@ -717,6 +717,12 @@ class MEPAddTransition(bpy.types.Operator, tool.Ifc.Operator): ) 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() @@ -779,24 +785,16 @@ class MEPAddTransition(bpy.types.Operator, tool.Ifc.Operator): transition_dir = keep_only_z_axis(end_segment_extend_point - start_segment_extend_point).normalized() # adjust the segments - end_object_rotation = end_object.matrix_world.to_quaternion() - end_object_z_basis = end_object_rotation.to_matrix().col[2] # z basis vector - - # TODO: do it beforehand, as with bends - if tool.Cad.is_x(start_object_z_basis.dot(transition_dir), 1): - start_connection = "ATEND" - else: - start_connection = "ATSTART" - if tool.Cad.is_x(end_object_z_basis.dot(transition_dir), 1): - end_connection = "ATSTART" - else: - end_connection = "ATEND" 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 / si_conversion, - "end_length": self.end_length / si_conversion, + "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), } From 6109cdf41b63602491a11202eec7a4f82a606a4f Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Thu, 14 Sep 2023 15:06:21 +0500 Subject: [PATCH 087/190] Ooops, forgot to remove debug shape creation --- src/blenderbim/blenderbim/bim/module/model/mep.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/model/mep.py b/src/blenderbim/blenderbim/bim/module/model/mep.py index c55ee85061..df53529232 100644 --- a/src/blenderbim/blenderbim/bim/module/model/mep.py +++ b/src/blenderbim/blenderbim/bim/module/model/mep.py @@ -1112,8 +1112,6 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator): flip_z_axis=start_segment_sign == -1, ) - bpy.ops.bim.create_shape_from_step_id(step_id=rep.id(), should_include_curves=True) - parametric_data = { "start_length": self.start_length / si_conversion, "end_length": self.end_length / si_conversion, From f0e03c79df4fe2ee4d4a778bc0402258871426b9 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 15 Sep 2023 11:24:57 +1000 Subject: [PATCH 088/190] Project advanced mode filtering now uses new filter syntax --- src/blenderbim/blenderbim/bim/import_ifc.py | 13 ------------ .../blenderbim/bim/module/project/operator.py | 21 ++++++++++++------- 2 files changed, 13 insertions(+), 21 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/import_ifc.py b/src/blenderbim/blenderbim/bim/import_ifc.py index f235f652d0..ecfff3bcf0 100644 --- a/src/blenderbim/blenderbim/bim/import_ifc.py +++ b/src/blenderbim/blenderbim/bim/import_ifc.py @@ -21,9 +21,7 @@ import re import bpy import time import bmesh -import shutil import logging -import threading import mathutils import numpy as np import multiprocessing @@ -31,7 +29,6 @@ import ifcopenshell import ifcopenshell.geom import ifcopenshell.util.unit import ifcopenshell.util.element -import ifcopenshell.util.selector import ifcopenshell.util.geolocation import blenderbim.tool as tool from itertools import chain, accumulate @@ -39,16 +36,6 @@ from blenderbim.bim.ifc import IfcStore from blenderbim.bim.module.drawing.prop import ANNOTATION_TYPES_DATA -class FileCopy(threading.Thread): - def __init__(self, file_path, destination): - threading.Thread.__init__(self) - self.file_path = file_path - self.destination = destination - - def run(self): - shutil.copy(self.file_path, self.destination) - - class MaterialCreator: def __init__(self, ifc_import_settings, ifc_importer): self.mesh = None diff --git a/src/blenderbim/blenderbim/bim/module/project/operator.py b/src/blenderbim/blenderbim/bim/module/project/operator.py index 0ee0ac2d58..ec431b80bb 100644 --- a/src/blenderbim/blenderbim/bim/module/project/operator.py +++ b/src/blenderbim/blenderbim/bim/module/project/operator.py @@ -355,8 +355,8 @@ class AppendEntireLibrary(bpy.types.Operator): self.file = IfcStore.get_file() self.library = IfcStore.library_file - lib_elements = ifcopenshell.util.selector.Selector().parse( - self.library, ".IfcTypeProduct | .IfcMaterial | .IfcCostSchedule| .IfcProfileDef" + lib_elements = ifcopenshell.util.selector.filter_elements( + self.library, "IfcTypeProduct, IfcMaterial, IfcCostSchedule, IfcProfileDef" ) for element in lib_elements: bpy.ops.bim.append_library_element(definition=element.id()) @@ -768,12 +768,12 @@ class LoadProjectElements(bpy.types.Operator): return elements def get_whitelist_elements(self): - selector = ifcopenshell.util.selector.Selector() - return set(selector.parse(self.file, self.props.filter_query)) + return set(ifcopenshell.util.selector.filter_elements(self.file, self.props.filter_query)) def get_blacklist_elements(self): - selector = ifcopenshell.util.selector.Selector() - return set(self.file.by_type("IfcElement")) - set(selector.parse(self.file, self.props.filter_query)) + return set(self.file.by_type("IfcElement")) - set( + ifcopenshell.util.selector.filter_elements(self.file, self.props.filter_query) + ) class ToggleFilterCategories(bpy.types.Operator): @@ -895,6 +895,7 @@ class ReloadLink(bpy.types.Operator): for c in bpy.data.collections if "IfcProject" in c.name and c.library and os.path.basename(c.library.filepath) == selected_filename ] + for library in get_linked_ifcs() or []: library.reload() return {"FINISHED"} @@ -920,7 +921,9 @@ class ToggleLinkSelectability(bpy.types.Operator): def get_linked_collections(self): return [ - c for c in bpy.data.collections if "IfcProject" in c.name and c.library and c.library.filepath == self.filepath + c + for c in bpy.data.collections + if "IfcProject" in c.name and c.library and c.library.filepath == self.filepath ] @@ -974,7 +977,9 @@ class ToggleLinkVisibility(bpy.types.Operator): def get_linked_collections(self): return [ - c for c in bpy.data.collections if "IfcProject" in c.name and c.library and c.library.filepath == self.filepath + c + for c in bpy.data.collections + if "IfcProject" in c.name and c.library and c.library.filepath == self.filepath ] From b0d182396864d23cc2ff5cf50725050a3ca27740 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 15 Sep 2023 15:07:36 +0500 Subject: [PATCH 089/190] MEP bends - fixed bunch of bugs related to adjusting already existing bends types --- .../blenderbim/bim/module/model/mep.py | 116 ++++++++++++------ .../ifcopenshell/util/shape_builder.py | 2 +- 2 files changed, 80 insertions(+), 38 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/model/mep.py b/src/blenderbim/blenderbim/bim/module/model/mep.py index df53529232..4a8669d186 100644 --- a/src/blenderbim/blenderbim/bim/module/model/mep.py +++ b/src/blenderbim/blenderbim/bim/module/model/mep.py @@ -367,6 +367,7 @@ class MEPGenerator: 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): @@ -376,8 +377,9 @@ class MEPGenerator: return segments_data.append((segment_type, port.PredefinedType, port.SystemType)) - # TODO: test it with flipped transition where start length != end length 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) @@ -386,16 +388,39 @@ class MEPGenerator: return False fitting_bbim_data = fitting_bbim_data["data_dict"] - for key in bbim_data: + + def compare_value(key, second_key=None): + second_key = second_key or key requested_value = bbim_data[key] - fitting_value = fitting_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) - if not compare: + 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 @@ -450,7 +475,7 @@ class MEPGenerator: 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] + packed_data["start_port_match"] = element_type == segments_data[0][0] and start_port_match return packed_data @@ -1031,11 +1056,17 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator): if error_msg: self.report({"ERROR"}, error_msg) return {"CANCELLED"} + non_lateral_axis = 0 if lateral_axis == 1 else 1 - O = V(0, 0, 0) - angle = pi - tool.Cad.angle_edges( - (get_z_basis(start_object) * start_segment_sign, O), (get_z_basis(end_object) * end_segment_sign, O) - ) + 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(profile_offset[lateral_axis]) radial_offset = V(0, 0, 0) @@ -1126,27 +1157,20 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator): bend_type = fitting_data["fitting_type"] if fitting_data else None start_port_match = fitting_data["start_port_match"] if fitting_data else True - rotate_lateral_axis = None - flip_z_axis_type = None + # 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"] - flip_z_axis_type = bbim_data["flip_z_axis"] - - # if the lateral axis of the compatible fitting type doesn't match - # with the current lateral bend lateral axis, we'll adjust the rotation - if (lateral_axis_type != lateral_axis) or (lateral_sign_type != lateral_sign): - m = Matrix.Identity(3) - current_lateral_axis = m[lateral_axis] * lateral_sign - type_lateral_axis = m[lateral_axis_type] * lateral_sign_type - if lateral_axis_type == lateral_axis: - rotation_axis = m[2] # just V(0,0,1) - else: - rotation_axis = type_lateral_axis.cross(current_lateral_axis) - rotation_angle = current_lateral_axis.angle(type_lateral_axis) - # rotation axis is always Z but need to consider the rotation direction - rotate_lateral_axis = Matrix.Rotation(rotation_angle, 4, rotation_axis) + 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.run("geometry.remove_representation", ifc_file, representation=rep) @@ -1182,18 +1206,36 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator): fitting_obj.matrix_world = start_object.matrix_world context.view_layer.update() - # depending on fitting 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 fitting type before + # 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) - direction_match = True if flip_z_axis_type is None else (flip_z_axis_type == (start_segment_sign == -1)) - # if there are no mismatches or everything matches up we don't need to flip the transition - if start_port_match != direction_match: - fitting_obj.matrix_world = start_object.matrix_world @ Matrix.Rotation(radians(180), 4, "XY"[lateral_axis]) - if rotate_lateral_axis: - fitting_obj.matrix_world = fitting_obj.matrix_world @ rotate_lateral_axis - fitting_obj.location = start_segment_extend_point if start_port_match else end_segment_extend_point + 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() # add ports and connect them ports = tool.System.add_ports(fitting_obj, offset_end_port=start_object_rotation @ (radial_offset * V(1, 1, 0))) diff --git a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py index b85ab7b411..43822dfc6c 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py +++ b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py @@ -1456,7 +1456,7 @@ class ShapeBuilder: "angle": degrees(theta), "lateral_axis": lateral_axis, "lateral_sign": lateral_sign, - "flip_z_axis": flip_z_axis, + "z_axis_sign": -1 if flip_z_axis else 1, "main_profile_dimension": profile_dim[lateral_axis], } return rep, bend_data From 1c85034e194af67aba29b1b5177eef0c462dee6d Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 15 Sep 2023 15:35:53 +0500 Subject: [PATCH 090/190] Add starting/saving IFC project to Blender F4 file context menu https://i.imgur.com/FDvZZwc.png --- src/blenderbim/blenderbim/bim/module/project/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/blenderbim/blenderbim/bim/module/project/__init__.py b/src/blenderbim/blenderbim/bim/module/project/__init__.py index 977bc54c64..383b648053 100644 --- a/src/blenderbim/blenderbim/bim/module/project/__init__.py +++ b/src/blenderbim/blenderbim/bim/module/project/__init__.py @@ -70,6 +70,7 @@ addon_keymaps = [] def register(): bpy.types.Scene.BIMProjectProperties = bpy.props.PointerProperty(type=prop.BIMProjectProperties) bpy.types.TOPBAR_MT_file.prepend(ui.file_menu) + bpy.types.TOPBAR_MT_file_context_menu.prepend(ui.file_menu) wm = bpy.context.window_manager if wm.keyconfigs.addon: km = wm.keyconfigs.addon.keymaps.get("Window") @@ -87,6 +88,7 @@ def register(): def unregister(): bpy.types.TOPBAR_MT_file.remove(ui.file_menu) + bpy.types.TOPBAR_MT_file_context_menu.remove(ui.file_menu) del bpy.types.Scene.BIMProjectProperties wm = bpy.context.window_manager From b6ae5fd3446e203d25abce60b58a78c3506e815a Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 15 Sep 2023 15:54:56 +0500 Subject: [PATCH 091/190] For parametric doors and windows remove "Edit Profile" button from BIM Tool UI to avoid confusion --- .../blenderbim/bim/module/model/workspace.py | 27 +++++++------------ src/blenderbim/blenderbim/tool/model.py | 18 ++++++++++++- 2 files changed, 27 insertions(+), 18 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/model/workspace.py b/src/blenderbim/blenderbim/bim/module/model/workspace.py index 282f311d3c..f8e3d19c0c 100644 --- a/src/blenderbim/blenderbim/bim/module/model/workspace.py +++ b/src/blenderbim/blenderbim/bim/module/model/workspace.py @@ -24,7 +24,7 @@ import blenderbim.tool as tool import blenderbim.bim.module.type.prop as type_prop from blenderbim.bim.helper import prop_with_search, close_operator_panel from bpy.types import WorkSpaceTool -from blenderbim.bim.module.model.data import AuthoringData, RailingData, RoofData +from blenderbim.bim.module.model.data import AuthoringData from blenderbim.bim.module.drawing.data import DecoratorData from blenderbim.bim.module.model.prop import get_ifc_class @@ -358,9 +358,7 @@ class BimToolUI: row.operator("bim.extend_profile", icon="X", text="").join_type = "" elif ( - (RailingData.is_loaded or not RailingData.load()) - and RailingData.data["pset_data"] - and not context.active_object.BIMRailingProperties.is_editing_path + tool.Model.is_parametric_railing_active() and not context.active_object.BIMRailingProperties.is_editing_path ): # NOTE: should be above "active_representation_type" = "SweptSolid" check # because it could be a SweptSolid too @@ -369,7 +367,8 @@ class BimToolUI: row.operator("bim.enable_editing_railing_path", text="Edit Railing Path") elif AuthoringData.data["active_representation_type"] == "SweptSolid": - add_layout_hotkey_operator(cls.layout, "Edit Profile", "S_E", "") + if not tool.Model.is_parametric_window_active() and not tool.Model.is_parametric_door_active(): + add_layout_hotkey_operator(cls.layout, "Edit Profile", "S_E", "") elif AuthoringData.data["active_class"] in ( "IfcWindow", @@ -390,11 +389,7 @@ class BimToolUI: elif AuthoringData.data["active_class"] in ("IfcSpace",): add_layout_hotkey_operator(cls.layout, "Regen", "S_G", bpy.ops.bim.generate_space.__doc__) - elif ( - (RoofData.is_loaded or not RoofData.load()) - and RoofData.data["pset_data"] - and not context.active_object.BIMRoofProperties.is_editing_path - ): + elif tool.Model.is_parametric_roof_active() and not context.active_object.BIMRoofProperties.is_editing_path: row = cls.layout.row(align=True) row.label(text="", icon=f"EVENT_TAB") row.operator("bim.enable_editing_roof_path", text="Edit Roof Path") @@ -585,22 +580,20 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): # NOTE: placing it before the other operations because railing can also be SweptSolid # and it might conflict with one of the conditions below if ( - (RailingData.is_loaded or not RailingData.load()) - and RailingData.data["pset_data"] + tool.Model.is_parametric_railing_active() and not bpy.context.active_object.BIMRailingProperties.is_editing_path ): bpy.ops.bim.enable_editing_railing_path() return - elif ( - (RoofData.is_loaded or not RoofData.load()) - and RoofData.data["pset_data"] - and not bpy.context.active_object.BIMRoofProperties.is_editing_path - ): + elif tool.Model.is_parametric_roof_active() and not bpy.context.active_object.BIMRoofProperties.is_editing_path: # undo the unselection done above because roof has no usage type bpy.ops.bim.enable_editing_roof_path() return + elif tool.Model.is_parametric_window_active() or tool.Model.is_parametric_door_active(): + return + selected_usages = {} for obj in bpy.context.selected_objects: element = tool.Ifc.get_entity(obj) diff --git a/src/blenderbim/blenderbim/tool/model.py b/src/blenderbim/blenderbim/tool/model.py index 904f7019ed..1c12b9a92a 100644 --- a/src/blenderbim/blenderbim/tool/model.py +++ b/src/blenderbim/blenderbim/tool/model.py @@ -28,8 +28,8 @@ import blenderbim.core.geometry as geometry from mathutils import Matrix, Vector from blenderbim.bim import import_ifc from blenderbim.bim.module.geometry.helper import Helper +from blenderbim.bim.module.model.data import AuthoringData, RailingData, RoofData, WindowData, DoorData import collections -from blenderbim.bim.module.model.data import AuthoringData import json @@ -805,3 +805,19 @@ class Model(blenderbim.core.tool.Model): is_global=True, should_sync_changes_first=False, ) + + @classmethod + def is_parametric_roof_active(cls): + return (RoofData.is_loaded or not RoofData.load()) and RoofData.data["pset_data"] + + @classmethod + def is_parametric_railing_active(cls): + return (RailingData.is_loaded or not RailingData.load()) and RailingData.data["pset_data"] + + @classmethod + def is_parametric_window_active(cls): + return (WindowData.is_loaded or not WindowData.load()) and WindowData.data["pset_data"] + + @classmethod + def is_parametric_door_active(cls): + return (DoorData.is_loaded or not DoorData.load()) and DoorData.data["pset_data"] From b13cbbb244f5e2624421ab45f7deecb254f79bd8 Mon Sep 17 00:00:00 2001 From: Massimo Fabbro Date: Fri, 15 Sep 2023 22:47:35 +0200 Subject: [PATCH 092/190] Partial spatial tool refactor --- .../blenderbim/bim/module/model/__init__.py | 1 + .../blenderbim/bim/module/model/space.py | 218 ++---------------- src/blenderbim/blenderbim/core/spatial.py | 66 ++++++ src/blenderbim/blenderbim/core/tool.py | 13 ++ src/blenderbim/blenderbim/tool/spatial.py | 171 +++++++++++++- .../test/bim/feature/spatial.feature | 57 +++-- 6 files changed, 317 insertions(+), 209 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/model/__init__.py b/src/blenderbim/blenderbim/bim/module/model/__init__.py index bdb5f5fe01..8220be3df9 100644 --- a/src/blenderbim/blenderbim/bim/module/model/__init__.py +++ b/src/blenderbim/blenderbim/bim/module/model/__init__.py @@ -106,6 +106,7 @@ classes = ( slab.SetArcIndex, space.GenerateSpace, space.GenerateSpacesFromWalls, + space.GenerateFlooringCoveringsFromWalls, space.ToggleSpaceVisibility, mep.FitFlowSegments, mep.RegenerateDistributionElement, diff --git a/src/blenderbim/blenderbim/bim/module/model/space.py b/src/blenderbim/blenderbim/bim/module/model/space.py index 2af8ae020c..efbcd9bdca 100644 --- a/src/blenderbim/blenderbim/bim/module/model/space.py +++ b/src/blenderbim/blenderbim/bim/module/model/space.py @@ -23,6 +23,7 @@ import shapely import ifcopenshell import ifcopenshell.util.element import blenderbim.tool as tool +import blenderbim.core.spatial as core import blenderbim.core.type from math import pi from mathutils import Vector, Matrix @@ -230,182 +231,29 @@ class GenerateSpacesFromWalls(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): # This only works based on a 2D plan only considering the standard # walls (i.e. prismatic) in the active object storey. - # In order to run, the active objct must be a wall and - # have to be selected walls - props = context.scene.BIMModelProperties + # In order to run, the active object must be a wall and + # there must be selected walls + core.generate_spaces_from_walls(tool.Ifc, tool.Spatial, tool.Collector) + +class GenerateFlooringCoveringsFromWalls(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.generate_flooring_coverings_from_walls" + bl_label = "Generate Flooring Coverings From Walls" + bl_options = {"REGISTER", "UNDO"} + bl_description = "Generate flooring coverings from selected walls. The active object must be a wall" + + @classmethod + def poll(cls, context): active_obj = bpy.context.active_object - - if not active_obj: - self.report({"ERROR"}, "No active object. Please select a wall") - return - element = tool.Ifc.get_entity(active_obj) - if element and not element.is_a("IfcWall"): - return self.report({"ERROR"}, "The active object is not a wall. Please select a wall.") - - container = ifcopenshell.util.element.get_container(element) - if not container: - self.report({"ERROR"}, "The wall is not contained.") - - if not bpy.context.selected_objects: - self.report({"ERROR"}, "No selected objects found. Please select walls.") - return - - x, y, z = active_obj.matrix_world.translation.xyz - mat = active_obj.matrix_world - h = active_obj.dimensions.z - selected_objects = bpy.context.selected_objects - - boundary_elements = self.get_boundary_elements(selected_objects) - - polys = self.get_polygons(boundary_elements) - - converted_tolerance = self.get_converted_tolerance(tolerance=0.03) - - union = shapely.ops.unary_union(polys).buffer(converted_tolerance, cap_style=2, join_style=2) - - union = self.get_purged_inner_holes_poly(union_geom=union, min_area=self.get_converted_tolerance(tolerance=3)) - - for i, linear_ring in enumerate(union.interiors): - poly = Polygon(linear_ring) - poly = poly.buffer(converted_tolerance, single_sided=True, cap_style=2, join_style=2) - - bm = self.get_bmesh_from_polygon(poly, mat, h) - - name = "Space" + str(i) - mesh = bpy.data.meshes.new(name=name) - bm.to_mesh(mesh) - bm.free() - - obj = bpy.data.objects.new(name, mesh) - obj.matrix_world = mat - - self.set_obj_origin_to_bboxcenter(obj) - - if z != 0: - obj.location = obj.location + Vector((0, 0, z)) - - context.view_layer.active_layer_collection.collection.objects.link(obj) - bpy.ops.bim.assign_class(obj=obj.name, ifc_class="IfcSpace") - container_obj = tool.Ifc.get_object(container) - blenderbim.core.spatial.assign_container( - tool.Ifc, tool.Collector, tool.Spatial, structure_obj=container_obj, element_obj=obj - ) - - def get_boundary_elements(self, selected_objects): - boundary_elements = [] - for obj in selected_objects: - subelement = tool.Ifc.get_entity(obj) - if subelement.is_a("IfcWall") or subelement.is_a("IfcColumn"): - boundary_elements.append(subelement) - return boundary_elements - - def get_polygons(self, boundary_elements): - polys = [] - for boundary_element in boundary_elements: - obj = tool.Ifc.get_object(boundary_element) - if not obj: - continue - points = [] - base = self.get_obj_base_points(obj) - for index in ["low_left", "low_right", "high_right", "high_left"]: - point = base[index] - points.append(point) - - polys.append(Polygon(points)) - return polys - - def get_obj_base_points(self, obj): - x_values = [(obj.matrix_world @ Vector(v)).x for v in obj.bound_box] - y_values = [(obj.matrix_world @ Vector(v)).y for v in obj.bound_box] - return { - "low_left": (x_values[0], y_values[0]), - "high_left": (x_values[3], y_values[3]), - "low_right": (x_values[4], y_values[4]), - "high_right": (x_values[7], y_values[7]), - } - - def get_converted_tolerance(self, tolerance): - model = tool.Ifc.get() - project_unit = ifcopenshell.util.unit.get_project_unit(model, "LENGTHUNIT") - prefix = getattr(project_unit, "Prefix", None) - - converted_tolerance = ifcopenshell.util.unit.convert( - value=tolerance, - from_prefix=None, - from_unit="METRE", - to_prefix=prefix, - to_unit=project_unit.Name, - ) - return tolerance - - def get_purged_inner_holes_poly(self, union_geom, min_area): - interiors_list = [] - - if union_geom.geom_type == "MultiPolygon": - for poly in union_geom.geoms: - interiors_list = self.get_poly_valid_interior_list( - poly=poly, min_area=min_area, interiors_list=interiors_list - ) - - new_poly = Polygon(poly.exterior.coords, holes=interiors_list) - - if union_geom.geom_type == "Polygon": - interiors_list = self.get_poly_valid_interior_list( - poly=union_geom, min_area=min_area, interiors_list=interiors_list - ) - new_poly = Polygon(union_geom.exterior.coords, holes=interiors_list) - - return new_poly - - def get_poly_valid_interior_list(self, poly, min_area, interiors_list): - for interior in poly.interiors: - p = Polygon(interior) - if p.area >= min_area: - interiors_list.append(interior) - return interiors_list - - def get_bmesh_from_polygon(self, poly, mat, h): - bm = bmesh.new() - bm.verts.index_update() - bm.edges.index_update() - - mat_invert = mat.inverted() - - new_verts = [bm.verts.new(mat_invert @ Vector([v[0], v[1], 0])) for v in poly.exterior.coords[0:-1]] - [bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)] - bm.edges.new((new_verts[len(new_verts) - 1], new_verts[0])) - - bm.verts.index_update() - bm.edges.index_update() - - bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-5) - bmesh.ops.triangle_fill(bm, edges=bm.edges) - bmesh.ops.dissolve_limit(bm, angle_limit=pi / 180 * 5, verts=bm.verts, edges=bm.edges) - - extrusion = bmesh.ops.extrude_face_region(bm, geom=bm.faces) - extruded_verts = [g for g in extrusion["geom"] if isinstance(g, bmesh.types.BMVert)] - bmesh.ops.translate(bm, vec=[0.0, 0.0, h], verts=extruded_verts) - - bmesh.ops.recalc_face_normals(bm, faces=bm.faces) - - return bm - - def set_obj_origin_to_bboxcenter(self, obj): - mat = obj.matrix_world - inverted = mat.inverted() - local_bbox_center = 0.125 * sum((Vector(b) for b in obj.bound_box), Vector()) - global_bbox_center = mat @ local_bbox_center - - oldLoc = obj.location - newLoc = global_bbox_center - diff = newLoc - oldLoc - for vert in obj.data.vertices: - aux_vector = mat @ vert.co - aux_vector = aux_vector - diff - vert.co = inverted @ aux_vector - obj.location = newLoc + if element: + return context.selected_objects and element.is_a("IfcWall") + def _execute(self, context): + # This only works based on a 2D plan only considering the standard + # walls (i.e. prismatic) in the active object storey. + # In order to run, the active object must be a wall and + # there must be selected walls + core.generate_flooring_coverings_from_walls(tool.Ifc, tool.Spatial, tool.Collector) class ToggleSpaceVisibility(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.toggle_space_visibility" @@ -414,26 +262,6 @@ class ToggleSpaceVisibility(bpy.types.Operator, tool.Ifc.Operator): bl_description = "Change the space visibility" def execute(cls, context): - model = tool.Ifc.get() + core.toggle_space_visibility(tool.Ifc, tool.Spatial) + return {"FINISHED"} - spaces = model.by_type("IfcSpace") - - if not spaces: - print(spaces) - return {"FINISHED"} - - first_obj = tool.Ifc.get_object(spaces[0]) - - if bpy.data.objects[first_obj.name].display_type == "TEXTURED": - for space in spaces: - obj = tool.Ifc.get_object(space) - bpy.data.objects[obj.name].show_wire = True - bpy.data.objects[obj.name].display_type = "WIRE" - return {"FINISHED"} - - elif bpy.data.objects[first_obj.name].display_type == "WIRE": - for space in spaces: - obj = tool.Ifc.get_object(space) - bpy.data.objects[obj.name].show_wire = False - bpy.data.objects[obj.name].display_type = "TEXTURED" - return {"FINISHED"} diff --git a/src/blenderbim/blenderbim/core/spatial.py b/src/blenderbim/blenderbim/core/spatial.py index fb75dd2671..7864ad0845 100644 --- a/src/blenderbim/blenderbim/core/spatial.py +++ b/src/blenderbim/blenderbim/core/spatial.py @@ -17,6 +17,8 @@ # along with BlenderBIM Add-on. If not, see . import blenderbim.core +import bpy +import ifcopenshell def reference_structure(ifc, spatial, structure=None, element=None): @@ -120,3 +122,67 @@ def select_decomposed_elements(spatial): container = spatial.get_active_container() if container: spatial.select_products(spatial.get_decomposed_elements(container)) + +#HERE STARTS SPATIAL TOOL + +def generate_spaces_from_walls(ifc, spatial, collector): + #props = bpy.context.scene.BIMModelProperties + container, active_obj = spatial.get_container_and_active_obj() + + if not active_obj: + self.report({"ERROR"}, "No active object. Please select a wall") + return + + element = ifc.get_entity(active_obj) + if element and not element.is_a("IfcWall"): + return self.report({"ERROR"}, "The active object is not a wall. Please select a wall.") + + if not container: + self.report({"ERROR"}, "The wall is not contained.") + + if not bpy.context.selected_objects: + self.report({"ERROR"}, "No selected objects found. Please select walls.") + return + + x, y, z = active_obj.matrix_world.translation.xyz + mat = active_obj.matrix_world + h = active_obj.dimensions.z + selected_objects = bpy.context.selected_objects + + union = spatial.get_union_shape_from_selected_objects(selected_objects) + + for i, linear_ring in enumerate(union.interiors): + poly = spatial.get_buffered_poly_from_linear_ring(linear_ring) + + bm = spatial.get_bmesh_from_polygon(poly, mat, h) + + name = "Space" + str(i) + mesh = bpy.data.meshes.new(name=name) + bm.to_mesh(mesh) + bm.free() + + obj = bpy.data.objects.new(name, mesh) + obj.matrix_world = mat + + spatial.set_obj_origin_to_bboxcenter(obj) + + if z != 0: + obj.location = obj.location + Vector((0, 0, z)) + + bpy.context.view_layer.active_layer_collection.collection.objects.link(obj) + bpy.ops.bim.assign_class(obj=obj.name, ifc_class="IfcSpace") + container_obj = ifc.get_object(container) + blenderbim.core.spatial.assign_container( + ifc, collector, spatial, structure_obj=container_obj, element_obj=obj + ) + +def generate_flooring_coverings_from_walls(ifc, spatial, collector): + pass + +def toggle_space_visibility(ifc, spatial): + model = ifc.get() + spaces = model.by_type("IfcSpace") + if not spaces: + return + spatial.toggle_spaces_visibility_wired_and_textured(spaces) + diff --git a/src/blenderbim/blenderbim/core/tool.py b/src/blenderbim/blenderbim/core/tool.py index f0f168a7ba..b249df13f3 100644 --- a/src/blenderbim/blenderbim/core/tool.py +++ b/src/blenderbim/blenderbim/core/tool.py @@ -810,6 +810,19 @@ class Spatial: def set_active_object(cls, obj): pass def set_relative_object_matrix(cls, target_obj, relative_to_obj, matrix): pass def show_scene_objects(cls): pass + #HERE STARTS SPATIAL TOOL + def get_container_and_active_obj(cls): pass + def get_union_shape_from_selected_objects(cls, selected_objects): pass + def get_boundary_elements(cls, selected_objects): pass + def get_polygons(cls, boundary_elements): pass + def get_obj_base_points(cls, obj): pass + def get_converted_tolerance(cls, tolerance): pass + def get_purged_inner_holes_poly(cls, union_geom, min_area): pass + def get_poly_valid_interior_list(cls, poly, min_area, interiors_list): pass + def get_buffered_poly_from_linear_ring(cls, linear_ring): pass + def get_bmesh_from_polygon(cls, poly, mat, h): pass + def set_obj_origin_to_bboxcenter(cls, obj): pass + def toggle_spaces_visibility_wired_and_textured(cls, spaces): pass @interface diff --git a/src/blenderbim/blenderbim/tool/spatial.py b/src/blenderbim/blenderbim/tool/spatial.py index 5c3b6b895a..c6865c9c1d 100644 --- a/src/blenderbim/blenderbim/tool/spatial.py +++ b/src/blenderbim/blenderbim/tool/spatial.py @@ -17,13 +17,17 @@ # along with BlenderBIM Add-on. If not, see . import bpy +import bmesh +import shapely import ifcopenshell import blenderbim.core.tool import blenderbim.core.root import blenderbim.core.spatial import blenderbim.tool as tool import json - +from math import pi +from mathutils import Vector, Matrix +from shapely import Polygon, MultiPolygon class Spatial(blenderbim.core.tool.Spatial): @classmethod @@ -262,3 +266,168 @@ class Spatial(blenderbim.core.tool.Spatial): contracted_containers = json.loads(props.contracted_containers) contracted_containers.remove(container.id()) props.contracted_containers = json.dumps(contracted_containers) + +#HERE STARTS SPATIAL TOOL + + @classmethod + def get_container_and_active_obj(cls): + active_obj = bpy.context.active_object + element = tool.Ifc.get_entity(active_obj) + container = ifcopenshell.util.element.get_container(element) + return container, active_obj + + @classmethod + def get_union_shape_from_selected_objects(cls, selected_objects): + boundary_elements = cls.get_boundary_elements(selected_objects) + polys = cls.get_polygons(boundary_elements) + converted_tolerance = cls.get_converted_tolerance(tolerance=0.03) + union = shapely.ops.unary_union(polys).buffer(converted_tolerance, cap_style=2, join_style=2) + union = cls.get_purged_inner_holes_poly(union_geom=union, min_area=cls.get_converted_tolerance(tolerance=3)) + + return union + + @classmethod + def get_boundary_elements(cls, selected_objects): + boundary_elements = [] + for obj in selected_objects: + subelement = tool.Ifc.get_entity(obj) + if subelement.is_a("IfcWall") or subelement.is_a("IfcColumn"): + boundary_elements.append(subelement) + return boundary_elements + + @classmethod + def get_polygons(cls, boundary_elements): + polys = [] + for boundary_element in boundary_elements: + obj = tool.Ifc.get_object(boundary_element) + if not obj: + continue + points = [] + base = cls.get_obj_base_points(obj) + for index in ["low_left", "low_right", "high_right", "high_left"]: + point = base[index] + points.append(point) + + polys.append(Polygon(points)) + return polys + + @classmethod + def get_obj_base_points(cls, obj): + x_values = [(obj.matrix_world @ Vector(v)).x for v in obj.bound_box] + y_values = [(obj.matrix_world @ Vector(v)).y for v in obj.bound_box] + return { + "low_left": (x_values[0], y_values[0]), + "high_left": (x_values[3], y_values[3]), + "low_right": (x_values[4], y_values[4]), + "high_right": (x_values[7], y_values[7]), + } + + @classmethod + def get_converted_tolerance(cls, tolerance): + model = tool.Ifc.get() + project_unit = ifcopenshell.util.unit.get_project_unit(model, "LENGTHUNIT") + prefix = getattr(project_unit, "Prefix", None) + + converted_tolerance = ifcopenshell.util.unit.convert( + value=tolerance, + from_prefix=None, + from_unit="METRE", + to_prefix=prefix, + to_unit=project_unit.Name, + ) + return tolerance + + @classmethod + def get_purged_inner_holes_poly(cls, union_geom, min_area): + interiors_list = [] + + if union_geom.geom_type == "MultiPolygon": + for poly in union_geom.geoms: + interiors_list = cls.get_poly_valid_interior_list( + poly=poly, min_area=min_area, interiors_list=interiors_list + ) + + new_poly = Polygon(poly.exterior.coords, holes=interiors_list) + + if union_geom.geom_type == "Polygon": + interiors_list = cls.get_poly_valid_interior_list( + poly=union_geom, min_area=min_area, interiors_list=interiors_list + ) + new_poly = Polygon(union_geom.exterior.coords, holes=interiors_list) + + return new_poly + + @classmethod + def get_poly_valid_interior_list(cls, poly, min_area, interiors_list): + for interior in poly.interiors: + p = Polygon(interior) + if p.area >= min_area: + interiors_list.append(interior) + return interiors_list + + @classmethod + def get_buffered_poly_from_linear_ring(cls, linear_ring): + poly = Polygon(linear_ring) + converted_tolerance = cls.get_converted_tolerance(tolerance=0.03) + poly = poly.buffer(converted_tolerance, single_sided=True, cap_style=2, join_style=2) + return poly + + @classmethod + def get_bmesh_from_polygon(cls, poly, mat, h): + bm = bmesh.new() + bm.verts.index_update() + bm.edges.index_update() + + mat_invert = mat.inverted() + + new_verts = [bm.verts.new(mat_invert @ Vector([v[0], v[1], 0])) for v in poly.exterior.coords[0:-1]] + [bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)] + bm.edges.new((new_verts[len(new_verts) - 1], new_verts[0])) + + bm.verts.index_update() + bm.edges.index_update() + + bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=1e-5) + bmesh.ops.triangle_fill(bm, edges=bm.edges) + bmesh.ops.dissolve_limit(bm, angle_limit=pi / 180 * 5, verts=bm.verts, edges=bm.edges) + + extrusion = bmesh.ops.extrude_face_region(bm, geom=bm.faces) + extruded_verts = [g for g in extrusion["geom"] if isinstance(g, bmesh.types.BMVert)] + bmesh.ops.translate(bm, vec=[0.0, 0.0, h], verts=extruded_verts) + + bmesh.ops.recalc_face_normals(bm, faces=bm.faces) + + return bm + + @classmethod + def set_obj_origin_to_bboxcenter(cls, obj): + mat = obj.matrix_world + inverted = mat.inverted() + local_bbox_center = 0.125 * sum((Vector(b) for b in obj.bound_box), Vector()) + global_bbox_center = mat @ local_bbox_center + + oldLoc = obj.location + newLoc = global_bbox_center + diff = newLoc - oldLoc + for vert in obj.data.vertices: + aux_vector = mat @ vert.co + aux_vector = aux_vector - diff + vert.co = inverted @ aux_vector + obj.location = newLoc + + @classmethod + def toggle_spaces_visibility_wired_and_textured(cls, spaces): + first_obj = tool.Ifc.get_object(spaces[0]) + if bpy.data.objects[first_obj.name].display_type == "TEXTURED": + for space in spaces: + obj = tool.Ifc.get_object(space) + bpy.data.objects[obj.name].show_wire = True + bpy.data.objects[obj.name].display_type = "WIRE" + return + + elif bpy.data.objects[first_obj.name].display_type == "WIRE": + for space in spaces: + obj = tool.Ifc.get_object(space) + bpy.data.objects[obj.name].show_wire = False + bpy.data.objects[obj.name].display_type = "TEXTURED" + return diff --git a/src/blenderbim/test/bim/feature/spatial.feature b/src/blenderbim/test/bim/feature/spatial.feature index acdf6ee292..4e0d84d88c 100644 --- a/src/blenderbim/test/bim/feature/spatial.feature +++ b/src/blenderbim/test/bim/feature/spatial.feature @@ -1,6 +1,6 @@ @spatial Feature: Spatial - Covers spatial containment management. + Covers spatial containment management and spatial tool. Scenario: Enable editing container Given an empty IFC project @@ -21,8 +21,7 @@ Scenario: Assign container Given an empty IFC project And I add a cube And the object "Cube" is selected - And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" - And I press "bim.assign_class" + And I press "bim.assign_class(ifc_class='IfcWall', predefined_type='SOLIDWALL')" And the object "IfcWall/Cube" is selected And I press "bim.enable_editing_container" And the variable "site" is "tool.Ifc.get().by_type('IfcSite')[0].id()" @@ -33,8 +32,7 @@ Scenario: Copy to container Given an empty IFC project And I add a cube And the object "Cube" is selected - And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" - And I press "bim.assign_class" + And I press "bim.assign_class(ifc_class='IfcWall', predefined_type='SOLIDWALL')" And the object "IfcWall/Cube" is selected And I press "bim.enable_editing_container" When I set "scene.BIMSpatialProperties.containers[0].is_selected" to "True" @@ -45,8 +43,7 @@ Scenario: Reference structure Given an empty IFC project And I add a cube And the object "Cube" is selected - And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" - And I press "bim.assign_class" + And I press "bim.assign_class(ifc_class='IfcWall', predefined_type='SOLIDWALL')" And the object "IfcWall/Cube" is selected And I press "bim.enable_editing_container" When I set "scene.BIMSpatialProperties.containers[0].is_selected" to "True" @@ -57,8 +54,7 @@ Scenario: Dereference structure Given an empty IFC project And I add a cube And the object "Cube" is selected - And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" - And I press "bim.assign_class" + And I press "bim.assign_class(ifc_class='IfcWall', predefined_type='SOLIDWALL')" And the object "IfcWall/Cube" is selected And I press "bim.enable_editing_container" When I set "scene.BIMSpatialProperties.containers[0].is_selected" to "True" @@ -70,8 +66,7 @@ Scenario: Select container Given an empty IFC project And I add a cube And the object "Cube" is selected - And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" - And I press "bim.assign_class" + And I press "bim.assign_class(ifc_class='IfcWall', predefined_type='SOLIDWALL')" And the object "IfcWall/Cube" is selected And I press "bim.enable_editing_container" And the variable "site" is "tool.Ifc.get().by_type('IfcSite')[0].id()" @@ -83,11 +78,47 @@ Scenario: Select similar container Given an empty IFC project And I add a cube And the object "Cube" is selected - And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" - And I press "bim.assign_class" + And I press "bim.assign_class(ifc_class='IfcWall', predefined_type='SOLIDWALL')" And the object "IfcWall/Cube" is selected And I press "bim.enable_editing_container" And the variable "site" is "tool.Ifc.get().by_type('IfcSite')[0].id()" And I press "bim.assign_container(structure={site})" When I press "bim.select_similar_container" Then nothing happens + +#HERE STARTS TESTS FOR SPATIAL TOOL + +Scenario: Execute generate space from cursor position + Given an empty IFC project + When I press "bim.generate_space" + Then nothing happens + +Scenario: Execute generate spaces from walls + Given an empty IFC project + And I load the demo construction library + And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" + And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()" + And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" + And I press "bim.add_constr_type_instance" + And the object "IfcWall/Wall" is selected + When I press "bim.generate_spaces_from_walls" + Then nothing happens + +Scenario: Execute generate flooring coverings from walls + Given an empty IFC project + And I load the demo construction library + And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" + And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()" + And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" + And I press "bim.add_constr_type_instance" + And the object "IfcWall/Wall" is selected + When I press "bim.generate_flooring_coverings_from_walls" + Then nothing happens + +Scenario: Execute toggle space visibility + Given an empty IFC project + And I add a cube + And the object "Cube" is selected + And I press "bim.assign_class(ifc_class='IfcSpace', predefined_type='SPACE')" + When I press "bim.toggle_space_visibility" + Then nothing happens From a668743d10493e7779cb17d6e9ffd6e8ab9b3a44 Mon Sep 17 00:00:00 2001 From: "Sigma Dimensions (Yass)" <79010126+myoualid@users.noreply.github.com> Date: Sat, 16 Sep 2023 19:29:48 +0100 Subject: [PATCH 093/190] fix msp .xml import: exception days for a standard working week #3766 --- src/ifc4d/ifc4d/msp2ifc.py | 149 ++++++++++++++++++++++++++++++------- 1 file changed, 123 insertions(+), 26 deletions(-) diff --git a/src/ifc4d/ifc4d/msp2ifc.py b/src/ifc4d/ifc4d/msp2ifc.py index 470bcc435b..52592fa5d1 100644 --- a/src/ifc4d/ifc4d/msp2ifc.py +++ b/src/ifc4d/ifc4d/msp2ifc.py @@ -17,7 +17,7 @@ # along with Ifc4D. If not, see . import datetime -from datetime import timedelta +from datetime import timedelta, date import ifcopenshell import ifcopenshell.api import ifcopenshell.util.date @@ -110,31 +110,73 @@ class MSP2Ifc: for calendar in project.find("pr:Calendars", self.ns).findall("pr:Calendar", self.ns): calendar_id = calendar.find("pr:UID", self.ns).text week_days = [] + exceptions = {} week_days_element = calendar.find("pr:WeekDays", self.ns) week_day_elements = week_days_element.findall("pr:WeekDay", self.ns) if week_days_element else [] for week_day in week_day_elements: - working_times = [] if week_day.find("pr:WorkingTimes", self.ns): - for working_time in week_day.find("pr:WorkingTimes", self.ns).findall("pr:WorkingTime", self.ns): - if working_time.find("pr:FromTime", self.ns) is None: - continue - working_times.append( + working_times = [] + if week_day.find("pr:DayType", self.ns).text == "0": + d = datetime.datetime.fromisoformat( + week_day.find("pr:TimePeriod", self.ns).find("pr:FromDate", self.ns).text + ).date() + month = exceptions.setdefault(d.year, {}).setdefault(d.month, {}) + month.setdefault("FullDay", []) + month.setdefault("WorkTime", []) + for working_time in week_day.find("pr:WorkingTimes", self.ns).findall( + "pr:WorkingTime", self.ns + ): + if working_time.find("pr:FromTime", self.ns) is None: + continue + working_times.append( + { + "Start": datetime.time.fromisoformat( + working_time.find("pr:FromTime", self.ns).text + ), + "Finish": datetime.time.fromisoformat(working_time.find("pr:ToTime", self.ns).text), + "ifc": None, + } + ) + if working_times: + exceptions[d.year][d.month]["WorkTime"].append( + { + "Day": d.day, + "FromDate": datetime.datetime.fromisoformat( + week_day.find("pr:TimePeriod", self.ns).find("pr:FromDate", self.ns).text + ), + "ToDate": datetime.datetime.fromisoformat( + week_day.find("pr:TimePeriod", self.ns).find("pr:ToDate", self.ns).text + ), + "WorkingTimes": working_times, + "ifc": None, + } + ) + else: + for working_time in week_day.find("pr:WorkingTimes", self.ns).findall( + "pr:WorkingTime", self.ns + ): + if working_time.find("pr:FromTime", self.ns) is None: + continue + working_times.append( + { + "Start": datetime.time.fromisoformat( + working_time.find("pr:FromTime", self.ns).text + ), + "Finish": datetime.time.fromisoformat(working_time.find("pr:ToTime", self.ns).text), + } + ) + week_days.append( { - "Start": datetime.time.fromisoformat(working_time.find("pr:FromTime", self.ns).text), - "Finish": datetime.time.fromisoformat(working_time.find("pr:ToTime", self.ns).text), + "DayType": week_day.find("pr:DayType", self.ns).text, + "WorkingTimes": working_times, + "ifc": None, } ) - week_days.append( - { - "DayType": week_day.find("pr:DayType", self.ns).text, - "WorkingTimes": working_times, - "ifc": None, - } - ) - exceptions = {} + self.calendars[calendar_id] = { "Name": calendar.find("pr:Name", self.ns).text, "StandardWorkWeek": week_days, + "HolidayOrExceptions": exceptions, } def create_ifc(self): @@ -166,6 +208,7 @@ class MSP2Ifc: for calendar in self.calendars.values(): calendar["ifc"] = ifcopenshell.api.run("sequence.add_work_calendar", self.file, name=calendar["Name"]) self.process_working_week(calendar["StandardWorkWeek"], calendar["ifc"]) + self.process_exceptions(calendar["HolidayOrExceptions"], calendar["ifc"]) def create_task(self, task, work_schedule=None, parent_task=None): task["ifc"] = ifcopenshell.api.run( @@ -218,13 +261,14 @@ class MSP2Ifc: def process_working_week(self, week, calendar): day_map = { - "1": 7, # Sunday - "2": 1, # Monday - "3": 2, # Tuesday - "4": 3, # Wednesday - "5": 4, # Thursday - "6": 5, # Friday - "7": 6, # Saturday + "1": 7, # Sunday + "2": 1, # Monday + "3": 2, # Tuesday + "4": 3, # Wednesday + "5": 4, # Thursday + "6": 5, # Friday + "7": 6, # Saturday + "0": 0, # Exception } for day in week: if day["ifc"]: @@ -297,14 +341,12 @@ class MSP2Ifc: def parse_resources_xml(self, project): resources_lst = project.find("pr:Resources", self.ns) resources = resources_lst.findall("pr:Resource", self.ns) - # print("Resource text", resources[4].find("pr:Name", self.ns).text) for resource in resources: name = resource.find("pr:Name", self.ns) id = resource.find("pr:ID", self.ns).text if name is not None: name = name.text else: - # print("- No Name") name = None self.resources[id] = { "Name": name, @@ -314,4 +356,59 @@ class MSP2Ifc: "ifc": None, "rel": None, } - print("Resource found", self.resources) + + def process_exceptions(self, exceptions, calendar): + if exceptions: + for year, year_data in exceptions.items(): + for month, month_data in year_data.items(): + if month_data["WorkTime"]: + self.process_work_time_exceptions(year, month, month_data, calendar) + + def process_work_time_exceptions(self, year, month, month_data, calendar): + for day in month_data["WorkTime"]: + if day["ifc"]: + continue + + day["ifc"] = ifcopenshell.api.run( + "sequence.add_work_time", self.file, work_calendar=calendar, time_type="ExceptionTimes" + ) + + day_component = [day["Day"]] + for day2 in month_data["WorkTime"]: + if day["Day"] == day2["Day"]: + continue + if day["WorkingTimes"] == day2["WorkingTimes"]: + day_component.append(day2["Day"]) + # Don't process the next day, as we can group it + day2["ifc"] = day["ifc"] + + ifcopenshell.api.run( + "sequence.edit_work_time", + self.file, + work_time=day["ifc"], + attributes={ + "Name": "{}-{}-{}".format(year, month, ", ".join([str(d) for d in day_component])), + "Start": ifcopenshell.util.date.datetime2ifc(day["FromDate"], "IfcDate"), + "Finish": ifcopenshell.util.date.datetime2ifc(day["ToDate"], "IfcDate"), + }, + ) + recurrence = ifcopenshell.api.run( + "sequence.assign_recurrence_pattern", + self.file, + parent=day["ifc"], + recurrence_type="YEARLY_BY_DAY_OF_MONTH", + ) + ifcopenshell.api.run( + "sequence.edit_recurrence_pattern", + self.file, + recurrence_pattern=recurrence, + attributes={"DayComponent": day_component, "MonthComponent": [month]}, + ) + for work_time in day["WorkingTimes"]: + ifcopenshell.api.run( + "sequence.add_time_period", + self.file, + recurrence_pattern=recurrence, + start_time=work_time["Start"], + end_time=work_time["Finish"], + ) From a34d0cabfc621e4303f8b804b5c591b7bb978fb3 Mon Sep 17 00:00:00 2001 From: "Sigma Dimensions (Yass)" <79010126+myoualid@users.noreply.github.com> Date: Sun, 17 Sep 2023 03:10:31 +0100 Subject: [PATCH 094/190] MSP calendar exceptions are now also imported #3766 --- src/ifc4d/ifc4d/msp2ifc.py | 209 +++++++++++++++++++------------------ 1 file changed, 110 insertions(+), 99 deletions(-) diff --git a/src/ifc4d/ifc4d/msp2ifc.py b/src/ifc4d/ifc4d/msp2ifc.py index 52592fa5d1..159d8279e9 100644 --- a/src/ifc4d/ifc4d/msp2ifc.py +++ b/src/ifc4d/ifc4d/msp2ifc.py @@ -107,71 +107,74 @@ class MSP2Ifc: } def parse_calendar_xml(self, project): + def parse_working_times(day): + working_times = [] + if day.find("pr:WorkingTimes", self.ns): + for working_time in day.find("pr:WorkingTimes", self.ns).findall("pr:WorkingTime", self.ns): + if working_time.find("pr:FromTime", self.ns) is None: + continue + working_times.append( + { + "Start": datetime.time.fromisoformat(working_time.find("pr:FromTime", self.ns).text), + "Finish": datetime.time.fromisoformat(working_time.find("pr:ToTime", self.ns).text), + } + ) + return working_times + + def parse_exception(exception): + work_times = parse_working_times(exception) + time_period = exception.find("pr:TimePeriod", self.ns) + data = { + "Name": exception.find("pr:Name", self.ns).text + if exception.find("pr:Name", self.ns) is not None + else None, + "FromDate": datetime.datetime.fromisoformat(time_period.find("pr:FromDate", self.ns).text) + if time_period is not None + else None, + "ToDate": datetime.datetime.fromisoformat(time_period.find("pr:ToDate", self.ns).text) + if time_period is not None + else None, + "Occurrences": int(exception.find("pr:Occurrences", self.ns).text) + if exception.find("pr:Occurrences", self.ns) is not None + else None, + "Month": exception.find("pr:Month", self.ns).text + if exception.find("pr:Month", self.ns) is not None + else None, + "MonthDay": exception.find("pr:MonthDay", self.ns).text + if exception.find("pr:MonthDay", self.ns) is not None + else None, + "Type": exception.find("pr:Type", self.ns).text + if exception.find("pr:Type", self.ns) is not None + else None, + "WorkingTimes": work_times, + "ifc": None, + } + return data + for calendar in project.find("pr:Calendars", self.ns).findall("pr:Calendar", self.ns): calendar_id = calendar.find("pr:UID", self.ns).text week_days = [] - exceptions = {} + exceptions = [] week_days_element = calendar.find("pr:WeekDays", self.ns) week_day_elements = week_days_element.findall("pr:WeekDay", self.ns) if week_days_element else [] for week_day in week_day_elements: if week_day.find("pr:WorkingTimes", self.ns): - working_times = [] if week_day.find("pr:DayType", self.ns).text == "0": - d = datetime.datetime.fromisoformat( - week_day.find("pr:TimePeriod", self.ns).find("pr:FromDate", self.ns).text - ).date() - month = exceptions.setdefault(d.year, {}).setdefault(d.month, {}) - month.setdefault("FullDay", []) - month.setdefault("WorkTime", []) - for working_time in week_day.find("pr:WorkingTimes", self.ns).findall( - "pr:WorkingTime", self.ns - ): - if working_time.find("pr:FromTime", self.ns) is None: - continue - working_times.append( - { - "Start": datetime.time.fromisoformat( - working_time.find("pr:FromTime", self.ns).text - ), - "Finish": datetime.time.fromisoformat(working_time.find("pr:ToTime", self.ns).text), - "ifc": None, - } - ) - if working_times: - exceptions[d.year][d.month]["WorkTime"].append( - { - "Day": d.day, - "FromDate": datetime.datetime.fromisoformat( - week_day.find("pr:TimePeriod", self.ns).find("pr:FromDate", self.ns).text - ), - "ToDate": datetime.datetime.fromisoformat( - week_day.find("pr:TimePeriod", self.ns).find("pr:ToDate", self.ns).text - ), - "WorkingTimes": working_times, - "ifc": None, - } - ) + data = parse_exception(week_day) + data["Type"] = "2" + exceptions.append(data) else: - for working_time in week_day.find("pr:WorkingTimes", self.ns).findall( - "pr:WorkingTime", self.ns - ): - if working_time.find("pr:FromTime", self.ns) is None: - continue - working_times.append( - { - "Start": datetime.time.fromisoformat( - working_time.find("pr:FromTime", self.ns).text - ), - "Finish": datetime.time.fromisoformat(working_time.find("pr:ToTime", self.ns).text), - } - ) week_days.append( { "DayType": week_day.find("pr:DayType", self.ns).text, - "WorkingTimes": working_times, + "WorkingTimes": parse_working_times(week_day), "ifc": None, } ) + exceptions_element = calendar.find("pr:Exceptions", self.ns) + for exception in exceptions_element.findall("pr:Exception", self.ns) if exceptions_element else []: + data = parse_exception(exception) + exceptions.append(data) self.calendars[calendar_id] = { "Name": calendar.find("pr:Name", self.ns).text, @@ -205,7 +208,12 @@ class MSP2Ifc: ) def create_calendars(self): + def has_work_or_exceptions(calendar): + return calendar["StandardWorkWeek"] or calendar["HolidayOrExceptions"] + for calendar in self.calendars.values(): + if not has_work_or_exceptions(calendar): + continue calendar["ifc"] = ifcopenshell.api.run("sequence.add_work_calendar", self.file, name=calendar["Name"]) self.process_working_week(calendar["StandardWorkWeek"], calendar["ifc"]) self.process_exceptions(calendar["HolidayOrExceptions"], calendar["ifc"]) @@ -358,57 +366,60 @@ class MSP2Ifc: } def process_exceptions(self, exceptions, calendar): - if exceptions: - for year, year_data in exceptions.items(): - for month, month_data in year_data.items(): - if month_data["WorkTime"]: - self.process_work_time_exceptions(year, month, month_data, calendar) - - def process_work_time_exceptions(self, year, month, month_data, calendar): - for day in month_data["WorkTime"]: - if day["ifc"]: - continue - - day["ifc"] = ifcopenshell.api.run( - "sequence.add_work_time", self.file, work_calendar=calendar, time_type="ExceptionTimes" - ) - - day_component = [day["Day"]] - for day2 in month_data["WorkTime"]: - if day["Day"] == day2["Day"]: - continue - if day["WorkingTimes"] == day2["WorkingTimes"]: - day_component.append(day2["Day"]) - # Don't process the next day, as we can group it - day2["ifc"] = day["ifc"] + for exception in exceptions or []: + self.process_exception(exception, calendar) + def process_exception(self, exception, calendar): + if exception["ifc"] or not exception["FromDate"]: + return + exception["ifc"] = ifcopenshell.api.run( + "sequence.add_work_time", self.file, work_calendar=calendar, time_type="ExceptionTimes" + ) + ifcopenshell.api.run( + "sequence.edit_work_time", + self.file, + work_time=exception["ifc"], + attributes={ + "Name": exception["Name"], + "Start": ifcopenshell.util.date.datetime2ifc(exception["FromDate"], "IfcDate"), + "Finish": ifcopenshell.util.date.datetime2ifc(exception["ToDate"], "IfcDate"), + }, + ) + # BIG assumptions due to missing types enumeration in docs https://learn.microsoft.com/en-us/office-project/xml-data-interchange/exception-element?view=project-client-2016 + recurrence_type = None + if exception["Type"] == "1": + recurrence_type = "DAILY" + attributes = { + "Occurrences": int(exception["Occurrences"]) if exception["Occurrences"] else None, + } + elif exception["Type"] == "2": + recurrence_type = "YEARLY_BY_DAY_OF_MONTH" + month_component = [int(exception["Month"]) + 1] if exception["Month"] else None + day_component = [int(exception["MonthDay"])] if exception["MonthDay"] else None + if month_component is None and (exception["FromDate"].date().day == exception["ToDate"].date().day): + month_component = [exception["FromDate"].date().month] + day_component = [exception["FromDate"].date().day] + attributes = { + "MonthComponent": month_component, + "DayComponent": day_component, + "Occurrences": int(exception["Occurrences"]) if exception["Occurrences"] else None, + } + else: + return + recurrence = ifcopenshell.api.run( + "sequence.assign_recurrence_pattern", + self.file, + parent=exception["ifc"], + recurrence_type=recurrence_type, + ) + ifcopenshell.api.run( + "sequence.edit_recurrence_pattern", self.file, recurrence_pattern=recurrence, attributes=attributes + ) + for work_time in exception["WorkingTimes"] or []: ifcopenshell.api.run( - "sequence.edit_work_time", - self.file, - work_time=day["ifc"], - attributes={ - "Name": "{}-{}-{}".format(year, month, ", ".join([str(d) for d in day_component])), - "Start": ifcopenshell.util.date.datetime2ifc(day["FromDate"], "IfcDate"), - "Finish": ifcopenshell.util.date.datetime2ifc(day["ToDate"], "IfcDate"), - }, - ) - recurrence = ifcopenshell.api.run( - "sequence.assign_recurrence_pattern", - self.file, - parent=day["ifc"], - recurrence_type="YEARLY_BY_DAY_OF_MONTH", - ) - ifcopenshell.api.run( - "sequence.edit_recurrence_pattern", + "sequence.add_time_period", self.file, recurrence_pattern=recurrence, - attributes={"DayComponent": day_component, "MonthComponent": [month]}, + start_time=work_time["Start"], + end_time=work_time["Finish"], ) - for work_time in day["WorkingTimes"]: - ifcopenshell.api.run( - "sequence.add_time_period", - self.file, - recurrence_pattern=recurrence, - start_time=work_time["Start"], - end_time=work_time["Finish"], - ) From 8e375dcf73df9b0b6a3ea10553fb5b0108ae1326 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 18 Sep 2023 10:04:02 +0500 Subject: [PATCH 095/190] Save last active context editing doors/windows, solves #3735 previously - https://imgur.com/a/gri6zMk now - https://imgur.com/a/GIwZP6c --- .../blenderbim/bim/module/model/door.py | 39 ++++++++++++++++--- .../blenderbim/bim/module/model/window.py | 22 +++++++++-- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/model/door.py b/src/blenderbim/blenderbim/bim/module/model/door.py index e470b3e3ac..d51ba058a1 100644 --- a/src/blenderbim/blenderbim/bim/module/model/door.py +++ b/src/blenderbim/blenderbim/bim/module/model/door.py @@ -68,6 +68,14 @@ def update_door_modifier_representation(context, obj): }, } + def get_active_representation_context(obj): + active_representation = tool.Geometry.get_active_representation(obj) + if active_representation: + return active_representation.ContextOfItems + return ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW") + + previously_active_context = get_active_representation_context(obj) + # ELEVATION_VIEW representation profile = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Profile", "ELEVATION_VIEW") if profile: @@ -78,6 +86,7 @@ def update_door_modifier_representation(context, obj): tool.Model.replace_object_ifc_representation(profile, obj, elevation_representation) # MODEL_VIEW representation + # (Model/Body defined only BEFORE Plan/Body to prevent #2744) body = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW") representation_data["context"] = body model_representation = ifcopenshell.api.run("geometry.add_door_representation", ifc_file, **representation_data) @@ -113,14 +122,34 @@ def update_door_modifier_representation(context, obj): ) tool.Model.replace_object_ifc_representation(plan_annotation, obj, plan_representation) - if plan_body or plan_annotation: - # adding switch representation at the end instead of changing order of representations - # to prevent #2744 - core.switch_representation( + # adding switch representation at the end instead of changing order of representations + # to prevent #2744 + if get_active_representation_context(obj) != previously_active_context: + previously_active_representation = ifcopenshell.util.representation.get_representation( + element, + previously_active_context.ContextType, + previously_active_context.ContextIdentifier, + previously_active_context.TargetView, + ) + + if not previously_active_representation: + # we assume there is no representation because it was + # Plan/Annotation/PLAN_VIEW + previously_active_context = ifcopenshell.util.representation.get_context( + ifc_file, "Plan", "Body", "PLAN_VIEW" + ) + previously_active_representation = ifcopenshell.util.representation.get_representation( + element, + previously_active_context.ContextType, + previously_active_context.ContextIdentifier, + previously_active_context.TargetView, + ) + + blenderbim.core.geometry.switch_representation( tool.Ifc, tool.Geometry, obj=obj, - representation=model_representation, + representation=previously_active_representation, should_reload=True, is_global=True, should_sync_changes_first=True, diff --git a/src/blenderbim/blenderbim/bim/module/model/window.py b/src/blenderbim/blenderbim/bim/module/model/window.py index 24499ee6b6..fe934693ac 100644 --- a/src/blenderbim/blenderbim/bim/module/model/window.py +++ b/src/blenderbim/blenderbim/bim/module/model/window.py @@ -116,6 +116,14 @@ def update_window_modifier_representation(context, obj): } representation_data["panel_properties"].append(panel_data) + def get_active_representation_context(obj): + active_representation = tool.Geometry.get_active_representation(obj) + if active_representation: + return active_representation.ContextOfItems + return ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW") + + previously_active_context = get_active_representation_context(obj) + # ELEVATION_VIEW representation profile = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Profile", "ELEVATION_VIEW") if profile: @@ -126,6 +134,7 @@ def update_window_modifier_representation(context, obj): tool.Model.replace_object_ifc_representation(profile, obj, elevation_representation) # MODEL_VIEW representation + # (Model/Body defined only BEFORE Plan/Body to prevent #2744) body = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW") representation_data["context"] = body model_representation = ifcopenshell.api.run("geometry.add_window_representation", ifc_file, **representation_data) @@ -140,13 +149,20 @@ def update_window_modifier_representation(context, obj): ) tool.Model.replace_object_ifc_representation(plan, obj, plan_representation) - # adding switch representation at the end instead of changing order of representations - # to prevent #2744 + # adding switch representation at the end instead of changing order of representations + # to prevent #2744 + if get_active_representation_context(obj) != previously_active_context: + previously_active_representation = ifcopenshell.util.representation.get_representation( + element, + previously_active_context.ContextType, + previously_active_context.ContextIdentifier, + previously_active_context.TargetView, + ) blenderbim.core.geometry.switch_representation( tool.Ifc, tool.Geometry, obj=obj, - representation=model_representation, + representation=previously_active_representation, should_reload=True, is_global=True, should_sync_changes_first=True, From d67eae55df06510d7e663248b446b1039a24cbf8 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 18 Sep 2023 10:35:47 +0500 Subject: [PATCH 096/190] Clean up orphaned meshes - delete_ifc_project - bim.add_constr_type_instance - adding transitions and bends - add profile / slab / wall --- src/blenderbim/blenderbim/bim/module/model/mep.py | 2 ++ src/blenderbim/blenderbim/bim/module/model/product.py | 5 +++++ src/blenderbim/blenderbim/bim/module/model/profile.py | 1 + src/blenderbim/blenderbim/bim/module/model/slab.py | 1 + src/blenderbim/blenderbim/bim/module/model/wall.py | 1 + src/blenderbim/blenderbim/tool/geometry.py | 6 ++++++ src/blenderbim/blenderbim/tool/ifc.py | 9 +++++++++ 7 files changed, 25 insertions(+) diff --git a/src/blenderbim/blenderbim/bim/module/model/mep.py b/src/blenderbim/blenderbim/bim/module/model/mep.py index 4a8669d186..9d03590f6a 100644 --- a/src/blenderbim/blenderbim/bim/module/model/mep.py +++ b/src/blenderbim/blenderbim/bim/module/model/mep.py @@ -847,6 +847,7 @@ class MEPAddTransition(bpy.types.Operator, tool.Ifc.Operator): ) body = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW") tool.Model.replace_object_ifc_representation(body, obj, rep) + tool.Blender.remove_data_block(mesh) pset = ifcopenshell.api.run("pset.add_pset", tool.Ifc.get(), product=transition_type, name="BBIM_Fitting") ifcopenshell.api.run( "pset.edit_pset", @@ -1188,6 +1189,7 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator): ) body = ifcopenshell.util.representation.get_context(ifc_file, "Model", "Body", "MODEL_VIEW") tool.Model.replace_object_ifc_representation(body, obj, rep) + tool.Blender.remove_data_block(mesh) pset = ifcopenshell.api.run("pset.add_pset", tool.Ifc.get(), product=bend_type, name="BBIM_Fitting") ifcopenshell.api.run( "pset.edit_pset", diff --git a/src/blenderbim/blenderbim/bim/module/model/product.py b/src/blenderbim/blenderbim/bim/module/model/product.py index 0d56fc63e4..8d67810409 100644 --- a/src/blenderbim/blenderbim/bim/module/model/product.py +++ b/src/blenderbim/blenderbim/bim/module/model/product.py @@ -185,8 +185,13 @@ class AddConstrTypeInstance(bpy.types.Operator): collection_obj = collection.BIMCollectionProperties.obj bpy.ops.bim.assign_class(obj=obj.name, ifc_class=instance_class) + tool.Blender.remove_data_block(mesh) # Remove "Instance" mesh + + mesh_data = obj.data element = tool.Ifc.get_entity(obj) blenderbim.core.type.assign_type(tool.Ifc, tool.Type, element=element, type=relating_type) + if obj.data != mesh_data: # remove orphaned mesh from "bim.assign_class" + tool.Blender.remove_data_block(mesh_data) # Update required as core.type.assign_type may change obj.data # TODO: This is inefficient. It literally creates a mesh, then potentially removes it. diff --git a/src/blenderbim/blenderbim/bim/module/model/profile.py b/src/blenderbim/blenderbim/bim/module/model/profile.py index 8bac8d9199..d2576f1185 100644 --- a/src/blenderbim/blenderbim/bim/module/model/profile.py +++ b/src/blenderbim/blenderbim/bim/module/model/profile.py @@ -132,6 +132,7 @@ class DumbProfileGenerator: is_global=True, should_sync_changes_first=False, ) + tool.Blender.remove_data_block(mesh) pset = ifcopenshell.api.run("pset.add_pset", self.file, product=element, name="EPset_Parametric") ifcopenshell.api.run("pset.edit_pset", self.file, pset=pset, properties={"Engine": "BlenderBIM.DumbProfile"}) diff --git a/src/blenderbim/blenderbim/bim/module/model/slab.py b/src/blenderbim/blenderbim/bim/module/model/slab.py index f349aec433..b3cbe091b4 100644 --- a/src/blenderbim/blenderbim/bim/module/model/slab.py +++ b/src/blenderbim/blenderbim/bim/module/model/slab.py @@ -196,6 +196,7 @@ class DumbSlabGenerator: is_global=True, should_sync_changes_first=False, ) + tool.Blender.remove_data_block(mesh) if self.footprint_context: extrusion = tool.Model.get_extrusion(representation) diff --git a/src/blenderbim/blenderbim/bim/module/model/wall.py b/src/blenderbim/blenderbim/bim/module/model/wall.py index e2aa7e6a56..06a7af965e 100644 --- a/src/blenderbim/blenderbim/bim/module/model/wall.py +++ b/src/blenderbim/blenderbim/bim/module/model/wall.py @@ -637,6 +637,7 @@ class DumbWallGenerator: is_global=True, should_sync_changes_first=False, ) + tool.Blender.remove_data_block(mesh) pset = ifcopenshell.api.run("pset.add_pset", self.file, product=element, name="EPset_Parametric") ifcopenshell.api.run("pset.edit_pset", self.file, pset=pset, properties={"Engine": "BlenderBIM.DumbLayer2"}) obj.select_set(True) diff --git a/src/blenderbim/blenderbim/tool/geometry.py b/src/blenderbim/blenderbim/tool/geometry.py index b1ce914f4c..8e624d61f0 100644 --- a/src/blenderbim/blenderbim/tool/geometry.py +++ b/src/blenderbim/blenderbim/tool/geometry.py @@ -115,6 +115,12 @@ class Geometry(blenderbim.core.tool.Geometry): for port in ifcopenshell.util.system.get_ports(element): blenderbim.core.system.remove_port(tool.Ifc, tool.System, port=port) ifcopenshell.api.run("root.remove_product", tool.Ifc.get(), product=element) + + if isinstance(obj.data, bpy.types.Mesh) and not tool.Ifc.get_entity_by_id( + obj.data.BIMMeshProperties.ifc_definition_id + ): + tool.Blender.remove_data_block(obj.data) + if is_spatial: blenderbim.core.spatial.load_container_manager(tool.Spatial) try: diff --git a/src/blenderbim/blenderbim/tool/ifc.py b/src/blenderbim/blenderbim/tool/ifc.py index 9c975e92d8..a655b5ce1a 100644 --- a/src/blenderbim/blenderbim/tool/ifc.py +++ b/src/blenderbim/blenderbim/tool/ifc.py @@ -86,6 +86,15 @@ class Ifc(blenderbim.core.tool.Ifc): except: pass + @classmethod + def get_entity_by_id(cls, entity_id): + """useful to check whether entity_id is still exists in IFC""" + ifc_file = tool.Ifc.get() + try: + return ifc_file.by_id(entity_id) + except RuntimeError: + return None + @classmethod def get_object(cls, element): return IfcStore.get_element(element.id()) From 7b28bc9c682755effa884c157d42236cce0c24b5 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 18 Sep 2023 11:30:36 +0500 Subject: [PATCH 097/190] fixed bug creating transition shapes it was accidentally assigning extrusion vector instead of an offset --- src/ifcopenshell-python/ifcopenshell/util/shape_builder.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py index 43822dfc6c..6941e37b2f 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py +++ b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py @@ -1075,7 +1075,7 @@ class ShapeBuilder: transition_items.append(self.extrude_face_set(first_profile_points, start_length, end_cap=False)) transition_items.append( - self.extrude_face_set(second_profile_points, end_length, end_extrusion_offset, start_cap=False) + self.extrude_face_set(second_profile_points, end_length, offset=end_extrusion_offset, start_cap=False) ) first_profile_points = [p + start_offset for p in first_profile_points] @@ -1105,7 +1105,7 @@ class ShapeBuilder: transition_items.append(self.extrude_face_set(start_points, start_length, end_cap=False)) transition_items.append( - self.extrude_face_set(end_points, end_length, end_extrusion_offset, start_cap=False) + self.extrude_face_set(end_points, end_length, offset=end_extrusion_offset, start_cap=False) ) # offset verts From 19345ff5386f60fb6b3042e0611ea12492b7fd0a Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 18 Sep 2023 12:07:19 +0500 Subject: [PATCH 098/190] Remove orphaned IfcPropertyEnumeration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Noticed a couple hundreds of orphaned IfcPropertyEnumeration in my .ifc like below 😁 IFCPROPERTYENUMERATION('Status',(IFCLABEL('NEW'),IFCLABEL('EXISTING'),IFCLABEL('DEMOLISH'),IFCLABEL('TEMPORARY'),IFCLABEL('OTHER'),IFCLABEL('NOTKNOWN'),IFCLABEL('UNSET')),$); --- .../ifcopenshell/api/pset/remove_pset.py | 9 +++++-- .../test/api/pset/test_remove_pset.py | 24 +++++++++++++++++++ 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/api/pset/remove_pset.py b/src/ifcopenshell-python/ifcopenshell/api/pset/remove_pset.py index 322fb46356..44aa512ab3 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/pset/remove_pset.py +++ b/src/ifcopenshell-python/ifcopenshell/api/pset/remove_pset.py @@ -65,8 +65,13 @@ class Usecase: elif self.settings["pset"].is_a() in ("IfcMaterialProperties", "IfcProfileProperties"): properties = self.settings["pset"].Properties or [] for prop in properties: - if self.file.get_total_inverses(prop) == 1: - self.file.remove(prop) + if self.file.get_total_inverses(prop) != 1: + continue + if prop.is_a("IfcPropertyEnumeratedValue"): + enumeration = prop.EnumerationReference + if self.file.get_total_inverses(enumeration) == 1: + self.file.remove(enumeration) + self.file.remove(prop) self.file.remove(self.settings["pset"]) for element in to_purge: self.file.remove(element) diff --git a/src/ifcopenshell-python/test/api/pset/test_remove_pset.py b/src/ifcopenshell-python/test/api/pset/test_remove_pset.py index 3ce8d0127e..1685f158e8 100644 --- a/src/ifcopenshell-python/test/api/pset/test_remove_pset.py +++ b/src/ifcopenshell-python/test/api/pset/test_remove_pset.py @@ -83,3 +83,27 @@ class TestRemovePset(test.bootstrap.IFC4): pset2.HasProperties = pset.HasProperties ifcopenshell.api.run("pset.remove_pset", self.file, product=element, pset=pset) assert pset2.HasProperties + + def test_removing_a_pset_with_enumeration(self): + element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") + pset = ifcopenshell.api.run("pset.add_pset", self.file, product=element, name="Pset_WallCommon") + ifcopenshell.api.run("pset.edit_pset", self.file, pset=pset, properties={"Status": ["NEW"]}) + ifcopenshell.api.run("pset.remove_pset", self.file, product=element, pset=pset) + assert len(self.file.by_type("IfcPropertyEnumeration")) == 0 + + def test_removing_a_pset_with_shared_enumeration(self): + element = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") + pset = ifcopenshell.api.run("pset.add_pset", self.file, product=element, name="Pset_WallCommon") + ifcopenshell.api.run("pset.edit_pset", self.file, pset=pset, properties={"Status": ["NEW"]}) + + element2 = ifcopenshell.api.run("root.create_entity", self.file, ifc_class="IfcWall") + pset2 = ifcopenshell.api.run("pset.add_pset", self.file, product=element2, name="Pset_WallCommon") + ifcopenshell.api.run("pset.edit_pset", self.file, pset=pset2, properties={"Status": ["NEW"]}) + + enumeration1 = pset.HasProperties[0].EnumerationReference + enumeration2 = pset2.HasProperties[0].EnumerationReference + pset2.HasProperties[0].EnumerationReference = enumeration1 + self.file.remove(enumeration2) + + ifcopenshell.api.run("pset.remove_pset", self.file, product=element, pset=pset) + assert len(self.file.by_type("IfcPropertyEnumeration")) == 1 From 0f1d675bef138fc94bce88a5d44b563216276a2b Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 18 Sep 2023 15:17:38 +0500 Subject: [PATCH 099/190] Associate IFC files with BlenderBIM on Windows Buttons located in addon preferences - https://i.imgur.com/Unp4P9L.png Just waiting for this to break somehow... It's not completely automatic now and it doesn't create assign special icon for IFC files yet. --- src/blenderbim/blenderbim/bim/operator.py | 55 +++++++++++++++++-- .../libs/desktop/windows_bbim_association.ps1 | 16 ++++++ 2 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 src/blenderbim/blenderbim/libs/desktop/windows_bbim_association.ps1 diff --git a/src/blenderbim/blenderbim/bim/operator.py b/src/blenderbim/blenderbim/bim/operator.py index 9f36116ce2..a8a675ef91 100644 --- a/src/blenderbim/blenderbim/bim/operator.py +++ b/src/blenderbim/blenderbim/bim/operator.py @@ -42,7 +42,7 @@ from math import radians class SetTab(bpy.types.Operator): bl_idname = "bim.set_tab" - # NOTE: bl_label is set to empty string intentionally + # NOTE: bl_label is set to empty string intentionally # to avoid showing the operator's name in the tooltips, see #3704 bl_label = "" bl_options = {"REGISTER", "UNDO", "INTERNAL"} @@ -197,21 +197,52 @@ class FileAssociate(bpy.types.Operator): @classmethod def poll(cls, context): - if platform.system() == "Linux": + if platform.system() in ("Linux", "Windows"): return True - cls.poll_message_set("Option available only on Linux.") - # TODO Windows and Darwin + cls.poll_message_set("Option available only on Windows & Linux.") + # TODO Darwin # https://stackoverflow.com/questions/1082889/how-to-change-filetype-association-in-the-registry return False + def draw(self, context): + # NOTE: really weird thing on windows that typing this command in cmd works + # when even if you create .bat with the command below and run it as administrator it won't + # Haven't found a workaround yet to automate process completely. + command = "ASSOC .IFC=BLENDERBIM" + self.layout.label(text="On the next step to create file association ") + self.layout.label(text="the system console will be opened ") + self.layout.label(text=f"and you will be asked to type command") + self.layout.label(text=f'"{command}"') + self.layout.label(text="to create an association.") + + def invoke(self, context, event): + if platform.system() == "Windows": + return context.window_manager.invoke_props_dialog(self) + else: + return self.execute(context) + def execute(self, context): src_dir = os.path.join(os.path.dirname(__file__), "../libs/desktop") binary_path = bpy.app.binary_path if platform.system() == "Linux": destdir = os.path.join(os.environ["HOME"], ".local") self.install_desktop_linux(src_dir=src_dir, destdir=destdir, binary_path=binary_path) + elif platform.system() == "Windows": + self.install_desktop_windows(src_dir, binary_path) + self.report({"INFO"}, "Associations established.") return {"FINISHED"} + def install_desktop_windows(self, src_dir, binary_path): + # very important to clear this regitstry key before creating new association + # tried to do the regitsry change from powershell/cmd - but even admin rights are not enough + # this is why we're using .reg + reg_change_path = os.path.join(src_dir, "windows_bbim_association.reg") + subprocess.run(["cmd", "/c", reg_change_path]) + + ps_script_path = os.path.join(src_dir, "windows_bbim_association.ps1") + # NOTE: call powershell with RunAs to get admin rights from user + subprocess.run(["powershell", "-file", ps_script_path, binary_path], shell=True) + def install_desktop_linux(self, src_dir=None, destdir="/tmp", binary_path="/usr/bin/blender"): """Creates linux file assocations and launcher icon""" @@ -272,17 +303,29 @@ class FileUnassociate(bpy.types.Operator): @classmethod def poll(cls, context): - if platform.system() == "Linux": + if platform.system() in ("Linux", "Windows"): return True - cls.poll_message_set("Option available only on Linux.") + cls.poll_message_set("Option available only on Windows & Linux.") return False def execute(self, context): if platform.system() == "Linux": destdir = os.path.join(os.environ["HOME"], ".local") self.uninstall_desktop_linux(destdir=destdir) + elif platform.system() == "Windows": + self.uninstall_desktop_windows() return {"FINISHED"} + def uninstall_desktop_windows(self): + # NOTE: call powershell with RunAs to get admin rights from user + cmd = [ + "powershell", + "-Command", + "Start-Process -Verb RunAs -Wait cmd -ArgumentList '/c reg delete HKCR\\BLENDERBIM /f'", + ] + subprocess.run(cmd, check=True) + self.report({"INFO"}, "Association removed.") + def uninstall_desktop_linux(self, destdir="/tmp"): """Removes linux file assocations and launcher icon""" for rel_path in ( diff --git a/src/blenderbim/blenderbim/libs/desktop/windows_bbim_association.ps1 b/src/blenderbim/blenderbim/libs/desktop/windows_bbim_association.ps1 new file mode 100644 index 0000000000..11926a170a --- /dev/null +++ b/src/blenderbim/blenderbim/libs/desktop/windows_bbim_association.ps1 @@ -0,0 +1,16 @@ +param( + [string]$BlenderPath = "BLENDER_EXE" +) + +Start-Process cmd -ArgumentList ` + "/k ", ` + "ASSOC .IFC=", ` + "&", ` + "FTYPE BLENDERBIM=""$BlenderPath"" --python-expr ""import bpy; bpy.ops.bim.load_project(filepath=r'%1')""", + "&", ` + "echo. & echo. & echo To create an association between .IFC files and BlenderBIM", ` + "&", ` + "echo type the command below & echo.", ` + "&", ` + "echo ASSOC .IFC=BLENDERBIM & echo." ` +-Verb RunAs \ No newline at end of file From 27b71a9d074e6e91a4a3b184e9e779c130f7a9de Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 18 Sep 2023 23:14:52 +1000 Subject: [PATCH 100/190] Fix #3752. Accommodate situations where layouts don't match IFC. --- .../blenderbim/bim/module/drawing/sheeter.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/drawing/sheeter.py b/src/blenderbim/blenderbim/bim/module/drawing/sheeter.py index f452bcc158..ee0e5506e8 100644 --- a/src/blenderbim/blenderbim/bim/module/drawing/sheeter.py +++ b/src/blenderbim/blenderbim/bim/module/drawing/sheeter.py @@ -345,8 +345,12 @@ class SheetBuilder: def build_drawings(self, root, sheet): for view in root.findall('{http://www.w3.org/2000/svg}g[@data-type="drawing"]'): drawing_id = int(view.attrib["data-id"]) - reference = tool.Ifc.get().by_id(int(view.attrib["data-id"])) - drawing = tool.Ifc.get().by_id(view.attrib["data-drawing"]) + try: + reference = tool.Ifc.get().by_id(int(view.attrib["data-id"])) + drawing = tool.Ifc.get().by_id(view.attrib["data-drawing"]) + except: + # Perhaps the SVG has outdated content or is edited externally which we cannot control. + continue images = view.findall("{http://www.w3.org/2000/svg}image") @@ -387,8 +391,12 @@ class SheetBuilder: def build_schedules(self, root, sheet): for view in root.findall('{http://www.w3.org/2000/svg}g[@data-type="schedule"]'): - reference = tool.Ifc.get().by_id(int(view.attrib["data-id"])) - schedule = tool.Ifc.get().by_id(int(view.attrib["data-schedule"])) + try: + reference = tool.Ifc.get().by_id(int(view.attrib["data-id"])) + schedule = tool.Ifc.get().by_id(int(view.attrib["data-schedule"])) + except: + # Perhaps the SVG has outdated content or is edited externally which we cannot control. + continue images = view.findall("{http://www.w3.org/2000/svg}image") From 9240518066d3fec1b6d5c4cafb7fc12348918816 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 18 Sep 2023 23:41:13 +1000 Subject: [PATCH 101/190] Accommodate potential manual deletion of EPset drawing. --- src/blenderbim/blenderbim/tool/drawing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blenderbim/blenderbim/tool/drawing.py b/src/blenderbim/blenderbim/tool/drawing.py index 516f5c879a..93fb1218fe 100644 --- a/src/blenderbim/blenderbim/tool/drawing.py +++ b/src/blenderbim/blenderbim/tool/drawing.py @@ -445,7 +445,7 @@ class Drawing(blenderbim.core.tool.Drawing): @classmethod def get_drawing_target_view(cls, drawing): - return ifcopenshell.util.element.get_psets(drawing)["EPset_Drawing"].get("TargetView", "MODEL_VIEW") + return ifcopenshell.util.element.get_psets(drawing).get("EPset_Drawing", {}).get("TargetView", "MODEL_VIEW") @classmethod def get_group_elements(cls, group): From 438f35fe88d5848f1af47171083f1465164a4721 Mon Sep 17 00:00:00 2001 From: "Sigma Dimensions (Yass)" <79010126+myoualid@users.noreply.github.com> Date: Mon, 18 Sep 2023 20:20:23 +0100 Subject: [PATCH 102/190] purge cost schedules --- .../ifcopenshell/api/cost/remove_cost_schedule.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/ifcopenshell-python/ifcopenshell/api/cost/remove_cost_schedule.py b/src/ifcopenshell-python/ifcopenshell/api/cost/remove_cost_schedule.py index 33414bc486..081f30a43d 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/cost/remove_cost_schedule.py +++ b/src/ifcopenshell-python/ifcopenshell/api/cost/remove_cost_schedule.py @@ -16,6 +16,8 @@ # You should have received a copy of the GNU Lesser General Public License # along with IfcOpenShell. If not, see . +import ifcopenshell.api + class Usecase: def __init__(self, file, cost_schedule=None): @@ -42,4 +44,13 @@ class Usecase: def execute(self): # TODO: do a deep purge + for inverse in self.file.get_inverse(self.settings["cost_schedule"]): + if inverse.is_a("IfcRelAssignsToControl"): + [ + ifcopenshell.api.run( + "sequence.remove_cost_item", self.file, task=related_object + ) + for related_object in inverse.RelatedObjects + if related_object.is_a("IfcCostItem") + ] self.file.remove(self.settings["cost_schedule"]) From 39b1a65dc54b2811f818b83e5c7003d1daaa4ca7 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 19 Sep 2023 09:03:41 +1000 Subject: [PATCH 103/190] IfcTester now includes percentages per requirement --- src/ifctester/ifctester/reporter.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/ifctester/ifctester/reporter.py b/src/ifctester/ifctester/reporter.py index c982e1ec9b..82b50910ea 100644 --- a/src/ifctester/ifctester/reporter.py +++ b/src/ifctester/ifctester/reporter.py @@ -211,16 +211,19 @@ class Json(Reporter): requirements = [] for requirement in specification.requirements: total_fail = len(requirement.failed_entities) + total_pass = total_applicable - total_fail + percent_pass = math.floor((total_pass / total_applicable) * 100) if total_applicable else "N/A" total_checks += total_applicable - total_checks_pass += total_applicable - total_fail + total_checks_pass += total_pass requirements.append( { "description": requirement.to_string("requirement"), "status": requirement.status, "failed_entities": self.report_failed_entities(requirement), "total_applicable": total_applicable, - "total_pass": total_applicable - total_fail, + "total_pass": total_pass, "total_fail": total_fail, + "percent_pass": percent_pass, } ) total_applicable_pass = total_applicable - len(specification.failed_entities) From 56040951f183e750aba1525307191644c622a876 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Mon, 18 Sep 2023 20:21:19 -0500 Subject: [PATCH 104/190] Have bim.select_aggregate make the aggregate empty the active object, as well. --- src/blenderbim/blenderbim/bim/module/aggregate/operator.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/blenderbim/blenderbim/bim/module/aggregate/operator.py b/src/blenderbim/blenderbim/bim/module/aggregate/operator.py index d09179f6b1..bee67a413e 100644 --- a/src/blenderbim/blenderbim/bim/module/aggregate/operator.py +++ b/src/blenderbim/blenderbim/bim/module/aggregate/operator.py @@ -202,6 +202,7 @@ class BIM_OT_select_aggregate(bpy.types.Operator): aggregate_obj = tool.Ifc.get_object(aggregate) if aggregate_obj in context.selectable_objects: aggregate_obj.select_set(True) + bpy.context.view_layer.objects.active = aggregate_obj return {"FINISHED"} From c40cc3f7406400bb70da58524f1ad7a6c0902596 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Tue, 19 Sep 2023 11:47:43 +0500 Subject: [PATCH 105/190] select array parent / all array objects to work without parent guid argument now it's getting parent guid from the currently active object, not from provided guid argument - so now it's possible to add those operators to quick favorites --- .../blenderbim/bim/module/model/array.py | 45 +++++++++++++++---- .../blenderbim/bim/module/model/ui.py | 6 +-- 2 files changed, 38 insertions(+), 13 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/model/array.py b/src/blenderbim/blenderbim/bim/module/model/array.py index 5b1606154b..087bf1e436 100644 --- a/src/blenderbim/blenderbim/bim/module/model/array.py +++ b/src/blenderbim/blenderbim/bim/module/model/array.py @@ -206,15 +206,29 @@ class SelectArrayParent(bpy.types.Operator): bl_idname = "bim.select_array_parent" bl_label = "Select Array Parent" bl_options = {"REGISTER", "UNDO"} - parent: bpy.props.StringProperty(description="Parent Element GUID") + + @classmethod + def poll(cls, context): + if not context.active_object: + cls.poll_message_set("No active object selected") + return False + return True def execute(self, context): - try: - element = tool.Ifc.get().by_guid(self.parent) - except: - self.report({"ERROR"}, f"Couldn't find array parent by guid '{self.parent}'") + object = context.active_object + element = tool.Ifc.get_entity(object) + array_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") + if not array_pset: + self.report({"ERROR"}, f"Object is not part of an array.") return {"CANCELLED"} - obj = tool.Ifc.get_object(element) + + try: + parent_element = tool.Ifc.get().by_guid(array_pset["Parent"]) + except: + self.report({"ERROR"}, f"Couldn't find array parent by guid '{array_pset['Parent']}'") + return {"CANCELLED"} + + obj = tool.Ifc.get_object(parent_element) if obj: tool.Blender.select_and_activate_single_object(context, active_object=obj) return {"FINISHED"} @@ -224,13 +238,26 @@ class SelectAllArrayObjects(bpy.types.Operator): bl_idname = "bim.select_all_array_objects" bl_label = "Select All Array Objects" bl_options = {"REGISTER", "UNDO"} - parent: bpy.props.StringProperty(description="Parent Element GUID") + + @classmethod + def poll(cls, context): + if not context.active_object: + cls.poll_message_set("No active object selected") + return False + return True def execute(self, context): + object = context.active_object + element = tool.Ifc.get_entity(object) + array_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") + if not array_pset: + self.report({"ERROR"}, f"Object is not part of an array.") + return {"CANCELLED"} + try: - parent_element = tool.Ifc.get().by_guid(self.parent) + parent_element = tool.Ifc.get().by_guid(array_pset["Parent"]) except RuntimeError: - self.report({"ERROR"}, f"Couldn't find array parent by guid '{self.parent}'") + self.report({"ERROR"}, f"Couldn't find array parent by guid '{array_pset['Parent']}'") return {"CANCELLED"} array_objects = tool.Blender.Modifier.Array.get_all_objects(parent_element) diff --git a/src/blenderbim/blenderbim/bim/module/model/ui.py b/src/blenderbim/blenderbim/bim/module/model/ui.py index 6a0b8005f8..07cd3ec4bd 100644 --- a/src/blenderbim/blenderbim/bim/module/model/ui.py +++ b/src/blenderbim/blenderbim/bim/module/model/ui.py @@ -188,10 +188,8 @@ class BIM_PT_array(bpy.types.Panel): if ArrayData.data["parameters"]: row = self.layout.row(align=True) row.label(text=ArrayData.data["parameters"]["parent_name"], icon="CON_CHILDOF") - op = row.operator("bim.select_array_parent", icon="OBJECT_DATA", text="") - op.parent = ArrayData.data["parameters"]["Parent"] - op = row.operator("bim.select_all_array_objects", icon="RESTRICT_SELECT_OFF", text="") - op.parent = ArrayData.data["parameters"]["Parent"] + row.operator("bim.select_array_parent", icon="OBJECT_DATA", text="") + row.operator("bim.select_all_array_objects", icon="RESTRICT_SELECT_OFF", text="") if ArrayData.data["parameters"]["data_dict"]: row.operator("bim.add_array", icon="ADD", text="") From 812d8508f4bb402cf4da90f0e8bdee77c950c118 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 19 Sep 2023 18:08:58 +1000 Subject: [PATCH 106/190] Implement classification searching in bSDD library --- src/bsdd/bsdd.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/bsdd/bsdd.py b/src/bsdd/bsdd.py index 0e0f704cc5..637e36912c 100644 --- a/src/bsdd/bsdd.py +++ b/src/bsdd/bsdd.py @@ -52,7 +52,7 @@ class Client: headers = {} if is_auth_required: headers = {"Authorization": "Bearer " + self.get_access_token()} - return requests.get(f"{self.baseurl}{endpoint}", headers=headers, params=params or None).json() + return requests.get(f"{self.baseurl}{endpoint}", timeout=10, headers=headers, params=params or None).json() def post(self): pass # TODO @@ -121,6 +121,20 @@ class Client: }, ) + def ClassificationSearchOpen(self, SearchText, version="v1", DomainNamespaceUris=None, RelatedIfcEntities=None): + if DomainNamespaceUris is None: + DomainNamespaceUris = [] + if RelatedIfcEntities is None: + RelatedIfcEntities = [] + return self.get( + f"api/ClassificationSearchOpen/{version}", + { + "SearchText": SearchText, + "DomainNamespaceUris": DomainNamespaceUris, + "RelatedIfcEntities": RelatedIfcEntities, + }, + ) + def Country(self, version="v1"): return self.get(f"api/Country/{version}") From a5a958d6e22cb07040c83c4101992d04aef936be Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 19 Sep 2023 18:13:38 +1000 Subject: [PATCH 107/190] Basic integration of bSDD with BlenderBIM Add-on: you can now assign classification references from the bSDD and set the active bSDD domain. --- src/blenderbim/Makefile | 2 + src/blenderbim/blenderbim/bim/__init__.py | 1 + .../blenderbim/bim/module/bsdd/__init__.py | 40 +++++++ .../blenderbim/bim/module/bsdd/operator.py | 85 +++++++++++++++ .../blenderbim/bim/module/bsdd/prop.py | 59 ++++++++++ .../blenderbim/bim/module/bsdd/ui.py | 71 ++++++++++++ .../bim/module/classification/__init__.py | 2 + .../bim/module/classification/operator.py | 69 ++++++++++++ .../bim/module/classification/prop.py | 9 ++ .../bim/module/classification/ui.py | 101 ++++++++++++++++-- 10 files changed, 431 insertions(+), 8 deletions(-) create mode 100644 src/blenderbim/blenderbim/bim/module/bsdd/__init__.py create mode 100644 src/blenderbim/blenderbim/bim/module/bsdd/operator.py create mode 100644 src/blenderbim/blenderbim/bim/module/bsdd/prop.py create mode 100644 src/blenderbim/blenderbim/bim/module/bsdd/ui.py diff --git a/src/blenderbim/Makefile b/src/blenderbim/Makefile index 5d3ceeeee3..94f9df787e 100644 --- a/src/blenderbim/Makefile +++ b/src/blenderbim/Makefile @@ -203,6 +203,8 @@ endif cp -r dist/working/IfcOpenShell-0.7.0/src/ifcfm/ifcfm dist/blenderbim/libs/site/packages/ # Provides IFCCOBie functionality cp -r dist/working/IfcOpenShell-0.7.0/src/ifccobie/* dist/blenderbim/libs/site/packages/ + # Provides bSDD functionality + cp -r dist/working/IfcOpenShell-0.7.0/src/bsdd/* dist/blenderbim/libs/site/packages/ # Provides IFCDiff functionality cp -r dist/working/IfcOpenShell-0.7.0/src/ifcdiff/* dist/blenderbim/libs/site/packages/ # Provides IFCCSV functionality diff --git a/src/blenderbim/blenderbim/bim/__init__.py b/src/blenderbim/blenderbim/bim/__init__.py index 5f2e0fe87e..c21240c12b 100644 --- a/src/blenderbim/blenderbim/bim/__init__.py +++ b/src/blenderbim/blenderbim/bim/__init__.py @@ -30,6 +30,7 @@ modules = { "project": None, "search": None, "bcf": None, + "bsdd": None, "root": None, "unit": None, "model": None, diff --git a/src/blenderbim/blenderbim/bim/module/bsdd/__init__.py b/src/blenderbim/blenderbim/bim/module/bsdd/__init__.py new file mode 100644 index 0000000000..d56f214697 --- /dev/null +++ b/src/blenderbim/blenderbim/bim/module/bsdd/__init__.py @@ -0,0 +1,40 @@ +# BlenderBIM Add-on - OpenBIM Blender Add-on +# Copyright (C) 2023 Dion Moult +# +# This file is part of BlenderBIM Add-on. +# +# BlenderBIM Add-on 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. +# +# BlenderBIM Add-on 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 BlenderBIM Add-on. If not, see . + +import bpy +from . import ui, prop, operator + +classes = ( + operator.LoadBSDDDomains, + operator.SearchBSDDClassifications, + operator.SetActiveBSDDDomain, + prop.BSDDDomain, + prop.BSDDClassification, + prop.BIMBSDDProperties, + ui.BIM_UL_bsdd_domains, + ui.BIM_UL_bsdd_classifications, + ui.BIM_PT_bsdd, +) + + +def register(): + bpy.types.Scene.BIMBSDDProperties = bpy.props.PointerProperty(type=prop.BIMBSDDProperties) + + +def unregister(): + del bpy.types.Scene.BIMBSDDProperties diff --git a/src/blenderbim/blenderbim/bim/module/bsdd/operator.py b/src/blenderbim/blenderbim/bim/module/bsdd/operator.py new file mode 100644 index 0000000000..f7cb918e59 --- /dev/null +++ b/src/blenderbim/blenderbim/bim/module/bsdd/operator.py @@ -0,0 +1,85 @@ +# BlenderBIM Add-on - OpenBIM Blender Add-on +# Copyright (C) 2023 Dion Moult +# +# This file is part of BlenderBIM Add-on. +# +# BlenderBIM Add-on 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. +# +# BlenderBIM Add-on 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 BlenderBIM Add-on. If not, see . + +import os +import bpy +import bsdd +import ifcopenshell +import blenderbim.tool as tool + + +class LoadBSDDDomains(bpy.types.Operator): + bl_idname = "bim.load_bsdd_domains" + bl_label = "Load bSDD Domains" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + props = context.scene.BIMBSDDProperties + props.domains.clear() + client = bsdd.Client() + for domain in sorted(client.Domain(), key=lambda x: x["name"]): + new = props.domains.add() + new.name = domain["name"] + new.namespace_uri = domain["namespaceUri"] + new.default_language_code = domain["defaultLanguageCode"] + new.organization_name_owner = domain["organizationNameOwner"] + new.status = domain["status"] + new.version = domain["version"] + return {"FINISHED"} + + +class SetActiveBSDDDomain(bpy.types.Operator): + bl_idname = "bim.set_active_bsdd_domain" + bl_label = "Load bSDD Domains" + bl_options = {"REGISTER", "UNDO"} + name: bpy.props.StringProperty() + uri: bpy.props.StringProperty() + + def execute(self, context): + props = context.scene.BIMBSDDProperties + props.active_domain = self.name + props.active_uri = self.uri + return {"FINISHED"} + + +class SearchBSDDClassifications(bpy.types.Operator): + bl_idname = "bim.search_bsdd_classifications" + bl_label = "Search bSDD Classifications" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + props = context.scene.BIMBSDDProperties + props.classifications.clear() + client = bsdd.Client() + related_ifc_entities = [] + if len(props.keyword) < 3: + return {"FINISHED"} + if props.should_filter_ifc_class and context.active_object: + element = tool.Ifc.get_entity(context.active_object) + if element: + related_ifc_entities = [element.is_a()] + results = client.ClassificationSearchOpen(props.keyword, DomainNamespaceUris=[props.active_uri], RelatedIfcEntities=related_ifc_entities) + for result in sorted(results["classifications"], key=lambda x: x["referenceCode"]): + new = props.classifications.add() + new.name = result["name"] + new.reference_code = result["referenceCode"] + new.description = result["description"] + new.namespace_uri = result["namespaceUri"] + new.domain_name = result["domainName"] + new.domain_namespace_uri = result["domainNamespaceUri"] + return {"FINISHED"} diff --git a/src/blenderbim/blenderbim/bim/module/bsdd/prop.py b/src/blenderbim/blenderbim/bim/module/bsdd/prop.py new file mode 100644 index 0000000000..1a12b32809 --- /dev/null +++ b/src/blenderbim/blenderbim/bim/module/bsdd/prop.py @@ -0,0 +1,59 @@ +# BlenderBIM Add-on - OpenBIM Blender Add-on +# Copyright (C) 2023 Dion Moult +# +# This file is part of BlenderBIM Add-on. +# +# BlenderBIM Add-on 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. +# +# BlenderBIM Add-on 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 BlenderBIM Add-on. If not, see . + +import bpy +from bpy.types import PropertyGroup +from bpy.props import ( + PointerProperty, + StringProperty, + EnumProperty, + BoolProperty, + IntProperty, + FloatProperty, + FloatVectorProperty, + CollectionProperty, +) + + +class BSDDDomain(PropertyGroup): + name: StringProperty(name="Name") + namespace_uri: StringProperty(name="URI") + default_language_code: StringProperty(name="Language") + organization_name_owner: StringProperty(name="Organization") + status: StringProperty(name="Status") + version: StringProperty(name="Version") + + +class BSDDClassification(PropertyGroup): + name: StringProperty(name="Name") + reference_code: StringProperty(name="Reference Code") + description: StringProperty(name="Description") + namespace_uri: StringProperty(name="Namespace URI") + domain_name: StringProperty(name="Domain Name") + domain_namespace_uri: StringProperty(name="Domain Namespace URI") + + +class BIMBSDDProperties(PropertyGroup): + active_domain: StringProperty(name="Active Domain") + active_uri: StringProperty(name="Active URI") + domains: CollectionProperty(name="Domains", type=BSDDDomain) + active_domain_index: IntProperty(name="Active Domain Index") + classifications: CollectionProperty(name="Classifications", type=BSDDClassification) + active_classification_index: IntProperty(name="Active Classification Index") + keyword: StringProperty(name="Keyword") + should_filter_ifc_class: BoolProperty(name="Filter Active IFC Class", default=True) diff --git a/src/blenderbim/blenderbim/bim/module/bsdd/ui.py b/src/blenderbim/blenderbim/bim/module/bsdd/ui.py new file mode 100644 index 0000000000..8577c3242a --- /dev/null +++ b/src/blenderbim/blenderbim/bim/module/bsdd/ui.py @@ -0,0 +1,71 @@ +# BlenderBIM Add-on - OpenBIM Blender Add-on +# Copyright (C) 2023 Dion Moult +# +# This file is part of BlenderBIM Add-on. +# +# BlenderBIM Add-on 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. +# +# BlenderBIM Add-on 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 BlenderBIM Add-on. If not, see . + +import blenderbim.tool as tool +from bpy.types import Panel, UIList +from blenderbim.bim.ifc import IfcStore + + +class BIM_PT_bsdd(Panel): + bl_label = "buildingSMART Data Dictionary" + bl_idname = "BIM_PT_bsdd" + bl_options = {"DEFAULT_CLOSED"} + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "scene" + bl_parent_id = "BIM_PT_project_setup" + + def draw(self, context): + props = context.scene.BIMBSDDProperties + if props.active_domain: + row = self.layout.row() + row.label(text="Active: " + props.active_domain, icon="URL") + else: + row = self.layout.row() + row.label(text="No Active bSDD Domain", icon="ERROR") + + if len(props.domains): + self.layout.template_list( + "BIM_UL_bsdd_domains", + "", + props, + "domains", + props, + "active_domain_index", + ) + else: + row = self.layout.row() + row.operator("bim.load_bsdd_domains") + + +class BIM_UL_bsdd_domains(UIList): + def draw_item(self, context, layout, data, item, icon, active_data, active_propname): + if item: + row = layout.row(align=True) + row.label(text=f"{item.name} ({item.organization_name_owner})") + op = row.operator("bim.set_active_bsdd_domain", text="", icon="RESTRICT_SELECT_OFF") + op.name = item.name + op.uri = item.namespace_uri + + +class BIM_UL_bsdd_classifications(UIList): + def draw_item(self, context, layout, data, item, icon, active_data, active_propname): + if item: + row = layout.row(align=True) + row.label(text=item.reference_code) + row.label(text=item.name) diff --git a/src/blenderbim/blenderbim/bim/module/classification/__init__.py b/src/blenderbim/blenderbim/bim/module/classification/__init__.py index f5fdcaaaed..d59b2656b3 100644 --- a/src/blenderbim/blenderbim/bim/module/classification/__init__.py +++ b/src/blenderbim/blenderbim/bim/module/classification/__init__.py @@ -21,7 +21,9 @@ from . import ui, prop, operator classes = ( operator.AddClassification, + operator.AddClassificationFromBSDD, operator.AddClassificationReference, + operator.AddClassificationReferenceFromBSDD, operator.ChangeClassificationLevel, operator.DisableEditingClassification, operator.DisableEditingClassificationReference, diff --git a/src/blenderbim/blenderbim/bim/module/classification/operator.py b/src/blenderbim/blenderbim/bim/module/classification/operator.py index d9b1b56845..9af38e5058 100644 --- a/src/blenderbim/blenderbim/bim/module/classification/operator.py +++ b/src/blenderbim/blenderbim/bim/module/classification/operator.py @@ -54,6 +54,25 @@ class AddClassification(bpy.types.Operator, tool.Ifc.Operator): ) +class AddClassificationFromBSDD(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.add_classification_from_bsdd" + bl_label = "Add Classification From bSDD" + bl_options = {"REGISTER", "UNDO"} + + def _execute(self, context): + props = context.scene.BIMBSDDProperties + domain = [d for d in props.domains if d.name == props.active_domain][0] + for element in tool.Ifc.get().by_type("IfcClassification"): + if element.Name == props.active_domain or element.Location == domain.namespace_uri: + return + classification = ifcopenshell.api.run( + "classification.add_classification", tool.Ifc.get(), classification=props.active_domain + ) + classification.Source = domain.organization_name_owner + classification.Location = domain.namespace_uri + classification.Edition = domain.version + + class EnableEditingClassification(bpy.types.Operator): bl_idname = "bim.enable_editing_classification" bl_label = "Enable Editing Classification" @@ -254,6 +273,56 @@ class AddClassificationReference(bpy.types.Operator, tool.Ifc.Operator): ) +class AddClassificationReferenceFromBSDD(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.add_classification_reference_from_bsdd" + bl_label = "Add Classification Reference From bSDD" + bl_options = {"REGISTER", "UNDO"} + obj: bpy.props.StringProperty() + obj_type: bpy.props.StringProperty() + + def _execute(self, context): + if self.obj_type == "Object": + if context.selected_objects: + objects = [o.name for o in context.selected_objects] + else: + objects = [context.active_object.name] + else: + objects = [self.obj] + props = context.scene.BIMClassificationProperties + bprops = context.scene.BIMBSDDProperties + + bsdd_classification = bprops.classifications[bprops.active_classification_index] + + classification = None + for element in tool.Ifc.get().by_type("IfcClassification"): + if ( + element.Name == bsdd_classification.domain_name + or element.Location == bsdd_classification.domain_namespace_uri + ): + classification = element + break + + if not classification: + classification = ifcopenshell.api.run( + "classification.add_classification", tool.Ifc.get(), classification=bsdd_classification.domain_name + ) + classification.Location = bsdd_classification.domain_namespace_uri + + for obj in objects: + ifc_definition_id = blenderbim.bim.helper.get_obj_ifc_definition_id(context, obj, self.obj_type) + if not ifc_definition_id: + continue + reference = ifcopenshell.api.run( + "classification.add_reference", + tool.Ifc.get(), + product=tool.Ifc.get().by_id(ifc_definition_id), + classification=classification, + identification=bsdd_classification.reference_code, + name=bsdd_classification.name, + ) + reference.Location = bsdd_classification.namespace_uri + + class ChangeClassificationLevel(bpy.types.Operator): bl_idname = "bim.change_classification_level" bl_label = "Change Classification Level" diff --git a/src/blenderbim/blenderbim/bim/module/classification/prop.py b/src/blenderbim/blenderbim/bim/module/classification/prop.py index eee7b14952..22c4e6af5c 100644 --- a/src/blenderbim/blenderbim/bim/module/classification/prop.py +++ b/src/blenderbim/blenderbim/bim/module/classification/prop.py @@ -48,6 +48,15 @@ class ClassificationReference(PropertyGroup): class BIMClassificationProperties(PropertyGroup): + classification_source: EnumProperty( + items=[ + ("FILE", "IFC File", ""), + ("BSDD", "buildingSMART Data Dictionary", ""), + ("MANUAL", "Manual Entry", ""), + ], + name="Classification Source", + default="FILE", + ) available_classifications: EnumProperty(items=get_available_classifications, name="Available Classifications") classification_attributes: CollectionProperty(name="Classification Attributes", type=Attribute) active_classification_id: IntProperty(name="Active Classification Id") diff --git a/src/blenderbim/blenderbim/bim/module/classification/ui.py b/src/blenderbim/blenderbim/bim/module/classification/ui.py index 5a4de6bf65..965b215643 100644 --- a/src/blenderbim/blenderbim/bim/module/classification/ui.py +++ b/src/blenderbim/blenderbim/bim/module/classification/ui.py @@ -48,6 +48,42 @@ class BIM_PT_classifications(Panel): self.props = context.scene.BIMClassificationProperties + row = self.layout.row(align=True) + row.label(text="Source", icon="OUTLINER") + row.prop(self.props, "classification_source", text="") + + if self.props.classification_source == "FILE": + self.draw_add_file_ui(context) + elif self.props.classification_source == "BSDD": + self.draw_add_bsdd_ui(context) + elif self.props.classification_source == "MANUAL": + self.draw_add_manual_ui(context) + + for classification in ClassificationsData.data["classifications"]: + if self.props.active_classification_id == classification["id"]: + self.draw_editable_ui() + else: + self.draw_ui(classification) + + def draw_add_manual_ui(self, context): + row = self.layout.row() + row.label(text="TODO", icon="ERROR") + + def draw_add_bsdd_ui(self, context): + self.bprops = context.scene.BIMBSDDProperties + + if not self.bprops.active_domain: + row = self.layout.row() + row.label(text="No Active bSDD Domain", icon="ERROR") + return + + row = self.layout.row() + row.label(text="Active: " + self.bprops.active_domain, icon="URL") + + row = self.layout.row() + row.operator("bim.add_classification_from_bsdd", icon="ADD") + + def draw_add_file_ui(self, context): if ClassificationsData.data["has_classification_file"]: row = self.layout.row(align=True) row.prop(self.props, "available_classifications", text="") @@ -58,12 +94,6 @@ class BIM_PT_classifications(Panel): row.label(text="No Active Classification Library") row.operator("bim.load_classification_library", text="", icon="IMPORT") - for classification in ClassificationsData.data["classifications"]: - if self.props.active_classification_id == classification["id"]: - self.draw_editable_ui() - else: - self.draw_ui(classification) - def draw_editable_ui(self): row = self.layout.row(align=True) row.operator("bim.edit_classification", text="Save changes", icon="CHECKMARK") @@ -84,6 +114,7 @@ class ReferenceUI: obj = context.active_object self.oprops = obj.BIMObjectProperties self.sprops = context.scene.BIMClassificationProperties + self.bprops = context.scene.BIMBSDDProperties self.props = obj.BIMClassificationReferenceProperties self.file = IfcStore.get_file() @@ -100,14 +131,68 @@ class ReferenceUI: self.draw_reference_ui(reference) def draw_add_ui(self, context): + row = self.layout.row(align=True) + row.label(text="Source", icon="OUTLINER") + row.prop(self.sprops, "classification_source", text="") + + if self.sprops.classification_source == "FILE": + self.draw_add_file_ui(context) + elif self.sprops.classification_source == "BSDD": + self.draw_add_bsdd_ui(context) + elif self.sprops.classification_source == "MANUAL": + self.draw_add_manual_ui(context) + + def draw_add_manual_ui(self, context): + row = self.layout.row() + row.label(text="TODO", icon="ERROR") + + def draw_add_bsdd_ui(self, context): + if not self.bprops.active_domain: + row = self.layout.row() + row.label(text="No Active bSDD Domain", icon="ERROR") + return + + row = self.layout.row() + row.label(text="Active: " + self.bprops.active_domain, icon="URL") + + row = self.layout.row(align=True) + row.prop(self.bprops, "keyword", text="") + row.operator("bim.search_bsdd_classifications", text="", icon="VIEWZOOM") + + row = self.layout.row() + row.prop(self.bprops, "should_filter_ifc_class") + + if len(self.bprops.classifications): + self.layout.template_list( + "BIM_UL_bsdd_classifications", + "", + self.bprops, + "classifications", + self.bprops, + "active_classification_index", + ) + else: + row = self.layout.row() + row.label(text="No Search Results") + + if self.bprops.active_classification_index < len(self.bprops.classifications): + row = self.layout.row() + op = row.operator( + "bim.add_classification_reference_from_bsdd", text="Add Classification Reference", icon="ADD" + ) + op.obj = self.obj + op.obj_type = self.obj_type + + def draw_add_file_ui(self, context): if not self.data.data["active_classification_library"]: row = self.layout.row(align=True) - row.label(text="No Active Classification Library") + row.label(text="No Active Classification Library", icon="ERROR") row.operator("bim.load_classification_library", text="", icon="IMPORT") return + row = self.layout.row(align=True) row.label(text=f"Active Classification Library: {self.data.data['active_classification_library']}") - #row.prop(self.sprops, "available_classifications", text="") + # row.prop(self.sprops, "available_classifications", text="") if not self.sprops.available_library_references: op = row.operator("bim.change_classification_level", text="", icon="GREASEPENCIL") op.parent_id = int(self.sprops.available_classifications) From 62bf8c22879d02ca087237d77611584a6bce8e11 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 19 Sep 2023 18:15:56 +1000 Subject: [PATCH 108/190] Fix crash where IFCTester read directly from IFC in the UI --- .../blenderbim/bim/module/tester/operator.py | 3 ++- src/blenderbim/blenderbim/bim/module/tester/prop.py | 3 ++- src/blenderbim/blenderbim/bim/module/tester/ui.py | 12 ++---------- src/ifcopenshell-python/test/util/test_selector.py | 5 +++-- src/ifctester/ifctester/reporter.py | 9 ++++++--- 5 files changed, 15 insertions(+), 17 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/tester/operator.py b/src/blenderbim/blenderbim/bim/module/tester/operator.py index 4b88c3ae4b..57ed89429d 100644 --- a/src/blenderbim/blenderbim/bim/module/tester/operator.py +++ b/src/blenderbim/blenderbim/bim/module/tester/operator.py @@ -131,7 +131,8 @@ class SelectRequirement(bpy.types.Operator): props.failed_entities.clear() for e in failed_entities: new_entity = props.failed_entities.add() - new_entity.element = e["element"] + new_entity.ifc_id = e["id"] + new_entity.element = f'{e["class"]}/{e["name"]}' new_entity.reason = e["reason"] return {"FINISHED"} diff --git a/src/blenderbim/blenderbim/bim/module/tester/prop.py b/src/blenderbim/blenderbim/bim/module/tester/prop.py index 53e4c26993..7496d4ba44 100644 --- a/src/blenderbim/blenderbim/bim/module/tester/prop.py +++ b/src/blenderbim/blenderbim/bim/module/tester/prop.py @@ -41,8 +41,9 @@ class Specification(PropertyGroup): class FailedEntities(PropertyGroup): - reason: StringProperty(name="Reason") + ifc_id: IntProperty(name="IFC ID") element: StringProperty(name="Element") + reason: StringProperty(name="Reason") class IfcTesterProperties(PropertyGroup): diff --git a/src/blenderbim/blenderbim/bim/module/tester/ui.py b/src/blenderbim/blenderbim/bim/module/tester/ui.py index 2af3f7af98..31c45bc888 100644 --- a/src/blenderbim/blenderbim/bim/module/tester/ui.py +++ b/src/blenderbim/blenderbim/bim/module/tester/ui.py @@ -115,17 +115,9 @@ class BIM_UL_tester_failed_entities(UIList): def draw_item(self, context, layout, data, item, icon, active_data, active_propname): props = context.scene.IfcTesterProperties if item: - if props.should_load_from_memory: - ifc_file = tool.Ifc.get() - ifc_id = int(item.element[1 : item.element.find("=")]) - entity = ifc_file.by_id(ifc_id) - report_entity = f"[#{ifc_id}][{entity.is_a()}] {entity.Name}" - else: - report_entity = item.element - row = layout.row(align=True) - row.label(text=report_entity) + row.label(text=item.element) row.label(text=item.reason) if props.should_load_from_memory: op = row.operator("bim.select_entity", text="", icon="RESTRICT_SELECT_OFF") - op.ifc_id = entity.id() + op.ifc_id = item.ifc_id diff --git a/src/ifcopenshell-python/test/util/test_selector.py b/src/ifcopenshell-python/test/util/test_selector.py index d5306a84b3..c5b69a8724 100644 --- a/src/ifcopenshell-python/test/util/test_selector.py +++ b/src/ifcopenshell-python/test/util/test_selector.py @@ -34,12 +34,13 @@ class TestFormat(): assert subject.format('title(\"fOo\")') == "Foo" assert subject.format('concat(\"fOo\", \"bar\")') == "fOobar" assert subject.format('upper(concat(\"fOo\", \"bar\"))') == "FOOBAR" + assert subject.format('substr(\"foobar\", 3)') == "bar" assert subject.format('substr(\"foobar\", 1, 2)') == "o" assert subject.format('substr(\"foobar\", 1, -1)') == "ooba" def test_number_formatting(self): - assert subject.format("round(123, 5)") == "125.0" - assert subject.format('round(\"123\", 5)') == "125.0" + assert subject.format("round(123, 5)") == "125" + assert subject.format('round(\"123\", 5)') == "125" assert subject.format('metric_length(123, 5, 2)') == "125.00" assert subject.format('metric_length(123.123, 0.1, 2)') == "123.10" assert subject.format('metric_length(\"123\", 5, 2)') == "125.00" diff --git a/src/ifctester/ifctester/reporter.py b/src/ifctester/ifctester/reporter.py index 82b50910ea..e9b139b829 100644 --- a/src/ifctester/ifctester/reporter.py +++ b/src/ifctester/ifctester/reporter.py @@ -436,13 +436,16 @@ class Bcf(Json): continue for failure in requirement["failed_entities"]: element = failure["element"] - title_components = [ + title_components = [] + for title_component in [ element.is_a(), - getattr(element, "Name", None) or "Unnamed", + getattr(element, "Name", "") or "Unnamed", failure.get("reason", "No reason"), getattr(element, "GlobalId", ""), getattr(element, "Tag", ""), - ] + ]: + if title_component: + title_components.append(title_component) title = " - ".join(title_components) description = f'{specification["name"]} - {requirement["description"]}' topic = bcfxml.add_topic(title, description, "IfcTester") From 2363b5e16763e58f1f325f05e7867973cfb1ebf0 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 19 Sep 2023 19:13:21 +1000 Subject: [PATCH 109/190] Minor fix --- src/blenderbim/blenderbim/bim/module/bsdd/operator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blenderbim/blenderbim/bim/module/bsdd/operator.py b/src/blenderbim/blenderbim/bim/module/bsdd/operator.py index f7cb918e59..44a009c664 100644 --- a/src/blenderbim/blenderbim/bim/module/bsdd/operator.py +++ b/src/blenderbim/blenderbim/bim/module/bsdd/operator.py @@ -78,7 +78,7 @@ class SearchBSDDClassifications(bpy.types.Operator): new = props.classifications.add() new.name = result["name"] new.reference_code = result["referenceCode"] - new.description = result["description"] + new.description = result.get("description", "") new.namespace_uri = result["namespaceUri"] new.domain_name = result["domainName"] new.domain_namespace_uri = result["domainNamespaceUri"] From 43a7340f5d84736ce5e59ca7eb03cb65da6ef5f6 Mon Sep 17 00:00:00 2001 From: Vukas Pajic Date: Tue, 19 Sep 2023 12:28:09 +0200 Subject: [PATCH 110/190] Fix case when ids_dict has key but value is None --- src/ifctester/ifctester/ids.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ifctester/ifctester/ids.py b/src/ifctester/ifctester/ids.py index c3ebfed16c..2993732b40 100644 --- a/src/ifctester/ifctester/ids.py +++ b/src/ifctester/ifctester/ids.py @@ -173,8 +173,8 @@ class Specification: self.minOccurs = ids_dict["@minOccurs"] self.maxOccurs = ids_dict["@maxOccurs"] self.ifcVersion = ids_dict["@ifcVersion"] - self.applicability = self.parse_clause(ids_dict["applicability"]) if "applicability" in ids_dict else [] - self.requirements = self.parse_clause(ids_dict["requirements"]) if "requirements" in ids_dict else [] + self.applicability = self.parse_clause(ids_dict["applicability"]) if ids_dict.get("applicability") is not None else [] + self.requirements = self.parse_clause(ids_dict["requirements"]) if ids_dict.get("requirements") is not None else [] return self def parse_clause(self, clause): From 37287bac7e27dce1eaf92dabefe2aee3319b46a4 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 19 Sep 2023 20:30:43 +1000 Subject: [PATCH 111/190] You can now add bSDD properties when assigning bSDD classifications --- .../blenderbim/bim/module/bsdd/__init__.py | 2 + .../blenderbim/bim/module/bsdd/operator.py | 47 +++++++++++++++++++ .../blenderbim/bim/module/bsdd/prop.py | 7 +++ .../bim/module/classification/operator.py | 16 ++++++- .../bim/module/classification/ui.py | 10 +++- 5 files changed, 80 insertions(+), 2 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/bsdd/__init__.py b/src/blenderbim/blenderbim/bim/module/bsdd/__init__.py index d56f214697..63f62931a6 100644 --- a/src/blenderbim/blenderbim/bim/module/bsdd/__init__.py +++ b/src/blenderbim/blenderbim/bim/module/bsdd/__init__.py @@ -20,11 +20,13 @@ import bpy from . import ui, prop, operator classes = ( + operator.GetBSDDClassificationProperties, operator.LoadBSDDDomains, operator.SearchBSDDClassifications, operator.SetActiveBSDDDomain, prop.BSDDDomain, prop.BSDDClassification, + prop.BSDDPset, prop.BIMBSDDProperties, ui.BIM_UL_bsdd_domains, ui.BIM_UL_bsdd_classifications, diff --git a/src/blenderbim/blenderbim/bim/module/bsdd/operator.py b/src/blenderbim/blenderbim/bim/module/bsdd/operator.py index 44a009c664..f3ecba2204 100644 --- a/src/blenderbim/blenderbim/bim/module/bsdd/operator.py +++ b/src/blenderbim/blenderbim/bim/module/bsdd/operator.py @@ -19,6 +19,7 @@ import os import bpy import bsdd +import json import ifcopenshell import blenderbim.tool as tool @@ -83,3 +84,49 @@ class SearchBSDDClassifications(bpy.types.Operator): new.domain_name = result["domainName"] new.domain_namespace_uri = result["domainNamespaceUri"] return {"FINISHED"} + + +class GetBSDDClassificationProperties(bpy.types.Operator): + bl_idname = "bim.get_bsdd_classification_properties" + bl_label = "Search bSDD Classifications" + bl_options = {"REGISTER", "UNDO"} + + def execute(self, context): + bprops = context.scene.BIMBSDDProperties + bprops.classification_psets.clear() + bsdd_classification = bprops.classifications[bprops.active_classification_index] + client = bsdd.Client() + data = client.Classification(bsdd_classification.namespace_uri) + + properties = data.get("classificationProperties", None) + if not properties: + return {"FINISHED"} + + psets = {} + + for prop in properties: + if prop.get("propertyDomainName") != "IFC": + continue + pset = prop.get("propertySet", None) + if not pset: + continue + psets.setdefault(pset, {}) + + predefined_value = prop.get("predefinedValue") + if predefined_value: + possible_values = [predefined_value] + else: + possible_values = prop.get("possibleValues", []) or [] + possible_values = [v["value"] for v in possible_values] + + psets[pset][prop["name"]] = possible_values + + for pset_name, pset in psets.items(): + new = bprops.classification_psets.add() + new.name = pset_name + for name, values in pset.items(): + new2 = new.properties.add() + new2.name = name + new2.enum_items = json.dumps(values) + new2.data_type = "enum" + return {"FINISHED"} diff --git a/src/blenderbim/blenderbim/bim/module/bsdd/prop.py b/src/blenderbim/blenderbim/bim/module/bsdd/prop.py index 1a12b32809..cf8e3e95e1 100644 --- a/src/blenderbim/blenderbim/bim/module/bsdd/prop.py +++ b/src/blenderbim/blenderbim/bim/module/bsdd/prop.py @@ -18,6 +18,7 @@ import bpy from bpy.types import PropertyGroup +from blenderbim.bim.prop import Attribute, StrProperty from bpy.props import ( PointerProperty, StringProperty, @@ -48,6 +49,11 @@ class BSDDClassification(PropertyGroup): domain_namespace_uri: StringProperty(name="Domain Namespace URI") +class BSDDPset(PropertyGroup): + name: StringProperty(name="Name") + properties: CollectionProperty(name="Properties", type=Attribute) + + class BIMBSDDProperties(PropertyGroup): active_domain: StringProperty(name="Active Domain") active_uri: StringProperty(name="Active URI") @@ -57,3 +63,4 @@ class BIMBSDDProperties(PropertyGroup): active_classification_index: IntProperty(name="Active Classification Index") keyword: StringProperty(name="Keyword") should_filter_ifc_class: BoolProperty(name="Filter Active IFC Class", default=True) + classification_psets: CollectionProperty(name="Classification Psets", type=BSDDPset) diff --git a/src/blenderbim/blenderbim/bim/module/classification/operator.py b/src/blenderbim/blenderbim/bim/module/classification/operator.py index 9af38e5058..8ec27ad731 100644 --- a/src/blenderbim/blenderbim/bim/module/classification/operator.py +++ b/src/blenderbim/blenderbim/bim/module/classification/operator.py @@ -312,16 +312,30 @@ class AddClassificationReferenceFromBSDD(bpy.types.Operator, tool.Ifc.Operator): ifc_definition_id = blenderbim.bim.helper.get_obj_ifc_definition_id(context, obj, self.obj_type) if not ifc_definition_id: continue + element = tool.Ifc.get().by_id(ifc_definition_id) reference = ifcopenshell.api.run( "classification.add_reference", tool.Ifc.get(), - product=tool.Ifc.get().by_id(ifc_definition_id), + product=element, classification=classification, identification=bsdd_classification.reference_code, name=bsdd_classification.name, ) reference.Location = bsdd_classification.namespace_uri + for classification_pset in bprops.classification_psets: + pset = ifcopenshell.util.element.get_pset(element, classification_pset.name) + if pset: + pset = tool.Ifc.get().by_id(pset["id"]) + else: + pset = ifcopenshell.api.run( + "pset.add_pset", tool.Ifc.get(), product=element, name=classification_pset.name + ) + properties = {} + for prop in classification_pset.properties: + properties[prop.name] = prop.get_value() + ifcopenshell.api.run("pset.edit_pset", tool.Ifc.get(), pset=pset, properties=properties) + class ChangeClassificationLevel(bpy.types.Operator): bl_idname = "bim.change_classification_level" diff --git a/src/blenderbim/blenderbim/bim/module/classification/ui.py b/src/blenderbim/blenderbim/bim/module/classification/ui.py index 965b215643..f63c887fdf 100644 --- a/src/blenderbim/blenderbim/bim/module/classification/ui.py +++ b/src/blenderbim/blenderbim/bim/module/classification/ui.py @@ -176,12 +176,20 @@ class ReferenceUI: row.label(text="No Search Results") if self.bprops.active_classification_index < len(self.bprops.classifications): - row = self.layout.row() + row = self.layout.row(align=True) op = row.operator( "bim.add_classification_reference_from_bsdd", text="Add Classification Reference", icon="ADD" ) op.obj = self.obj op.obj_type = self.obj_type + row.operator("bim.get_bsdd_classification_properties", text="", icon="COPY_ID") + + if len(self.bprops.classification_psets): + for pset in self.bprops.classification_psets: + box = self.layout.box() + row = box.row() + row.label(text=pset.name, icon="COPY_ID") + blenderbim.bim.helper.draw_attributes(pset.properties, box) def draw_add_file_ui(self, context): if not self.data.data["active_classification_library"]: From 23251de6f10f730cafdedacd2b8f1788b15f2178 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Tue, 19 Sep 2023 14:46:00 +0500 Subject: [PATCH 112/190] info message for refresh_aggregate --- src/blenderbim/blenderbim/bim/module/geometry/operator.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/blenderbim/blenderbim/bim/module/geometry/operator.py b/src/blenderbim/blenderbim/bim/module/geometry/operator.py index b8ed85a5d8..90d5adf668 100644 --- a/src/blenderbim/blenderbim/bim/module/geometry/operator.py +++ b/src/blenderbim/blenderbim/bim/module/geometry/operator.py @@ -1180,22 +1180,26 @@ class RefreshAggregate(bpy.types.Operator): return new_entity if len(context.selected_objects) != 1: + self.report({"INFO"}, "Only 1 object need to be selected.") return {"FINISHED"} selected_root_obj = context.selected_objects[0] selected_root_entity = tool.Ifc.get_entity(selected_root_obj) if not selected_root_entity.is_a("IfcElementAssembly"): + self.report({"INFO"}, "Object is not IfcElementAssembly.") return {"FINISHED"} pset = ifcopenshell.util.element.get_pset(selected_root_entity, "BBIM_Aggregate_Data") if not pset: + self.report({"INFO"}, "Object is not part of an assembly aggregate.") return {"FINISHED"} pset_data = json.loads(pset["Data"])[0] instance_of = pset_data["instance_of"][0] original_root_entity = tool.Ifc.get().by_guid(instance_of) if original_root_entity == selected_root_entity: + self.report({"INFO"}, "Cannot refresh original assembly. Select an assembly instance.") return {"FINISHED"} parents = remove_objects(selected_root_entity) From 4ee66caaf95eef99bc892fba55fbe814fa1725c2 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Tue, 19 Sep 2023 14:46:08 +0500 Subject: [PATCH 113/190] override_outliner_delete poll error fix --- src/blenderbim/blenderbim/bim/module/geometry/operator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blenderbim/blenderbim/bim/module/geometry/operator.py b/src/blenderbim/blenderbim/bim/module/geometry/operator.py index 90d5adf668..74121e1e19 100644 --- a/src/blenderbim/blenderbim/bim/module/geometry/operator.py +++ b/src/blenderbim/blenderbim/bim/module/geometry/operator.py @@ -538,7 +538,7 @@ class OverrideOutlinerDelete(bpy.types.Operator): @classmethod def poll(cls, context): - return len(context.selected_ids) > 0 + return len(getattr(context, "selected_ids", [])) > 0 def execute(self, context): # In this override, we don't check self.hierarchy. This effectively From 90c4a2fd41cf19d560d7fe5cce215590a2387e09 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Tue, 19 Sep 2023 16:16:10 +0500 Subject: [PATCH 114/190] bim.mep_connect_elements to connect elements by the closest ports Previously it was looking for exact match. Now operator also adjusts connected ports --- .../blenderbim/bim/module/system/operator.py | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/system/operator.py b/src/blenderbim/blenderbim/bim/module/system/operator.py index 71336ec7fb..0b703495b3 100644 --- a/src/blenderbim/blenderbim/bim/module/system/operator.py +++ b/src/blenderbim/blenderbim/bim/module/system/operator.py @@ -219,12 +219,15 @@ class DisconnectPort(bpy.types.Operator, Operator): class MEPConnectElements(bpy.types.Operator, Operator): bl_idname = "bim.mep_connect_elements" bl_label = "Connect MEP Elements" - bl_description = "Connects two selected elements if they have ports with matching location" + bl_description = "Connects two selected elements by their closest located ports and adjusts them" bl_options = {"REGISTER", "UNDO"} @classmethod def poll(cls, context): - return len(context.selected_objects) == 2 + if not len(context.selected_objects) == 2: + cls.poll_message_set("Need to select 2 objects.") + return False + return True def _execute(self, context): obj1 = context.active_object @@ -233,23 +236,32 @@ class MEPConnectElements(bpy.types.Operator, Operator): el1 = tool.Ifc.get_entity(obj1) el2 = tool.Ifc.get_entity(obj2) + connected_elements = ifcopenshell.util.system.get_connected_to(el1) + connected_elements += ifcopenshell.util.system.get_connected_to(el2) + + if el2 in connected_elements: + self.report({"ERROR"}, "MEP elements are already connected to each other.") + return {"CANCELLED"} + obj1_ports = [p for p in tool.System.get_ports(el1) if not tool.System.get_connected_port(p)] obj2_ports = [p for p in tool.System.get_ports(el2) if not tool.System.get_connected_port(p)] if not obj1_ports or not obj2_ports: self.report({"ERROR"}, "Couldn't find free ports to connect.") - return + return {"CANCELLED"} + ports_distance = dict() for port1 in obj1_ports: port1_location = tool.Model.get_element_matrix(port1).translation for port2 in obj2_ports: port2_location = tool.Model.get_element_matrix(port2).translation - if tool.Cad.are_vectors_equal(port1_location, port2_location): - core.connect_port(tool.Ifc, port1, port2) - return {"FINISHED"} + distance = (port1_location - port2_location).length + ports_distance[(port1, port2)] = distance - self.report({"ERROR"}, "Couldn't find any matching ports to connect.") - return {"CANCELLED"} + closest_ports = min(ports_distance, key=lambda x: ports_distance[x]) + core.connect_port(tool.Ifc, *closest_ports) + bpy.ops.bim.regenerate_distribution_element() + return {"FINISHED"} class SetFlowDirection(bpy.types.Operator, Operator): From 8320623210a502e50478a9f0bf36729c283129a7 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Tue, 19 Sep 2023 16:58:12 +0500 Subject: [PATCH 115/190] Exposed some MEP operators in BIM Tool UI https://i.imgur.com/U61QqlQ.png --- .../blenderbim/bim/module/model/workspace.py | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/model/workspace.py b/src/blenderbim/blenderbim/bim/module/model/workspace.py index f8e3d19c0c..fa2f33090d 100644 --- a/src/blenderbim/blenderbim/bim/module/model/workspace.py +++ b/src/blenderbim/blenderbim/bim/module/model/workspace.py @@ -347,14 +347,21 @@ class BimToolUI: "IfcDuctSegment", "IfcPipeSegment", ): - add_layout_hotkey_operator(cls.layout, "Extend", "S_E", "") add_layout_hotkey_operator(cls.layout, "Add Fitting", "S_F", "") + add_layout_hotkey_operator( + cls.layout, "Regen MEP", "S_G", bpy.ops.bim.regenerate_distribution_element.__doc__ + ) + if context.region.type != "TOOL_HEADER": + cls.layout.operator("bim.mep_add_bend") + cls.layout.operator("bim.mep_add_transition") + cls.layout.operator("bim.mep_add_obstruction") + cls.layout.operator("bim.mep_connect_elements") else: add_layout_hotkey_operator(cls.layout, "Edit Axis", "A_E", "") add_layout_hotkey_operator(cls.layout, "Butt", "S_T", "") add_layout_hotkey_operator(cls.layout, "Mitre", "S_Y", "") add_layout_hotkey_operator(cls.layout, "Rotate 90", "S_R", bpy.ops.bim.rotate_90.__doc__) - add_layout_hotkey_operator(cls.layout, "Regen", "S_G", bpy.ops.bim.recalculate_profile.__doc__) + add_layout_hotkey_operator(cls.layout, "Regen", "S_G", bpy.ops.bim.recalculate_profile.__doc__) row.operator("bim.extend_profile", icon="X", text="").join_type = "" elif ( @@ -667,7 +674,15 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): if self.active_material_usage == "LAYER2": bpy.ops.bim.recalculate_wall() elif self.active_material_usage == "PROFILE": - bpy.ops.bim.recalculate_profile() + if self.active_class in ( + "IfcCableCarrierSegment", + "IfcCableSegment", + "IfcDuctSegment", + "IfcPipeSegment", + ): + bpy.ops.bim.regenerate_distribution_element() + else: + bpy.ops.bim.recalculate_profile() elif self.active_class in ("IfcWindow", "IfcWindowStandardCase", "IfcDoor", "IfcDoorStandardCase"): bpy.ops.bim.recalculate_fill() elif self.active_class in ("IfcSpace"): From 63a5c0986f22bebba0395cf9b5221d5c80043dff Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 19 Sep 2023 23:55:53 +1000 Subject: [PATCH 116/190] Datatype checks in IfcTester are now case insensitive --- src/ifctester/ifctester/facet.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/ifctester/ifctester/facet.py b/src/ifctester/ifctester/facet.py index 2e5efdf0ae..791223a11b 100644 --- a/src/ifctester/ifctester/facet.py +++ b/src/ifctester/ifctester/facet.py @@ -637,7 +637,7 @@ class Property(Facet): elif prop_entity.is_a("IfcPropertySingleValue"): data_type = prop_entity.NominalValue.is_a() - if data_type != self.datatype: + if data_type.lower() != self.datatype.lower(): is_pass = False reason = {"type": "DATATYPE", "actual": data_type} break @@ -656,7 +656,7 @@ class Property(Facet): prop_schema = prop_entity.wrapped_data.declaration().as_entity() data_type = prop_schema.attribute_by_index(3).type_of_attribute().declared_type().name() - if data_type != self.datatype: + if data_type.lower() != self.datatype.lower(): is_pass = False reason = {"type": "DATATYPE", "actual": data_type} break @@ -676,7 +676,7 @@ class Property(Facet): reason = {"type": "NOVALUE"} break data_type = prop_entity.EnumerationValues[0].is_a() - if data_type != self.datatype: + if data_type.lower() != self.datatype.lower(): is_pass = False reason = {"type": "DATATYPE", "actual": data_type} break @@ -686,7 +686,7 @@ class Property(Facet): reason = {"type": "NOVALUE"} break data_type = prop_entity.ListValues[0].is_a() - if data_type != self.datatype: + if data_type.lower() != self.datatype.lower(): is_pass = False reason = {"type": "DATATYPE", "actual": data_type} break @@ -709,7 +709,7 @@ class Property(Facet): if value is not None: data_type = value.is_a() values.append(value.wrappedValue) - if data_type != self.datatype: + if data_type.lower() != self.datatype.lower(): is_pass = False reason = {"type": "DATATYPE", "actual": data_type} break @@ -734,7 +734,7 @@ class Property(Facet): if not column_values: continue data_type = column_values[0].is_a() - if data_type == self.datatype: + if data_type.lower() == self.datatype.lower(): column_values = [v.wrappedValue for v in column_values] unit = units[f"{attribute}Unit"] if unit: From a3802c25c0cf01cb1ced9d4770a08a343c011dcd Mon Sep 17 00:00:00 2001 From: "Sigma Dimensions (Yass)" <79010126+myoualid@users.noreply.github.com> Date: Wed, 20 Sep 2023 01:05:19 +0100 Subject: [PATCH 117/190] fix cascading schedule when parent tasks have sequence relationships #3772 --- .../api/sequence/cascade_schedule.py | 10 +++++++++- .../ifcopenshell/util/sequence.py | 20 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/cascade_schedule.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/cascade_schedule.py index d8fc4f4384..b541a81670 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/cascade_schedule.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/cascade_schedule.py @@ -135,7 +135,9 @@ class Usecase: finishes = [] starts = [] - for rel in task.IsSuccessorFrom: + for rel in ifcopenshell.util.sequence.get_sequence_assignment( + task, "predecessor" + ): predecessor = rel.RelatingProcess predecessor_duration = ( ifcopenshell.util.date.ifc2datetime( @@ -314,6 +316,12 @@ class Usecase: for rel in task.IsPredecessorTo: self.cascade_task(rel.RelatedProcess, task_sequence=task_sequence + [task]) + for rel in task.IsNestedBy: + [ + self.cascade_task(nested_task, task_sequence=task_sequence + [task]) + for nested_task in rel.RelatedObjects or [] + ] + def get_lag_time_days(self, lag_time): return ifcopenshell.util.date.ifc2datetime(lag_time.LagValue.wrappedValue).days diff --git a/src/ifcopenshell-python/ifcopenshell/util/sequence.py b/src/ifcopenshell-python/ifcopenshell/util/sequence.py index 02ec945a63..9389320120 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/sequence.py +++ b/src/ifcopenshell-python/ifcopenshell/util/sequence.py @@ -426,3 +426,23 @@ def get_tasks_for_product(product, schedule=None): ] return inputs, outputs + + +def get_sequence_assignment(task, sequence="successor"): + if sequence == "successor": + relationship_attr = "IsPredecessorTo" + elif sequence == "predecessor": + relationship_attr = "IsSuccessorFrom" + else: + return [] + + relationship = getattr(task, relationship_attr, None) + if relationship: + return relationship + + for rel in task.Nests or []: + result = get_sequence_assignment(rel.RelatingObject, sequence) + if result: + return result + + return [] From ef11ae654017d852bc1e62e466fe5d3f4591971d Mon Sep 17 00:00:00 2001 From: "Sigma Dimensions (Yass)" <79010126+myoualid@users.noreply.github.com> Date: Wed, 20 Sep 2023 01:10:34 +0100 Subject: [PATCH 118/190] fix calculating critical path to consider: - parent tasks with sequence assignments #3772 - tasks that are constrained by a schedule start --- .../api/sequence/recalculate_schedule.py | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/recalculate_schedule.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/recalculate_schedule.py index c7e911e2d9..4039c53c57 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/recalculate_schedule.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/recalculate_schedule.py @@ -148,7 +148,7 @@ class Usecase: [ ( rel.RelatingProcess.id(), - rel.RelatedProcess.id(), + task.id(), { "lag_time": 0 if not rel.TimeLag @@ -158,11 +158,12 @@ class Usecase: "type": self.sequence_type_map[rel.SequenceType], }, ) - for rel in task.IsSuccessorFrom or [] + for rel in ifcopenshell.util.sequence.get_sequence_assignment(task, sequence="predecessor") ] ) - predecessor_types = [rel.SequenceType for rel in task.IsSuccessorFrom] - successor_types = [rel.SequenceType for rel in task.IsPredecessorTo] + + predecessor_types = [rel.SequenceType for rel in ifcopenshell.util.sequence.get_sequence_assignment(task, "predecessor")] + successor_types = [rel.SequenceType for rel in ifcopenshell.util.sequence.get_sequence_assignment(task, "successor")] if not predecessor_types: self.edges.append(("start", task.id(), {"lag_time": 0, "type": "FS"})) @@ -170,6 +171,7 @@ class Usecase: self.start_dates.append( ifcopenshell.util.date.ifc2datetime(task.TaskTime.ScheduleStart) ) + self.g.nodes[task.id()]["early_start"] = ifcopenshell.util.date.ifc2datetime(task.TaskTime.ScheduleStart) # we assume this task is constrained to start on this date if not successor_types: self.edges.append((task.id(), "finish", {"lag_time": 0, "type": "FF"})) @@ -223,6 +225,16 @@ class Usecase: else: finishes = [] starts = [] + if data.get("early_start") is not None: + data["early_finish"] = ifcopenshell.util.sequence.get_start_or_finish_date( + data["early_start"], + datetime.timedelta(days=data["duration"]), + data["duration_type"], + data["calendar"], + date_type="FINISH", + ) + return True # we're done! We assume this task is constrained and finish processing it + for predecessor in predecessors: predecessor_data = self.g.nodes[predecessor] edge = self.g[predecessor][node] From 6d38d9b53832b832954a7845b0e529604bebd060 Mon Sep 17 00:00:00 2001 From: "Sigma Dimensions (Yass)" <79010126+myoualid@users.noreply.github.com> Date: Wed, 20 Sep 2023 01:11:42 +0100 Subject: [PATCH 119/190] fix count_working_days when the start and finish are the same same hours --- src/ifcopenshell-python/ifcopenshell/util/sequence.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/ifcopenshell-python/ifcopenshell/util/sequence.py b/src/ifcopenshell-python/ifcopenshell/util/sequence.py index 9389320120..a97e9a58a1 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/sequence.py +++ b/src/ifcopenshell-python/ifcopenshell/util/sequence.py @@ -62,6 +62,8 @@ def derive_calendar(task): def count_working_days(start, finish, calendar): result = 0 + if start == finish: + return 0 current_date = datetime.date(start.year, start.month, start.day) finish_date = datetime.date(finish.year, finish.month, finish.day) while current_date <= finish_date: From b5b8b5df2660bad19d24958edfe7cb64069caf87 Mon Sep 17 00:00:00 2001 From: "Sigma Dimensions (Yass)" <79010126+myoualid@users.noreply.github.com> Date: Wed, 20 Sep 2023 01:15:06 +0100 Subject: [PATCH 120/190] cascade schedule when adding a sequence relationship --- .../ifcopenshell/api/sequence/assign_sequence.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/ifcopenshell-python/ifcopenshell/api/sequence/assign_sequence.py b/src/ifcopenshell-python/ifcopenshell/api/sequence/assign_sequence.py index a3b4dd2a16..078f5b7f5c 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/sequence/assign_sequence.py +++ b/src/ifcopenshell-python/ifcopenshell/api/sequence/assign_sequence.py @@ -120,7 +120,7 @@ class Usecase: for rel in self.settings["related_process"].IsSuccessorFrom or []: if rel.RelatingProcess == self.settings["relating_process"]: return rel - return self.file.create_entity( + rel = self.file.create_entity( "IfcRelSequence", **{ "GlobalId": ifcopenshell.guid.new(), @@ -132,3 +132,7 @@ class Usecase: "SequenceType": self.settings["sequence_type"], } ) + ifcopenshell.api.run( + "sequence.cascade_schedule", self.file, task=self.settings["relating_process"] + ) + return rel From f3c012660ad6603d6f4c97b6f182ba0e02c0b481 Mon Sep 17 00:00:00 2001 From: "Sigma Dimensions (Yass)" <79010126+myoualid@users.noreply.github.com> Date: Wed, 20 Sep 2023 01:22:18 +0100 Subject: [PATCH 121/190] improve pset editing workflow and use refactored core and tool functions --- .../blenderbim/bim/module/pset/operator.py | 19 +------------------ src/blenderbim/blenderbim/core/pset.py | 1 + src/blenderbim/blenderbim/tool/pset.py | 7 +++++++ 3 files changed, 9 insertions(+), 18 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/pset/operator.py b/src/blenderbim/blenderbim/bim/module/pset/operator.py index adbb9cfbcd..f5137c36e4 100644 --- a/src/blenderbim/blenderbim/bim/module/pset/operator.py +++ b/src/blenderbim/blenderbim/bim/module/pset/operator.py @@ -356,24 +356,7 @@ class AddPset(bpy.types.Operator, Operator): obj_type: bpy.props.StringProperty() def _execute(self, context): - self.file = IfcStore.get_file() - pset_name = get_pset_props(context, self.obj, self.obj_type).pset_name - if self.obj_type == "Object": - if context.selected_objects: - objects = [o.name for o in tool.Blender.get_selected_objects()] - else: - objects = [context.active_object.name] - else: - objects = [self.obj] - for obj in objects: - ifc_definition_id = blenderbim.bim.helper.get_obj_ifc_definition_id(context, obj, self.obj_type) - if not ifc_definition_id: - continue - element = tool.Ifc.get().by_id(ifc_definition_id) - if pset_name in blenderbim.bim.schema.ifc.psetqto.get_applicable_names(element.is_a(), pset_only=True): - bpy.ops.bim.enable_pset_editing( - pset_id=0, pset_name=pset_name, pset_type="PSET", obj=obj, obj_type=self.obj_type - ) + core.add_pset(tool.Ifc, tool.Pset, tool.Blender, obj_name=self.obj, obj_type=self.obj_type) class AddQto(bpy.types.Operator, Operator): diff --git a/src/blenderbim/blenderbim/core/pset.py b/src/blenderbim/blenderbim/core/pset.py index 36200040f7..023b7ba190 100644 --- a/src/blenderbim/blenderbim/core/pset.py +++ b/src/blenderbim/blenderbim/core/pset.py @@ -43,3 +43,4 @@ def add_pset(ifc, pset, blender, obj_name, obj_type): ifc_pset = pset.get_element_pset(element, pset_name) if not ifc_pset: ifc.run("pset.add_pset", product=element, name=pset_name) + pset.enable_pset_editing(pset_id=0, pset_name=pset_name, pset_type="PSET",obj=obj_name, obj_type=obj_type) \ No newline at end of file diff --git a/src/blenderbim/blenderbim/tool/pset.py b/src/blenderbim/blenderbim/tool/pset.py index 62f52bdecc..1c115f7e1e 100644 --- a/src/blenderbim/blenderbim/tool/pset.py +++ b/src/blenderbim/blenderbim/tool/pset.py @@ -66,3 +66,10 @@ class Pset(blenderbim.core.tool.Pset): if value is not None: return False return True + + @classmethod + def enable_pset_editing(cls, pset_id=None, pset_name=None, pset_type=None, obj=None, obj_type=None): + #TODO REFACTOR ONCE toll/CORE functions are available + bpy.ops.bim.enable_pset_editing( + pset_id=0, pset_name=tool.Pset.get_pset_name(obj, obj_type), pset_type="PSET", obj=obj, obj_type=obj_type + ) \ No newline at end of file From 4b159d400c3cf9fdbd33ffa1088c1ff1e88f1068 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 20 Sep 2023 09:34:50 +0500 Subject: [PATCH 122/190] bim.draw_system_arrows updated for ifc4 --- .../blenderbim/bim/module/misc/operator.py | 66 ++++++++++--------- 1 file changed, 35 insertions(+), 31 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/misc/operator.py b/src/blenderbim/blenderbim/bim/module/misc/operator.py index 3de1e0846e..9271b05197 100644 --- a/src/blenderbim/blenderbim/bim/module/misc/operator.py +++ b/src/blenderbim/blenderbim/bim/module/misc/operator.py @@ -205,46 +205,50 @@ class DrawSystemArrows(bpy.types.Operator, Operator): return context.selected_objects and tool.Ifc.get() def _execute(self, context): - unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) - curve = bpy.data.objects.new("System Arrows", bpy.data.curves.new("System Arrows", "CURVE")) - curve.data.dimensions = "3D" - context.scene.collection.objects.link(curve) + sinks = [] + sources = [] + for obj in bpy.context.selected_objects: if not obj.BIMObjectProperties.ifc_definition_id: continue + element = tool.Ifc.get_entity(obj) - sources = [] - sinks = [] - for rel in getattr(element, "HasPorts", []) or []: - if rel.RelatingPort.FlowDirection == "SOURCE": - sources.append( - self.get_absolute_matrix( - ifcopenshell.util.placement.get_local_placement(rel.RelatingPort.ObjectPlacement) - ) - ) - elif rel.RelatingPort.FlowDirection == "SINK": - sinks.append( - self.get_absolute_matrix( - ifcopenshell.util.placement.get_local_placement(rel.RelatingPort.ObjectPlacement) - ) - ) + sources_current = [] + sinks_current = [] + + for port in tool.System.get_ports(element): + local_placement = ifcopenshell.util.placement.get_local_placement(port.ObjectPlacement) + m = self.get_absolute_matrix(local_placement) + if port.FlowDirection == "SOURCE": + sources_current.append(m) + elif port.FlowDirection == "SINK": + sinks_current.append(m) else: - sources.append( - self.get_absolute_matrix( - ifcopenshell.util.placement.get_local_placement(rel.RelatingPort.ObjectPlacement) - ) - ) - sinks.append( - self.get_absolute_matrix( - ifcopenshell.util.placement.get_local_placement(rel.RelatingPort.ObjectPlacement) - ) - ) - for sink in sinks: - for source in sources: + sources_current.append(m) + sinks_current.append(m) + + if sinks_current or sources_current: + sinks.append(sinks_current) + sources.append(sources_current) + + if not sinks: + self.report({"INFO"}, "No sinks/sources found for selected objects.") + return {"FINISHED"} + + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + curve = bpy.data.objects.new("System Arrows", bpy.data.curves.new("System Arrows", "CURVE")) + curve.data.dimensions = "3D" + curve.show_in_front = True + context.scene.collection.objects.link(curve) + + for i in range(len(sinks)): + for sink in sinks[i]: + for source in sources[i]: polyline = curve.data.splines.new("POLY") polyline.points.add(1) polyline.points[0].co = (Matrix(sink).translation * unit_scale).to_4d() polyline.points[1].co = (Matrix(source).translation * unit_scale).to_4d() + tool.Blender.select_and_activate_single_object(context, curve) def get_absolute_matrix(self, matrix): props = bpy.context.scene.BIMGeoreferenceProperties From 5af3b537da0bec6476f454845c1cc75b2c07b831 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 20 Sep 2023 11:01:54 +0500 Subject: [PATCH 123/190] get_viewport_context to also return space --- src/blenderbim/blenderbim/tool/blender.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/blenderbim/blenderbim/tool/blender.py b/src/blenderbim/blenderbim/tool/blender.py index 20f825ab25..31027b4290 100644 --- a/src/blenderbim/blenderbim/tool/blender.py +++ b/src/blenderbim/blenderbim/tool/blender.py @@ -168,7 +168,8 @@ class Blender: """ area = next(area for area in bpy.context.screen.areas if area.type == "VIEW_3D") region = next(region for region in area.regions if region.type == "WINDOW") - context_override = {"area": area, "region": region} + space = next(space for space in area.spaces if space.type == "VIEW_3D") + context_override = {"area": area, "region": region, "space_data": space} return context_override @classmethod From bc5d6ee1133f8861f6829676ba8292b547e6c0f2 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 20 Sep 2023 12:30:12 +0500 Subject: [PATCH 124/190] fixed error in api.system.disconnect_port Error was: TypeError: can only concatenate tuple (not "list") to tuple --- .../ifcopenshell/api/system/disconnect_port.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/api/system/disconnect_port.py b/src/ifcopenshell-python/ifcopenshell/api/system/disconnect_port.py index 1ff6609ce1..eddfae5f59 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/system/disconnect_port.py +++ b/src/ifcopenshell-python/ifcopenshell/api/system/disconnect_port.py @@ -70,8 +70,8 @@ class Usecase: } def execute(self): - rels = self.settings["port"].ConnectedTo or [] - rels += self.settings["port"].ConnectedFrom or [] + rels = self.settings["port"].ConnectedTo or () + rels += self.settings["port"].ConnectedFrom or () for rel in rels: rel.RelatingPort.FlowDirection = None From dee4cf591e734ca5ec193bde593dfb26052663f2 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 20 Sep 2023 15:19:19 +0500 Subject: [PATCH 125/190] mep system decorators - basic implementation All it does currently it draws all MEP elements connections in viewport. It also highlights with green color all elements connected to the active object Demo - https://imgur.com/a/haAQeWt --- .../bim/module/drawing/decoration.py | 3 +- .../blenderbim/bim/module/system/__init__.py | 4 +- .../blenderbim/bim/module/system/data.py | 17 ++ .../blenderbim/bim/module/system/decorator.py | 145 ++++++++++++++++++ .../blenderbim/bim/module/system/prop.py | 12 ++ .../blenderbim/bim/module/system/ui.py | 4 + src/blenderbim/blenderbim/tool/system.py | 77 ++++++++++ 7 files changed, 260 insertions(+), 2 deletions(-) create mode 100644 src/blenderbim/blenderbim/bim/module/system/decorator.py diff --git a/src/blenderbim/blenderbim/bim/module/drawing/decoration.py b/src/blenderbim/blenderbim/bim/module/drawing/decoration.py index 6b21e95bee..ebff8b188d 100644 --- a/src/blenderbim/blenderbim/bim/module/drawing/decoration.py +++ b/src/blenderbim/blenderbim/bim/module/drawing/decoration.py @@ -1926,7 +1926,7 @@ class DecorationsHandler: if cls.installed: cls.uninstall() handler = cls() - # NOTE: we USE POST_PIXEL here so that we can draw use both 3D_POLYLINE_UNIFORM_COLOR + # NOTE: we USE POST_PIXEL here so that we can use both 3D_POLYLINE_UNIFORM_COLOR # and drawing text in the same handler. BUT this means that we supply coordinates in WINSPACE cls.installed = SpaceView3D.draw_handler_add(handler, (context,), "WINDOW", "POST_PIXEL") @@ -1944,6 +1944,7 @@ class DecorationsHandler: self.decorators[object_type] = self.decorators["FALL"] def get_objects_and_decorators(self, collection): + # TODO: do it in data instead of the handler for performance? results = [] for obj in collection.all_objects: diff --git a/src/blenderbim/blenderbim/bim/module/system/__init__.py b/src/blenderbim/blenderbim/bim/module/system/__init__.py index a57b7fb5a8..ea95b8965e 100644 --- a/src/blenderbim/blenderbim/bim/module/system/__init__.py +++ b/src/blenderbim/blenderbim/bim/module/system/__init__.py @@ -17,7 +17,7 @@ # along with BlenderBIM Add-on. If not, see . import bpy -from . import ui, prop, operator +from . import ui, prop, operator, decorator classes = ( operator.AddPort, @@ -51,7 +51,9 @@ classes = ( def register(): bpy.types.Scene.BIMSystemProperties = bpy.props.PointerProperty(type=prop.BIMSystemProperties) + bpy.app.handlers.load_post.append(decorator.toggle_decorations_on_load) def unregister(): del bpy.types.Scene.BIMSystemProperties + bpy.app.handlers.load_post.remove(decorator.toggle_decorations_on_load) diff --git a/src/blenderbim/blenderbim/bim/module/system/data.py b/src/blenderbim/blenderbim/bim/module/system/data.py index 6c71e88dc9..9dd348fe13 100644 --- a/src/blenderbim/blenderbim/bim/module/system/data.py +++ b/src/blenderbim/blenderbim/bim/module/system/data.py @@ -27,6 +27,7 @@ def refresh(): SystemData.is_loaded = False ObjectSystemData.is_loaded = False PortData.is_loaded = False + SystemDecorationData.is_loaded = False class SystemData: @@ -140,3 +141,19 @@ class PortData: data.append((port, port_obj, connected_element)) return data + + +class SystemDecorationData: + data = {} + is_loaded = False + + @classmethod + def load(cls): + cls.data = { + "decoration_data": cls.decoration_data(), + } + cls.is_loaded = True + + @classmethod + def decoration_data(cls): + return tool.System.get_decoration_data() diff --git a/src/blenderbim/blenderbim/bim/module/system/decorator.py b/src/blenderbim/blenderbim/bim/module/system/decorator.py new file mode 100644 index 0000000000..244c8d9a5f --- /dev/null +++ b/src/blenderbim/blenderbim/bim/module/system/decorator.py @@ -0,0 +1,145 @@ +# BlenderBIM Add-on - OpenBIM Blender Add-on +# Copyright (C) 2023 Dion Moult , @Andrej730 +# +# This file is part of BlenderBIM Add-on. +# +# BlenderBIM Add-on 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. +# +# BlenderBIM Add-on 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 BlenderBIM Add-on. If not, see . + +import bpy +import gpu +import bmesh +import blenderbim.tool as tool +from math import sin, cos, radians +from bpy.types import SpaceView3D +from mathutils import Vector, Matrix +from gpu_extras.batch import batch_for_shader +import ifcopenshell +from blenderbim.bim.module.system.data import SystemDecorationData +from bpy.app.handlers import persistent + + +ERROR_ELEMENTS_COLOR = (1, 0.2, 0.322, 1) # RED +UNSPECIAL_ELEMENT_COLOR = (0.2, 0.2, 0.2, 1) # GREY + + +def transparent_color(color, alpha=0.1): + color = [i for i in color] + color[3] = alpha + return color + + +@persistent +def toggle_decorations_on_load(*args): + if bpy.context.scene.BIMSystemProperties.should_draw_decorations: + SystemDecorator.install(bpy.context) + else: + SystemDecorator.uninstall() + + +class SystemDecorator: + installed = None + + @classmethod + def install(cls, context, get_custom_bmesh=None, draw_faces=False, exit_edit_mode_callback=None): + """Note that operators that change mesh in `exit_edit_mode_callback` can freeze blender. + The workaround is to move their code to function and use it for callback. + + Example: https://devtalk.blender.org/t/calling-operator-that-saves-bmesh-freezes-blender-forever/28595""" + if cls.installed: + cls.uninstall() + handler = cls() + cls.installed = SpaceView3D.draw_handler_add( + handler, (context, get_custom_bmesh, draw_faces, exit_edit_mode_callback), "WINDOW", "POST_VIEW" + ) + + @classmethod + def uninstall(cls): + try: + SpaceView3D.draw_handler_remove(cls.installed, "WINDOW") + except ValueError: + pass + cls.installed = None + + def draw_batch(self, shader_type, content_pos, color, indices=None): + shader = self.line_shader if shader_type == "LINES" else self.shader + batch = batch_for_shader(shader, shader_type, {"pos": content_pos}, indices=indices) + shader.uniform_float("color", color) + batch.draw(shader) + + def draw_faces(self, bm, vertices_coords): + """mutates original bm (triangulates it) + so the triangulation edges will be shown too + """ + traingulated_bm = bm + bmesh.ops.triangulate(traingulated_bm, faces=traingulated_bm.faces) + + face_indices = [[v.index for v in f.verts] for f in traingulated_bm.faces] + faces_color = transparent_color(self.addon_prefs.decorator_color_special) + self.draw_batch("TRIS", vertices_coords, faces_color, face_indices) + + def __call__(self, context, get_custom_bmesh=None, draw_faces=False, exit_edit_mode_callback=None): + self.addon_prefs = context.preferences.addons["blenderbim"].preferences + selected_elements_color = self.addon_prefs.decorator_color_selected + unselected_elements_color = self.addon_prefs.decorator_color_unselected + special_elements_color = self.addon_prefs.decorator_color_special + + gpu.state.point_size_set(6) + gpu.state.blend_set("ALPHA") + + ### Actually drawing + all_vertices = [] + error_vertices = [] + selected_vertices = [] + unselected_vertices = [] + # special = associated with arcs/circles + special_vertices = [] + special_vertex_indices = {} + selected_edges = [] + unselected_edges = [] + arc_edges = [] + roof_angle_edges = [] + preview_edges = [] + + if not SystemDecorationData.is_loaded: + SystemDecorationData.load() + + decoration_data = SystemDecorationData.data["decoration_data"] + all_vertices = decoration_data["all_vertices"] + preview_edges = decoration_data["preview_edges"] + special_vertices = decoration_data["special_vertices"] + selected_edges = decoration_data["selected_edges"] + selected_vertices = decoration_data["selected_vertices"] + + ### Actually drawing + # 3D_POLYLINE_UNIFORM_COLOR is good for smoothed lines since `bgl.enable(GL_LINE_SMOOTH)` is deprecated + self.line_shader = gpu.shader.from_builtin("3D_POLYLINE_UNIFORM_COLOR") + self.line_shader.bind() + # POLYLINE_UNIFORM_COLOR specific uniforms + self.line_shader.uniform_float("viewportSize", (context.region.width, context.region.height)) + self.line_shader.uniform_float("lineWidth", 2.0) + + # general shader + self.shader = gpu.shader.from_builtin("3D_UNIFORM_COLOR") + self.shader.bind() + + self.draw_batch("LINES", all_vertices, transparent_color(unselected_elements_color), unselected_edges) + self.draw_batch("LINES", all_vertices, selected_elements_color, selected_edges) + self.draw_batch("LINES", all_vertices, UNSPECIAL_ELEMENT_COLOR, arc_edges) + self.draw_batch("LINES", all_vertices, special_elements_color, preview_edges) + self.draw_batch("LINES", all_vertices, special_elements_color, roof_angle_edges) + + self.draw_batch("POINTS", unselected_vertices, transparent_color(unselected_elements_color, 0.5)) + self.draw_batch("POINTS", error_vertices, ERROR_ELEMENTS_COLOR) + self.draw_batch("POINTS", special_vertices, special_elements_color) + self.draw_batch("POINTS", selected_vertices, selected_elements_color) diff --git a/src/blenderbim/blenderbim/bim/module/system/prop.py b/src/blenderbim/blenderbim/bim/module/system/prop.py index 3d31414ffd..f1ffa3263c 100644 --- a/src/blenderbim/blenderbim/bim/module/system/prop.py +++ b/src/blenderbim/blenderbim/bim/module/system/prop.py @@ -18,6 +18,7 @@ import bpy from blenderbim.bim.module.system.data import SystemData +import blenderbim.bim.module.system.decorator as decorator from blenderbim.bim.prop import StrProperty, Attribute from bpy.types import PropertyGroup from bpy.props import ( @@ -44,6 +45,14 @@ class System(PropertyGroup): ifc_definition_id: IntProperty(name="IFC Definition ID") +def toggle_decorations(self, context): + toggle = self.should_draw_decorations + if toggle: + decorator.SystemDecorator.install(context) + else: + decorator.SystemDecorator.uninstall() + + class BIMSystemProperties(PropertyGroup): system_attributes: CollectionProperty(name="System Attributes", type=Attribute) is_editing: BoolProperty(name="Is Editing", default=False) @@ -52,3 +61,6 @@ class BIMSystemProperties(PropertyGroup): active_system_index: IntProperty(name="Active System Index") active_system_id: IntProperty(name="Active System Id") system_class: EnumProperty(items=get_system_class, name="Class") + should_draw_decorations: BoolProperty( + name="Should Draw Decorations", description="Toggle system decorations", update=toggle_decorations + ) diff --git a/src/blenderbim/blenderbim/bim/module/system/ui.py b/src/blenderbim/blenderbim/bim/module/system/ui.py index 5b60c151e7..d8e6b764c6 100644 --- a/src/blenderbim/blenderbim/bim/module/system/ui.py +++ b/src/blenderbim/blenderbim/bim/module/system/ui.py @@ -101,6 +101,10 @@ class BIM_PT_object_systems(Panel): if not ObjectSystemData.is_loaded: ObjectSystemData.load() self.props = context.scene.BIMSystemProperties + + row = self.layout.row(align=True) + row.prop(self.props, "should_draw_decorations") + if self.props.is_editing: row = self.layout.row() row.alignment = "RIGHT" diff --git a/src/blenderbim/blenderbim/tool/system.py b/src/blenderbim/blenderbim/tool/system.py index 13e25cf6af..87bdcc1a65 100644 --- a/src/blenderbim/blenderbim/tool/system.py +++ b/src/blenderbim/blenderbim/tool/system.py @@ -182,3 +182,80 @@ class System(blenderbim.core.tool.System): @classmethod def set_active_system(cls, system): bpy.context.scene.BIMSystemProperties.active_system_id = system.id() + + @classmethod + def get_decoration_data(cls): + all_vertices = [] + preview_edges = [] + special_vertices = [] + selected_edges = [] + selected_vertices = [] + + si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + start_vert_i = 0 + + if bpy.context.active_object and (active_element := tool.Ifc.get_entity(bpy.context.active_object)): + selected_elements = cls.get_connected_elements(active_element) + else: + selected_elements = set() + + # TODO: get only objects visible in viewport + objects = set(bpy.data.objects) - set(bpy.data.collections["Types"].objects) + for obj in objects: + start_vert_i = len(all_vertices) + if obj.hide_get(): + continue + + if not isinstance(obj.data, bpy.types.Mesh): + continue + + element = tool.Ifc.get_entity(obj) + if not element: + continue + + if not cls.is_mep_element(element): + continue + + ports = tool.System.get_ports(element) + + for port in ports: + position = tool.Model.get_element_matrix(port).translation * si_conversion + all_vertices.append(position) + + verts = range(start_vert_i, start_vert_i + len(ports)) + edges = [(i, i + 1) for i in range(start_vert_i, start_vert_i + len(ports) - 1)] + if element in selected_elements: + selected_vertices.extend(verts) + selected_edges.extend(edges) + else: + special_vertices.extend(verts) + preview_edges.extend(edges) + + decoration_data = { + "all_vertices": all_vertices, + "preview_edges": preview_edges, + "special_vertices": [all_vertices[i] for i in special_vertices], + "selected_edges": selected_edges, + "selected_vertices": [all_vertices[i] for i in selected_vertices], + } + return decoration_data + + @classmethod + def get_connected_elements(cls, element, elements=None): + if elements is None: + elements = set((element,)) + + connected_elements = ifcopenshell.util.system.get_connected_from(element) + connected_elements += ifcopenshell.util.system.get_connected_to(element) + + for element in connected_elements: + if element in elements: + continue + elements.add(element) + cls.get_connected_elements(element, elements) + + return elements + + @classmethod + def is_mep_element(cls, element): + return element.is_a("IfcFlowSegment") or element.is_a("IfcFlowFitting") From 9e67565c71bbe3d0ea45302674345f47572ed874 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Wed, 20 Sep 2023 08:54:04 -0500 Subject: [PATCH 126/190] Refresh aggregate from selecting a part --- .../blenderbim/bim/module/geometry/operator.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/geometry/operator.py b/src/blenderbim/blenderbim/bim/module/geometry/operator.py index 952198aa10..0fe53c117a 100644 --- a/src/blenderbim/blenderbim/bim/module/geometry/operator.py +++ b/src/blenderbim/blenderbim/bim/module/geometry/operator.py @@ -1186,10 +1186,17 @@ class RefreshAggregate(bpy.types.Operator): selected_root_obj = context.selected_objects[0] selected_root_entity = tool.Ifc.get_entity(selected_root_obj) - if not selected_root_entity.is_a("IfcElementAssembly"): - self.report({"INFO"}, "Object is not IfcElementAssembly.") + if selected_root_entity.is_a("IfcElementAssembly"): + return + elif selected_root_entity.Decomposes: + if selected_root_entity.Decomposes[0].RelatingObject.is_a("IfcElementAssembly"): + selected_root_entity = selected_root_entity.Decomposes[0].RelatingObject + selected_root_obj = tool.Ifc.get_object(selected_root_entity) + else: + self.report({"INFO"}, "Object is not part of a IfcElementAssembly.") return {"FINISHED"} + pset = ifcopenshell.util.element.get_pset(selected_root_entity, "BBIM_Aggregate_Data") if not pset: self.report({"INFO"}, "Object is not part of an assembly aggregate.") From 62fa0bb2187f03158d2881eb4216a5a65cdc9773 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 21 Sep 2023 13:21:13 +1000 Subject: [PATCH 127/190] Migrate more documentation to the official Sphinx docs and mention IfcOpenShell-WASM as an installation option --- src/bcf/README.md | 85 +------------------ src/bsdd/README.md | 20 +---- .../docs/bimserver-plugin.rst | 15 +--- src/ifcopenshell-python/docs/bimtester.rst | 14 +-- src/ifcopenshell-python/docs/bsdd.rst | 56 ++++++++++++ src/ifcopenshell-python/docs/conf.py | 1 + src/ifcopenshell-python/docs/ifccobie.rst | 16 ---- src/ifcopenshell-python/docs/ifcfm.rst | 4 + src/ifcopenshell-python/docs/ifcmax.rst | 20 +++++ .../docs/ifcopenshell-python/installation.rst | 18 +++- src/ifcopenshell-python/docs/index.rst | 5 +- 11 files changed, 107 insertions(+), 147 deletions(-) create mode 100644 src/ifcopenshell-python/docs/bsdd.rst delete mode 100644 src/ifcopenshell-python/docs/ifccobie.rst create mode 100644 src/ifcopenshell-python/docs/ifcfm.rst create mode 100644 src/ifcopenshell-python/docs/ifcmax.rst diff --git a/src/bcf/README.md b/src/bcf/README.md index a792c4f79f..aa1e4951e3 100644 --- a/src/bcf/README.md +++ b/src/bcf/README.md @@ -1,84 +1,5 @@ # bcf -A simple Python implementation of BCF. -Manipulation of BCF-XML is available via `bcfxml.py` and manipulation of BCF-API -is available via `bcfapi.py`. - -It tries to support BCF-XML version 2.1 and 3.0, and BCF-API 3.0. - -## bcfxml - -The `bcfxml.load` function lets you read a BCF-XML file. -It takes care of using the right version based on the "bcf.version" file contained in the BCF package. - -The BCF files are extracted and parsed on-demand, and edits are stored in memory until you call the `save` method. - -```python -from bcf.bcfxml import load - -# Load a project -with load("/path/to/file.bcf") as bcfxml: - project = bcfxml.project - print(project.name) - - # To edit a project, just modify the object directly - bcfxml.project.name = "New name" - - # Get a dictionary of topics - topics = bcfxml.topics - - for topic_guid, topic_handler in bcfxml.topics.items(): - topic = topic_handler.topic - print("Topic guid is", topic.guid) - print("Topic title is", topic.title) - - # Fetch extra data about a topic - header = topic_handler.header - comments = topic_handler.comments - viewpoints = topic_handler.viewpoints - - for comment in comments: - print(comment.guid) - print(comment.comment) - print(comment.author) - - # Get a particular topic - topic = bcfxml.get_topic(guid) - - # Modify a topic - topic.title = "New title" - - bcfxml.save() -``` - -## bcfapi - -The `bcfapi` module lets you interact with the BCF-API standard. - -```python -from bcf.v3.bcfapi import FoundationClient, BcfClient - -foundation_client = FoundationClient("YOUR_CLIENT_ID", "YOUR_CLIENT_SECRET", "OPENCDE_BASEURL") -auth_methods = foundation_client.get_auth_methods() - -# Our library currently only implements the authorization_code flow -if "authorization_code" in auth_methods: - foundation_client.login() - -bcf_client = BcfClient(foundation_client) - -versions = foundation_client.get_versions() -for version in versions: -if "3.0" in versions: - if version["api_id"] == "bcf" and version["version_id"] == "3.0": - bcf_client.set_version(version) - -data = bcf_client.get_projects() -print(data) -project_id = data[0]["project_id"] -print(project_id) -data = bcf_client.get_project(project_id) -print(data) -data = bcf_client.get_extensions(project_id) -print(data) -``` +A simple Python implementation of the BCF standard. Manipulation of BCF-XML is +available via `bcfxml.py` and manipulation of BCF-API is available via +`bcfapi.py`. diff --git a/src/bsdd/README.md b/src/bsdd/README.md index 215845a321..5d92dbe9d5 100644 --- a/src/bsdd/README.md +++ b/src/bsdd/README.md @@ -1,21 +1,3 @@ # bsdd -An experimental work in progress library to interact with the buildingSMART Data Dictionary (bSDD) API. - -More reading: - - * [Swagger API docs](https://bs-dd-api-prototype.azurewebsites.net/swagger/index.html) - * [bSDD Github Repository](https://github.com/buildingSMART/bSDD) - -# Demo - -Let's replicate the SketchUp example: - -``` -client = Client() -pprint(client.Domain()) -pprint(client.SearchListOpen("http://identifier.buildingsmart.org/uri/nlsfb/nlsfb2005-2.2", RelatedIfcEntity="IfcWall")) -data = client.Classification("http://identifier.buildingsmart.org/uri/nlsfb/nlsfb2005-2.2/class/21.21") -pprint(data) -apply_ifc_classification_properties(ifc_file, element, data["classificationProperties"]) -``` +A library to interact with the buildingSMART Data Dictionary (bSDD) API. diff --git a/src/ifcopenshell-python/docs/bimserver-plugin.rst b/src/ifcopenshell-python/docs/bimserver-plugin.rst index 432c499f87..b9d9beae07 100644 --- a/src/ifcopenshell-python/docs/bimserver-plugin.rst +++ b/src/ifcopenshell-python/docs/bimserver-plugin.rst @@ -1,16 +1,5 @@ BIMServer-Plugin ================ -This documentation is free software! You are free to contribute and help write -this document. - -.. toctree:: - :maxdepth: 1 - :caption: Contents: - -Indices and tables ------------------- - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` +The BIMServer-Plugin is a plugin to the open source BIMServer CDE to allow you +to use IfcOpenShell to parse, view, and audit models. diff --git a/src/ifcopenshell-python/docs/bimtester.rst b/src/ifcopenshell-python/docs/bimtester.rst index 8640df8bab..bdecbc2131 100644 --- a/src/ifcopenshell-python/docs/bimtester.rst +++ b/src/ifcopenshell-python/docs/bimtester.rst @@ -1,16 +1,4 @@ BIMTester ========= -This documentation is free software! You are free to contribute and help write -this document. - -.. toctree:: - :maxdepth: 1 - :caption: Contents: - -Indices and tables ------------------- - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` +BIMTester is a utility that allows you to write Gherkin-based tests for models. diff --git a/src/ifcopenshell-python/docs/bsdd.rst b/src/ifcopenshell-python/docs/bsdd.rst new file mode 100644 index 0000000000..d8f53b4f0f --- /dev/null +++ b/src/ifcopenshell-python/docs/bsdd.rst @@ -0,0 +1,56 @@ +bSDD +==== + +The **buildingSMART Data Dictionary** (bSDD) is an online RESTful centralised +API provided by buildingSMART that allows you to search for standardised +classifications and properties. + +For example, if you want to assign a Uniclass classification system (popular in +the UK) or an Omniclass classification system (popular in the US) to elements +in your model, instead of downloading the classification system from their +website, you can directly search the bSDD. This ensures that you are always up +to date, and that codes are entered correctly (without spelling mistakes, +correct formatting, etc). + +The bSDD search results may also be filtered based on IFC class. This will make +it quick to shortlist relevant classification codes and properties to a +particular object. + +The bSDD also stores information on whether or not classification systems +require additional standard properties to be filled out, and whether they +should be filled out in a particular way. For example, all countries need to +fill out a "Fire Rating" property for walls, but they have different ways to +fill it out. Local governments (or companies) may submit their standard to the +bSDD so that all bSDD-compatible BIM applications can look up the property and +fill it out in a standardised way (such as picking for a list of preset +possible values defined by the local government). + +More reading: + +1. `Swagger API docs `_ +2. `bSDD Github Repository `_ + +Examples +-------- + +Learning how to use the bSDD is best done by reading the official Swagger API docs. + +.. code-block:: python + + client = Client() + + # Get a list of "dictionary domains". For example, Uniclass (by the NBS organisation) might be one domain. + print(client.Domain()) + + # For example, search the Netherland's Nlsfb2005 classification standard for all codes that apply to an IfcWall. + print(client.SearchListOpen("http://identifier.buildingsmart.org/uri/nlsfb/nlsfb2005-2.2", RelatedIfcEntity="IfcWall")) + + # Alternatively, search up a particular classification code. + data = client.Classification("http://identifier.buildingsmart.org/uri/nlsfb/nlsfb2005-2.2/class/21.21") + print(data) + + # You may also apply default properties (if the classification system on + # the bSDD defines them) to your IFC element. For example, if a + # classification code is for a load bearing wall, it can automatically set + # the "LoadBearing" property to True for you. + apply_ifc_classification_properties(ifc_file, element, data["classificationProperties"]) diff --git a/src/ifcopenshell-python/docs/conf.py b/src/ifcopenshell-python/docs/conf.py index b766df476c..4bc3f18fd8 100644 --- a/src/ifcopenshell-python/docs/conf.py +++ b/src/ifcopenshell-python/docs/conf.py @@ -67,6 +67,7 @@ autoapi_type = 'python' # autoapi works by reading source code instead of importing modules autoapi_dirs = ['../ifcopenshell', '../../ifcdiff', '../../ifcpatch/ifcpatch'] +autoapi_dirs = ['../../ifcdiff', '../../ifcpatch/ifcpatch'] # These are auto-generated based on the IFC schema, so exclude them autoapi_ignore = ['*ifcopenshell/express/rules*'] diff --git a/src/ifcopenshell-python/docs/ifccobie.rst b/src/ifcopenshell-python/docs/ifccobie.rst deleted file mode 100644 index 35ba047267..0000000000 --- a/src/ifcopenshell-python/docs/ifccobie.rst +++ /dev/null @@ -1,16 +0,0 @@ -IfcCOBie -======== - -This documentation is free software! You are free to contribute and help write -this document. - -.. toctree:: - :maxdepth: 1 - :caption: Contents: - -Indices and tables ------------------- - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` diff --git a/src/ifcopenshell-python/docs/ifcfm.rst b/src/ifcopenshell-python/docs/ifcfm.rst new file mode 100644 index 0000000000..cefbe99a87 --- /dev/null +++ b/src/ifcopenshell-python/docs/ifcfm.rst @@ -0,0 +1,4 @@ +IfcFM +===== + +IfcFM is a utility to diff --git a/src/ifcopenshell-python/docs/ifcmax.rst b/src/ifcopenshell-python/docs/ifcmax.rst new file mode 100644 index 0000000000..a564e15b93 --- /dev/null +++ b/src/ifcopenshell-python/docs/ifcmax.rst @@ -0,0 +1,20 @@ +IfcMax +====== + +IfcMax is a 3ds Max importer plugin able to import the IFC file format. + +Community builds are available for 3ds Max by Josef Wienerroither (also known +as ``FrogsInSpace`` or ``spacefrog``). Builds are available for IfcOpenShell +v0.7.0 for 3ds Max version 2020-2024. Older builds are also available for +IfcOpenShell v0.6.0 for 3ds Max version 2015-2022. + +It is recommended to use the latest version of IfcOpenShell and 3ds Max. + +- `Visit FrogsInSpace official website for IfcMax `__. +- `Download IfcMax plugins `__. + +.. note:: + + This plugin is purely an importer and does not handle native IFC authoring + or exporting. For more information for native IFC authoring, we recommend + using the :doc:`BlenderBIM Add-on`. diff --git a/src/ifcopenshell-python/docs/ifcopenshell-python/installation.rst b/src/ifcopenshell-python/docs/ifcopenshell-python/installation.rst index 5c0d1b045a..8dc7811f1d 100644 --- a/src/ifcopenshell-python/docs/ifcopenshell-python/installation.rst +++ b/src/ifcopenshell-python/docs/ifcopenshell-python/installation.rst @@ -11,9 +11,10 @@ packages**. If you aren't a programmer, go for the **BlenderBIM Add-on**. 4. **Docker** is recommended for developers using Docker. 5. **AWS Lambda** is recommended for developers using AWS Lambda functions. 6. **Google Colab** is recommended for developers using Google Colab. -7. **Using the BlenderBIM Add-on** is recommended for non-developers wanting a graphical interface. -8. **From source with precompiled binaries** is recommended for developers actively working with the Python code. -9. **Compiling from source** is recommended for developers actively working with the C++ core. +7. **Web Assembly** is recommended for developers experimenting with IfcOpenShell on the web. +8. **Using the BlenderBIM Add-on** is recommended for non-developers wanting a graphical interface. +9. **From source with precompiled binaries** is recommended for developers actively working with the Python code. +10. **Compiling from source** is recommended for developers actively working with the C++ core. Pre-built packages ------------------ @@ -197,6 +198,17 @@ local system. `__ to launch a simple notebook. +Web Assembly +------------ + +IfcOpenShell is available as technology preview to be run using WASM. This +allows you to run IfcOpenShell in a browser using pyodide. This implementation +is incredibly heavy and will incur a long load time, but once loaded, will give +you full access to the entire IfcOpenShell API. + +`Click here `__ to learn how to +use WASM. + Using the BlenderBIM Add-on --------------------------- diff --git a/src/ifcopenshell-python/docs/index.rst b/src/ifcopenshell-python/docs/index.rst index f5bd6086d0..4aca839ad3 100644 --- a/src/ifcopenshell-python/docs/index.rst +++ b/src/ifcopenshell-python/docs/index.rst @@ -22,16 +22,19 @@ IfcOpenShell is a suite of developer libraries and utilities to manipulate OpenB :maxdepth: 1 :caption: Utilities: + bcf bimserver-plugin bimtester + bsdd ifc2ca ifc4d ifc5d ifccityjson ifcclash - ifccobie ifccsv ifcdiff + ifcfm + ifcmax ifcpatch ifcsverchok ifctester From 29b0bcb6b95273fcacfb90e49d7f04c58b8e69f3 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 21 Sep 2023 13:23:43 +1000 Subject: [PATCH 128/190] Forgot to commit BCF docs --- src/ifcopenshell-python/docs/bcf.rst | 112 +++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 src/ifcopenshell-python/docs/bcf.rst diff --git a/src/ifcopenshell-python/docs/bcf.rst b/src/ifcopenshell-python/docs/bcf.rst new file mode 100644 index 0000000000..6e09d19de6 --- /dev/null +++ b/src/ifcopenshell-python/docs/bcf.rst @@ -0,0 +1,112 @@ +BCF +=== + +**BIM Collaboration Format** (BCF) is a standard by buildingSMART to manage and +exchange coordination topics between disciplines collaborating on a project. +For example, when there is an issue during the design, engineering, or +construction of a project, a topic may be created, assigned, prioritised, +commented, or linked to objects in a BIM model or camera location. + +There are two implementations of BCF: + +1. **BCF-XML**: an XML file-based exchange of collaboration topics. This is + useful for mass imports, exports, data migration across CDEs, or fully + offline implementations. +2. **BCF-API**: an online RESTful API-based management of collaboration topics. + When topics are managed by a CDE, if the CDE follows the OpenCDE + specification by buildingSMART, their topics may be accessed and manipulated + using BCF. + +The upstream documentation by buildingSMART for BCF is available here: + +1. `BCF-XML 2.1 upstream documentation + `__. +2. `BCF-XML 3.0 upstream documentation + `__. +3. `BCF-API 3.0 upstream documentation + `__. + +The IfcOpenShell **BCF** library supports BCF-XML version 2.1 and 3.0, and +BCF-API 3.0. + +BCF-XML +------- + +The ``bcfxml.load`` function lets you read a BCF-XML file. + +It takes care of using the right version based on the "bcf.version" file +contained in the BCF package. + +The BCF files are extracted and parsed on-demand, and edits are stored in +memory until you call the `save` method. + +.. code-block:: python + + from bcf.bcfxml import load + + # Load a project + with load("/path/to/file.bcf") as bcfxml: + project = bcfxml.project + print(project.name) + + # To edit a project, just modify the object directly + bcfxml.project.name = "New name" + + # Get a dictionary of topics + topics = bcfxml.topics + + for topic_guid, topic_handler in bcfxml.topics.items(): + topic = topic_handler.topic + print("Topic guid is", topic.guid) + print("Topic title is", topic.title) + + # Fetch extra data about a topic + header = topic_handler.header + comments = topic_handler.comments + viewpoints = topic_handler.viewpoints + + for comment in comments: + print(comment.guid) + print(comment.comment) + print(comment.author) + + # Get a particular topic + topic = bcfxml.get_topic(guid) + + # Modify a topic + topic.title = "New title" + + bcfxml.save() + +BCF-API +------- + +The ``bcfapi`` module lets you interact with the BCF-API standard. + +.. code-block:: python + + from bcf.v3.bcfapi import FoundationClient, BcfClient + + foundation_client = FoundationClient("YOUR_CLIENT_ID", "YOUR_CLIENT_SECRET", "OPENCDE_BASEURL") + auth_methods = foundation_client.get_auth_methods() + + # Our library currently only implements the authorization_code flow + if "authorization_code" in auth_methods: + foundation_client.login() + + bcf_client = BcfClient(foundation_client) + + versions = foundation_client.get_versions() + for version in versions: + if "3.0" in versions: + if version["api_id"] == "bcf" and version["version_id"] == "3.0": + bcf_client.set_version(version) + + data = bcf_client.get_projects() + print(data) + project_id = data[0]["project_id"] + print(project_id) + data = bcf_client.get_project(project_id) + print(data) + data = bcf_client.get_extensions(project_id) + print(data) From b63f3f335c4685fc7c80dca4c138ca26d7ff2626 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Thu, 21 Sep 2023 13:33:26 +1000 Subject: [PATCH 129/190] Autogenerate API docs for more modules. --- src/ifcopenshell-python/docs/conf.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/ifcopenshell-python/docs/conf.py b/src/ifcopenshell-python/docs/conf.py index 4bc3f18fd8..0d981bfc71 100644 --- a/src/ifcopenshell-python/docs/conf.py +++ b/src/ifcopenshell-python/docs/conf.py @@ -66,8 +66,8 @@ autoapi_add_toctree_entry = True autoapi_type = 'python' # autoapi works by reading source code instead of importing modules -autoapi_dirs = ['../ifcopenshell', '../../ifcdiff', '../../ifcpatch/ifcpatch'] -autoapi_dirs = ['../../ifcdiff', '../../ifcpatch/ifcpatch'] +autoapi_dirs = ['../ifcopenshell', '../../bcf/src', '../../bsdd', '../../ifccsv', '../../ifcdiff', '../../ifcpatch/ifcpatch', '../../ifctester/ifctester'] +# autoapi_dirs = ['../../bcf/src', '../../bsdd', '../../ifccsv', '../../ifcdiff', '../../ifcpatch/ifcpatch', '../../ifctester/ifctester'] # These are auto-generated based on the IFC schema, so exclude them autoapi_ignore = ['*ifcopenshell/express/rules*'] From 9e4702c102c37c74db7fb89a21cb9f80f5fcc250 Mon Sep 17 00:00:00 2001 From: "Sigma Dimensions (Yass)" <79010126+myoualid@users.noreply.github.com> Date: Thu, 21 Sep 2023 06:04:24 +0100 Subject: [PATCH 130/190] small fix when calculating resource work --- src/blenderbim/blenderbim/bim/module/resource/operator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blenderbim/blenderbim/bim/module/resource/operator.py b/src/blenderbim/blenderbim/bim/module/resource/operator.py index fbdd4374f7..068212f68c 100644 --- a/src/blenderbim/blenderbim/bim/module/resource/operator.py +++ b/src/blenderbim/blenderbim/bim/module/resource/operator.py @@ -437,4 +437,4 @@ class CalculateResourceUsage(bpy.types.Operator, tool.Ifc.Operator): return False def _execute(self, context): - core.calculate_resource_usage(tool.Ifc, tool.Resource, resource=tool.Ifc.get().by_id(tool.Resource.get_highlighted_resource())) + core.calculate_resource_usage(tool.Ifc, tool.Resource, resource=tool.Resource.get_highlighted_resource()) From baffbd6a329462dc33d0fbef5fee1dd5ec061f0d Mon Sep 17 00:00:00 2001 From: Vukas Pajic Date: Thu, 21 Sep 2023 13:27:38 +0200 Subject: [PATCH 131/190] Update ids validation see https://github.com/IfcOpenShell/IfcOpenShell/issues/2933 --- src/ifctester/ifctester/ids.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/ifctester/ifctester/ids.py b/src/ifctester/ifctester/ids.py index 2993732b40..6aeef65d22 100644 --- a/src/ifctester/ifctester/ids.py +++ b/src/ifctester/ifctester/ids.py @@ -247,9 +247,7 @@ class Specification: if self.failed_entities: self.status = False elif self.maxOccurs == 0: - if (len(self.applicable_entities)) > 0 and len(self.requirements) == 0: - self.status = False - if (len(self.applicable_entities)) > 0 and (len(self.applicable_entities) - len(self.failed_entities)) > 0: + if (len(self.applicable_entities)) == 0: self.status = False def get_usage(self): From 9d3881b00e196dd08a97e8c272e6b75e27670b1b Mon Sep 17 00:00:00 2001 From: Vukas Pajic Date: Thu, 21 Sep 2023 16:14:37 +0200 Subject: [PATCH 132/190] typo --- src/ifctester/ifctester/ids.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ifctester/ifctester/ids.py b/src/ifctester/ifctester/ids.py index 6aeef65d22..c4766259bb 100644 --- a/src/ifctester/ifctester/ids.py +++ b/src/ifctester/ifctester/ids.py @@ -247,7 +247,7 @@ class Specification: if self.failed_entities: self.status = False elif self.maxOccurs == 0: - if (len(self.applicable_entities)) == 0: + if (len(self.applicable_entities)) > 0: self.status = False def get_usage(self): From 6536f7b56b72879a5d3c4ea26d416014c7713e62 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Thu, 21 Sep 2023 12:58:16 -0500 Subject: [PATCH 133/190] small fix for 9e67565 --- src/blenderbim/blenderbim/bim/module/geometry/operator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blenderbim/blenderbim/bim/module/geometry/operator.py b/src/blenderbim/blenderbim/bim/module/geometry/operator.py index 0fe53c117a..e603ef94ce 100644 --- a/src/blenderbim/blenderbim/bim/module/geometry/operator.py +++ b/src/blenderbim/blenderbim/bim/module/geometry/operator.py @@ -1187,7 +1187,7 @@ class RefreshAggregate(bpy.types.Operator): selected_root_entity = tool.Ifc.get_entity(selected_root_obj) if selected_root_entity.is_a("IfcElementAssembly"): - return + pass elif selected_root_entity.Decomposes: if selected_root_entity.Decomposes[0].RelatingObject.is_a("IfcElementAssembly"): selected_root_entity = selected_root_entity.Decomposes[0].RelatingObject From 2c685c846f955530d80bfdef6570c9fca25cb5fe Mon Sep 17 00:00:00 2001 From: Massimo Fabbro Date: Wed, 6 Sep 2023 18:45:10 +0200 Subject: [PATCH 134/190] Added calculating quantities function for Qto_DuctSegmentBaseQuantities and Qto_DuctFittingBaseQuantities --- .../bim/module/pset/calc_quantity_function_mapper.py | 8 ++++---- .../pset/notes_about_mapped_calculated_quantities.txt | 3 +++ 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/pset/calc_quantity_function_mapper.py b/src/blenderbim/blenderbim/bim/module/pset/calc_quantity_function_mapper.py index d366033073..c55b78b00b 100644 --- a/src/blenderbim/blenderbim/bim/module/pset/calc_quantity_function_mapper.py +++ b/src/blenderbim/blenderbim/bim/module/pset/calc_quantity_function_mapper.py @@ -71,10 +71,10 @@ mapper = { 'Area' : "get_net_side_area", }, 'Qto_DuctSegmentBaseQuantities' : { - 'Length' : None, + 'Length' : "get_length", 'GrossCrossSectionArea' : None, 'NetCrossSectionArea' : None, - 'OuterSurfaceArea' : None, + 'OuterSurfaceArea' : "get_outer_surface_area", 'GrossWeight' : None, }, 'Qto_TransformerBaseQuantities' : { @@ -203,10 +203,10 @@ mapper = { 'Weight' : None, }, 'Qto_DuctFittingBaseQuantities' : { - 'Length' : None, + 'Length' : "get_length", 'GrossCrossSectionArea' : None, 'NetCrossSectionArea' : None, - 'OuterSurfaceArea' : None, + 'OuterSurfaceArea' : "get_outer_surface_area", 'GrossWeight' : None, }, 'Qto_UnitaryControlElementBaseQuantities' : { diff --git a/src/blenderbim/blenderbim/bim/module/pset/notes_about_mapped_calculated_quantities.txt b/src/blenderbim/blenderbim/bim/module/pset/notes_about_mapped_calculated_quantities.txt index fb56c86ad0..73acaecd2f 100644 --- a/src/blenderbim/blenderbim/bim/module/pset/notes_about_mapped_calculated_quantities.txt +++ b/src/blenderbim/blenderbim/bim/module/pset/notes_about_mapped_calculated_quantities.txt @@ -31,6 +31,9 @@ NetFloorArea: it doesn't count the following entities contained in the spatial e NetVolume: like NetFloorArea, it doesn't count the following entities contained in the spatial entity: IfcColumn and IfcWall Also, the entire IfcColumn (or IfcWall) object volume is substracted, so it should be better to substract only the shared volume between IfcSpace and IfcColumn. Look at todo list +DUCT SEGMENTS AND DUCT FITTINGS +The length is calculated like a beam. Also outer surface area. Gross cross section area, net cross section area and weight are not calculated right now because the parametrically cross section area seems filled (without hole). + WEIGHT The object weight is calculated by multiplying the object mass density with the object volume (net or gross). It's only calculated if the object material has a MassDensity property in the Pset_MaterialCommon. From aefcedf75450f793fbcf5ccc74d1b4765c1d482b Mon Sep 17 00:00:00 2001 From: Massimo Fabbro Date: Sun, 27 Aug 2023 23:07:48 +0200 Subject: [PATCH 135/190] Add model test about manual booleans --- src/blenderbim/test/tool/test_model.py | 29 ++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/blenderbim/test/tool/test_model.py b/src/blenderbim/test/tool/test_model.py index 68c91a142e..90546c0a16 100644 --- a/src/blenderbim/test/tool/test_model.py +++ b/src/blenderbim/test/tool/test_model.py @@ -20,6 +20,7 @@ import bpy import ifcopenshell import blenderbim.core.tool import blenderbim.tool as tool +import numpy as np from test.bim.bootstrap import NewFile from blenderbim.tool.model import Model as subject @@ -50,3 +51,31 @@ class TestGenerateOccurrenceName(NewFile): bpy.context.scene.BIMModelProperties.occurrence_name_style = "CUSTOM" bpy.context.scene.BIMModelProperties.occurrence_name_function = '"Foobar"' assert subject.generate_occurrence_name(element_type, "IfcWall") == "Foobar" + +class TestGetManualBooleans(NewFile): + def test_run(self): + assert isinstance(subject(), blenderbim.core.tool.Model) + + def test_len_returned_boolean(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcProject") + length = ifcopenshell.api.run("unit.add_si_unit", ifc, unit_type="LENGTHUNIT") + ifcopenshell.api.run("unit.assign_unit", ifc, units=[length]) + ifcopenshell.api.run("unit.assign_unit", ifc) + element = ifc.createIfcColumn() + hea100 = ifc.create_entity( + "IfcIShapeProfileDef", ProfileName="HEA100", ProfileType="AREA", + OverallWidth=100, OverallDepth=96, WebThickness=5, FlangeThickness=8, FilletRadius=12, + ) + model3d = ifcopenshell.api.run("context.add_context", ifc, context_type="Model") + body = ifcopenshell.api.run("context.add_context", ifc,context_type="Model", context_identifier="Body", target_view="MODEL_VIEW", parent=model3d) + representation = ifcopenshell.api.run("geometry.add_profile_representation", ifc, context=body, profile=hea100, depth=5) + ifcopenshell.api.run("geometry.assign_representation", ifc, product=element, representation=representation) + matrix = np.eye(4) + matrix = ifcopenshell.util.placement.rotation(45,"X") @ matrix + matrix[:,3][0:3] = (0, 0, 3) + matrix = matrix.tolist() + ifcopenshell.api.run("geometry.add_boolean", ifc, representation = representation, type = "IfcHalfSpaceSolid", matrix = matrix) + assert len(subject.get_manual_booleans(element)) == 1 + From 57edd47ed76316322ab2ca220970abed180d18b0 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 22 Sep 2023 10:10:26 +0500 Subject: [PATCH 136/190] Fixed error editing pset with prop with empty prop.NominalValue Traceback: ``` Error: Python: Traceback (most recent call last): File "\blenderbim\bim\module\pset\operator.py", line 103, in execute self.load_from_pset_data(pset) File "\blenderbim\bim\module\pset\operator.py", line 226, in load_from_pset_data value = prop.NominalValue.wrappedValue AttributeError: 'NoneType' object has no attribute 'wrappedValue' ``` --- src/blenderbim/blenderbim/bim/module/pset/operator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blenderbim/blenderbim/bim/module/pset/operator.py b/src/blenderbim/blenderbim/bim/module/pset/operator.py index f5137c36e4..340a6b9f8c 100644 --- a/src/blenderbim/blenderbim/bim/module/pset/operator.py +++ b/src/blenderbim/blenderbim/bim/module/pset/operator.py @@ -223,7 +223,7 @@ class EnablePsetEditing(bpy.types.Operator): new.is_selected = enum in selected_enum_items else: if prop.is_a("IfcPropertySingleValue"): - value = prop.NominalValue.wrappedValue + value = prop.NominalValue.wrappedValue if prop.NominalValue else None elif prop.is_a("IfcPhysicalSimpleQuantity"): value = prop[3] new_prop = self.props.properties.add() From 6d14c2d448589a3dad10946cf5cb4a9eb6766c21 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 22 Sep 2023 11:24:39 +0500 Subject: [PATCH 137/190] removing arrays if all array elements are selected #3781 I think there is still some space to improve but it should cover the main problem with need going to array section to remove it Demo - https://imgur.com/a/9Z3t5Q8 --- .../bim/module/geometry/operator.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/blenderbim/blenderbim/bim/module/geometry/operator.py b/src/blenderbim/blenderbim/bim/module/geometry/operator.py index e603ef94ce..f17d68fb54 100644 --- a/src/blenderbim/blenderbim/bim/module/geometry/operator.py +++ b/src/blenderbim/blenderbim/bim/module/geometry/operator.py @@ -499,6 +499,8 @@ class OverrideDelete(bpy.types.Operator): def _execute(self, context): if self.is_batch: ifcopenshell.util.element.batch_remove_deep2(tool.Ifc.get()) + + self.process_arrays(context) for obj in context.selected_objects: element = tool.Ifc.get_entity(obj) if element: @@ -508,6 +510,7 @@ class OverrideDelete(bpy.types.Operator): tool.Geometry.delete_ifc_object(obj) else: bpy.data.objects.remove(obj) + if self.is_batch: old_file = tool.Ifc.get() old_file.end_transaction() @@ -528,6 +531,29 @@ class OverrideDelete(bpy.types.Operator): data["old_file"].redo() tool.Ifc.set(data["new_file"]) + def process_arrays(self, context): + selected_objects = set(context.selected_objects) + array_parents = set() + for obj in context.selected_objects: + element = tool.Ifc.get_entity(obj) + if not element: + continue + pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") + if not pset: + continue + array_parents.add(tool.Ifc.get().by_guid(pset["Parent"])) + + for array_parent in array_parents: + array_parent_obj = tool.Ifc.get_object(array_parent) + data = [(i, data) for i, data in enumerate(tool.Blender.Modifier.Array.get_modifiers_data(array_parent))] + # NOTE: there is a way to remove arrays more precisely but it's more complex + for i, modifier_data in reversed(data): + children = set(tool.Blender.Modifier.Array.get_children_objects(modifier_data)) + if children.issubset(selected_objects): + with context.temp_override(active_object=array_parent_obj): + bpy.ops.bim.remove_array(item=i) + else: + break class OverrideOutlinerDelete(bpy.types.Operator): bl_idname = "bim.override_outliner_delete" From 4a4a0f09ef2b9d514add007123abfd13d9e43295 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Fri, 22 Sep 2023 17:04:11 +1000 Subject: [PATCH 138/190] Finally purge IfcCOBie to be superseded by IfcFM --- README.md | 1 - src/blenderbim/blenderbim/bim/__init__.py | 1 - .../blenderbim/bim/module/cobie/__init__.py | 36 - .../blenderbim/bim/module/cobie/operator.py | 124 -- .../blenderbim/bim/module/cobie/prop.py | 38 - .../blenderbim/bim/module/cobie/ui.py | 63 - src/ifccobie/COPYING | 621 ------ src/ifccobie/COPYING.LESSER | 165 -- src/ifccobie/cobie.py | 1667 ----------------- src/ifccobie/get_maintainable_assets.py | 79 - src/ifccobie/icon.ico | Bin 104035 -> 0 bytes 11 files changed, 2795 deletions(-) delete mode 100644 src/blenderbim/blenderbim/bim/module/cobie/__init__.py delete mode 100644 src/blenderbim/blenderbim/bim/module/cobie/operator.py delete mode 100644 src/blenderbim/blenderbim/bim/module/cobie/prop.py delete mode 100644 src/blenderbim/blenderbim/bim/module/cobie/ui.py delete mode 100644 src/ifccobie/COPYING delete mode 100644 src/ifccobie/COPYING.LESSER delete mode 100755 src/ifccobie/cobie.py delete mode 100644 src/ifccobie/get_maintainable_assets.py delete mode 100644 src/ifccobie/icon.ico diff --git a/README.md b/README.md index e9416797e8..e8670871c8 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,6 @@ Those marked with an asterisk are part of IfcOpenShell. | ifcblender | Historic Blender IFC import add-on | LGPL-3.0-or-later\* | | ifccityjson | Convert CityJSON to IFC | LGPL-3.0-or-later | | ifcclash | Clash detection library and CLI app | LGPL-3.0-or-later | -| ifccobie | Extract IFC data for COBie handover requirements | LGPL-3.0-or-later | | ifcconvert | CLI app to convert IFC to many other formats | LGPL-3.0-or-later\* | | ifccsv | Library and CLI app to export and import schedules from IFC | LGPL-3.0-or-later | | ifcdiff | Compare changes between IFC models | LGPL-3.0-or-later | diff --git a/src/blenderbim/blenderbim/bim/__init__.py b/src/blenderbim/blenderbim/bim/__init__.py index c21240c12b..9528ab8869 100644 --- a/src/blenderbim/blenderbim/bim/__init__.py +++ b/src/blenderbim/blenderbim/bim/__init__.py @@ -46,7 +46,6 @@ modules = { "aggregate": None, "geometry": None, "fm": None, - "cobie": None, "resource": None, "cost": None, "sequence": None, diff --git a/src/blenderbim/blenderbim/bim/module/cobie/__init__.py b/src/blenderbim/blenderbim/bim/module/cobie/__init__.py deleted file mode 100644 index a1ad500ae2..0000000000 --- a/src/blenderbim/blenderbim/bim/module/cobie/__init__.py +++ /dev/null @@ -1,36 +0,0 @@ -# BlenderBIM Add-on - OpenBIM Blender Add-on -# Copyright (C) 2020, 2021 Dion Moult -# -# This file is part of BlenderBIM Add-on. -# -# BlenderBIM Add-on 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. -# -# BlenderBIM Add-on 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 BlenderBIM Add-on. If not, see . - -import bpy -from . import ui, prop, operator - -classes = ( - operator.SelectCobieIfcFile, - operator.SelectCobieJsonFile, - operator.ExecuteIfcCobie, - prop.COBieProperties, - ui.BIM_PT_cobie, -) - - -def register(): - bpy.types.Scene.COBieProperties = bpy.props.PointerProperty(type=prop.COBieProperties) - - -def unregister(): - del bpy.types.Scene.COBieProperties diff --git a/src/blenderbim/blenderbim/bim/module/cobie/operator.py b/src/blenderbim/blenderbim/bim/module/cobie/operator.py deleted file mode 100644 index 3f1a512116..0000000000 --- a/src/blenderbim/blenderbim/bim/module/cobie/operator.py +++ /dev/null @@ -1,124 +0,0 @@ -# BlenderBIM Add-on - OpenBIM Blender Add-on -# Copyright (C) 2020, 2021 Dion Moult -# -# This file is part of BlenderBIM Add-on. -# -# BlenderBIM Add-on 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. -# -# BlenderBIM Add-on 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 BlenderBIM Add-on. If not, see . - -import bpy -import os -import logging -import ifcopenshell -import json -import webbrowser -import tempfile -from blenderbim.bim.ifc import IfcStore - - -class SelectCobieIfcFile(bpy.types.Operator): - bl_idname = "bim.select_cobie_ifc_file" - bl_label = "Select COBie IFC File" - bl_options = {"REGISTER", "UNDO"} - filepath: bpy.props.StringProperty(subtype="FILE_PATH") - - def execute(self, context): - context.scene.COBieProperties.cobie_ifc_file = self.filepath - return {"FINISHED"} - - def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} - - -class SelectCobieJsonFile(bpy.types.Operator): - bl_idname = "bim.select_cobie_json_file" - bl_label = "Select COBie JSON File" - bl_options = {"REGISTER", "UNDO"} - filepath: bpy.props.StringProperty(subtype="FILE_PATH") - - def execute(self, context): - context.scene.COBieProperties.cobie_json_file = self.filepath - return {"FINISHED"} - - def invoke(self, context, event): - context.window_manager.fileselect_add(self) - return {"RUNNING_MODAL"} - - -class ExecuteIfcCobie(bpy.types.Operator): - bl_idname = "bim.execute_ifc_cobie" - bl_label = "Execute IFCCOBie" - file_format: bpy.props.StringProperty() - - @classmethod - def poll(cls, context): - props = context.scene.COBieProperties - return props.should_load_from_memory or props.cobie_ifc_file - - def execute(self, context): - from cobie import IfcCobieParser - - props = context.scene.COBieProperties - - if props.should_load_from_memory: - output_dir = tempfile.gettempdir() - else: - output_dir = os.path.dirname(props.cobie_ifc_file) - - output = os.path.join(output_dir, "output") - logger = logging.getLogger("IFCtoCOBie") - fh = logging.FileHandler(os.path.join(output_dir, "cobie.log")) - fh.setLevel(logging.DEBUG) - fh.setFormatter(logging.Formatter("%(asctime)s : %(levelname)s : %(message)s")) - logger = logging.getLogger("IFCtoCOBie") - logger.addHandler(fh) - selector = ifcopenshell.util.selector.Selector() - if props.cobie_json_file: - with open(props.cobie_json_file, "r") as f: - custom_data = json.load(f) - else: - custom_data = {} - parser = IfcCobieParser(logger, selector) - - ifc_file = IfcStore.get_file() - - if not (ifc_file and props.should_load_from_memory): - ifc_file = props.cobie_ifc_file - - parser.parse( - ifc_file, - props.cobie_types, - props.cobie_components, - custom_data, - ) - if self.file_format == "xlsx": - from cobie import CobieXlsWriter - - writer = CobieXlsWriter(parser, output) - writer.write() - webbrowser.open("file://" + output + "." + self.file_format) - elif self.file_format == "ods": - from cobie import CobieOdsWriter - - writer = CobieOdsWriter(parser, output) - writer.write() - webbrowser.open("file://" + output + "." + self.file_format) - else: - from cobie import CobieCsvWriter - - writer = CobieCsvWriter(parser, output_dir) - writer.write() - webbrowser.open("file://" + output_dir) - webbrowser.open("file://" + output_dir + "/cobie.log") - return {"FINISHED"} diff --git a/src/blenderbim/blenderbim/bim/module/cobie/prop.py b/src/blenderbim/blenderbim/bim/module/cobie/prop.py deleted file mode 100644 index 567a0f9960..0000000000 --- a/src/blenderbim/blenderbim/bim/module/cobie/prop.py +++ /dev/null @@ -1,38 +0,0 @@ -# BlenderBIM Add-on - OpenBIM Blender Add-on -# Copyright (C) 2020, 2021 Dion Moult -# -# This file is part of BlenderBIM Add-on. -# -# BlenderBIM Add-on 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. -# -# BlenderBIM Add-on 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 BlenderBIM Add-on. If not, see . - -import bpy -from bpy.types import PropertyGroup -from bpy.props import ( - PointerProperty, - StringProperty, - EnumProperty, - BoolProperty, - IntProperty, - FloatProperty, - FloatVectorProperty, - CollectionProperty, -) - - -class COBieProperties(PropertyGroup): - cobie_ifc_file: StringProperty(default="", name="COBie IFC File") - cobie_types: StringProperty(default=".COBieType", name="COBie Types") - cobie_components: StringProperty(default=".COBie", name="COBie Components") - cobie_json_file: StringProperty(default="", name="COBie JSON File") - should_load_from_memory: BoolProperty(default=False, name="Load from Memory") diff --git a/src/blenderbim/blenderbim/bim/module/cobie/ui.py b/src/blenderbim/blenderbim/bim/module/cobie/ui.py deleted file mode 100644 index a78cff2b62..0000000000 --- a/src/blenderbim/blenderbim/bim/module/cobie/ui.py +++ /dev/null @@ -1,63 +0,0 @@ -# BlenderBIM Add-on - OpenBIM Blender Add-on -# Copyright (C) 2020, 2021 Dion Moult -# -# This file is part of BlenderBIM Add-on. -# -# BlenderBIM Add-on 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. -# -# BlenderBIM Add-on 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 BlenderBIM Add-on. If not, see . - -import blenderbim.tool as tool -from bpy.types import Panel -from blenderbim.bim.ifc import IfcStore - - -class BIM_PT_cobie(Panel): - bl_label = "COBie" - bl_idname = "BIM_PT_cobie" - bl_space_type = "PROPERTIES" - bl_region_type = "WINDOW" - bl_context = "scene" - bl_parent_id = "BIM_PT_tab_handover" - - def draw(self, context): - layout = self.layout - layout.use_property_split = True - - scene = context.scene - props = scene.COBieProperties - - if IfcStore.get_file(): - row = layout.row() - row.prop(props, "should_load_from_memory") - - if not IfcStore.get_file() or not props.should_load_from_memory: - row = layout.row(align=True) - row.prop(props, "cobie_ifc_file") - row.operator("bim.select_cobie_ifc_file", icon="FILE_FOLDER", text="") - - row = layout.row() - row.prop(props, "cobie_types") - row = layout.row() - row.prop(props, "cobie_components") - - row = layout.row(align=True) - row.prop(props, "cobie_json_file") - row.operator("bim.select_cobie_json_file", icon="FILE_FOLDER", text="") - - row = layout.row() - op = row.operator("bim.execute_ifc_cobie", text="CSV") - op.file_format = "csv" - op = row.operator("bim.execute_ifc_cobie", text="ODS") - op.file_format = "ods" - op = row.operator("bim.execute_ifc_cobie", text="XLSX") - op.file_format = "xlsx" diff --git a/src/ifccobie/COPYING b/src/ifccobie/COPYING deleted file mode 100644 index 810fce6e9b..0000000000 --- a/src/ifccobie/COPYING +++ /dev/null @@ -1,621 +0,0 @@ - GNU GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - Preamble - - The GNU General Public License is a free, copyleft license for -software and other kinds of works. - - The licenses for most software and other practical works are designed -to take away your freedom to share and change the works. By contrast, -the GNU General Public License is intended to guarantee your freedom to -share and change all versions of a program--to make sure it remains free -software for all its users. We, the Free Software Foundation, use the -GNU General Public License for most of our software; it applies also to -any other work released this way by its authors. You can apply it to -your programs, too. - - When we speak of free software, we are referring to freedom, not -price. Our General Public Licenses are designed to make sure that you -have the freedom to distribute copies of free software (and charge for -them if you wish), that you receive source code or can get it if you -want it, that you can change the software or use pieces of it in new -free programs, and that you know you can do these things. - - To protect your rights, we need to prevent others from denying you -these rights or asking you to surrender the rights. Therefore, you have -certain responsibilities if you distribute copies of the software, or if -you modify it: responsibilities to respect the freedom of others. - - For example, if you distribute copies of such a program, whether -gratis or for a fee, you must pass on to the recipients the same -freedoms that you received. You must make sure that they, too, receive -or can get the source code. And you must show them these terms so they -know their rights. - - Developers that use the GNU GPL protect your rights with two steps: -(1) assert copyright on the software, and (2) offer you this License -giving you legal permission to copy, distribute and/or modify it. - - For the developers' and authors' protection, the GPL clearly explains -that there is no warranty for this free software. For both users' and -authors' sake, the GPL requires that modified versions be marked as -changed, so that their problems will not be attributed erroneously to -authors of previous versions. - - Some devices are designed to deny users access to install or run -modified versions of the software inside them, although the manufacturer -can do so. This is fundamentally incompatible with the aim of -protecting users' freedom to change the software. The systematic -pattern of such abuse occurs in the area of products for individuals to -use, which is precisely where it is most unacceptable. Therefore, we -have designed this version of the GPL to prohibit the practice for those -products. If such problems arise substantially in other domains, we -stand ready to extend this provision to those domains in future versions -of the GPL, as needed to protect the freedom of users. - - Finally, every program is threatened constantly by software patents. -States should not allow patents to restrict development and use of -software on general-purpose computers, but in those that do, we wish to -avoid the special danger that patents applied to a free program could -make it effectively proprietary. To prevent this, the GPL assures that -patents cannot be used to render the program non-free. - - The precise terms and conditions for copying, distribution and -modification follow. - - TERMS AND CONDITIONS - - 0. Definitions. - - "This License" refers to version 3 of the GNU General Public License. - - "Copyright" also means copyright-like laws that apply to other kinds of -works, such as semiconductor masks. - - "The Program" refers to any copyrightable work licensed under this -License. Each licensee is addressed as "you". "Licensees" and -"recipients" may be individuals or organizations. - - To "modify" a work means to copy from or adapt all or part of the work -in a fashion requiring copyright permission, other than the making of an -exact copy. The resulting work is called a "modified version" of the -earlier work or a work "based on" the earlier work. - - A "covered work" means either the unmodified Program or a work based -on the Program. - - To "propagate" a work means to do anything with it that, without -permission, would make you directly or secondarily liable for -infringement under applicable copyright law, except executing it on a -computer or modifying a private copy. Propagation includes copying, -distribution (with or without modification), making available to the -public, and in some countries other activities as well. - - To "convey" a work means any kind of propagation that enables other -parties to make or receive copies. Mere interaction with a user through -a computer network, with no transfer of a copy, is not conveying. - - An interactive user interface displays "Appropriate Legal Notices" -to the extent that it includes a convenient and prominently visible -feature that (1) displays an appropriate copyright notice, and (2) -tells the user that there is no warranty for the work (except to the -extent that warranties are provided), that licensees may convey the -work under this License, and how to view a copy of this License. If -the interface presents a list of user commands or options, such as a -menu, a prominent item in the list meets this criterion. - - 1. Source Code. - - The "source code" for a work means the preferred form of the work -for making modifications to it. "Object code" means any non-source -form of a work. - - A "Standard Interface" means an interface that either is an official -standard defined by a recognized standards body, or, in the case of -interfaces specified for a particular programming language, one that -is widely used among developers working in that language. - - The "System Libraries" of an executable work include anything, other -than the work as a whole, that (a) is included in the normal form of -packaging a Major Component, but which is not part of that Major -Component, and (b) serves only to enable use of the work with that -Major Component, or to implement a Standard Interface for which an -implementation is available to the public in source code form. A -"Major Component", in this context, means a major essential component -(kernel, window system, and so on) of the specific operating system -(if any) on which the executable work runs, or a compiler used to -produce the work, or an object code interpreter used to run it. - - The "Corresponding Source" for a work in object code form means all -the source code needed to generate, install, and (for an executable -work) run the object code and to modify the work, including scripts to -control those activities. However, it does not include the work's -System Libraries, or general-purpose tools or generally available free -programs which are used unmodified in performing those activities but -which are not part of the work. For example, Corresponding Source -includes interface definition files associated with source files for -the work, and the source code for shared libraries and dynamically -linked subprograms that the work is specifically designed to require, -such as by intimate data communication or control flow between those -subprograms and other parts of the work. - - The Corresponding Source need not include anything that users -can regenerate automatically from other parts of the Corresponding -Source. - - The Corresponding Source for a work in source code form is that -same work. - - 2. Basic Permissions. - - All rights granted under this License are granted for the term of -copyright on the Program, and are irrevocable provided the stated -conditions are met. This License explicitly affirms your unlimited -permission to run the unmodified Program. The output from running a -covered work is covered by this License only if the output, given its -content, constitutes a covered work. This License acknowledges your -rights of fair use or other equivalent, as provided by copyright law. - - You may make, run and propagate covered works that you do not -convey, without conditions so long as your license otherwise remains -in force. You may convey covered works to others for the sole purpose -of having them make modifications exclusively for you, or provide you -with facilities for running those works, provided that you comply with -the terms of this License in conveying all material for which you do -not control copyright. Those thus making or running the covered works -for you must do so exclusively on your behalf, under your direction -and control, on terms that prohibit them from making any copies of -your copyrighted material outside their relationship with you. - - Conveying under any other circumstances is permitted solely under -the conditions stated below. Sublicensing is not allowed; section 10 -makes it unnecessary. - - 3. Protecting Users' Legal Rights From Anti-Circumvention Law. - - No covered work shall be deemed part of an effective technological -measure under any applicable law fulfilling obligations under article -11 of the WIPO copyright treaty adopted on 20 December 1996, or -similar laws prohibiting or restricting circumvention of such -measures. - - When you convey a covered work, you waive any legal power to forbid -circumvention of technological measures to the extent such circumvention -is effected by exercising rights under this License with respect to -the covered work, and you disclaim any intention to limit operation or -modification of the work as a means of enforcing, against the work's -users, your or third parties' legal rights to forbid circumvention of -technological measures. - - 4. Conveying Verbatim Copies. - - You may convey verbatim copies of the Program's source code as you -receive it, in any medium, provided that you conspicuously and -appropriately publish on each copy an appropriate copyright notice; -keep intact all notices stating that this License and any -non-permissive terms added in accord with section 7 apply to the code; -keep intact all notices of the absence of any warranty; and give all -recipients a copy of this License along with the Program. - - You may charge any price or no price for each copy that you convey, -and you may offer support or warranty protection for a fee. - - 5. Conveying Modified Source Versions. - - You may convey a work based on the Program, or the modifications to -produce it from the Program, in the form of source code under the -terms of section 4, provided that you also meet all of these conditions: - - a) The work must carry prominent notices stating that you modified - it, and giving a relevant date. - - b) The work must carry prominent notices stating that it is - released under this License and any conditions added under section - 7. This requirement modifies the requirement in section 4 to - "keep intact all notices". - - c) You must license the entire work, as a whole, under this - License to anyone who comes into possession of a copy. This - License will therefore apply, along with any applicable section 7 - additional terms, to the whole of the work, and all its parts, - regardless of how they are packaged. This License gives no - permission to license the work in any other way, but it does not - invalidate such permission if you have separately received it. - - d) If the work has interactive user interfaces, each must display - Appropriate Legal Notices; however, if the Program has interactive - interfaces that do not display Appropriate Legal Notices, your - work need not make them do so. - - A compilation of a covered work with other separate and independent -works, which are not by their nature extensions of the covered work, -and which are not combined with it such as to form a larger program, -in or on a volume of a storage or distribution medium, is called an -"aggregate" if the compilation and its resulting copyright are not -used to limit the access or legal rights of the compilation's users -beyond what the individual works permit. Inclusion of a covered work -in an aggregate does not cause this License to apply to the other -parts of the aggregate. - - 6. Conveying Non-Source Forms. - - You may convey a covered work in object code form under the terms -of sections 4 and 5, provided that you also convey the -machine-readable Corresponding Source under the terms of this License, -in one of these ways: - - a) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by the - Corresponding Source fixed on a durable physical medium - customarily used for software interchange. - - b) Convey the object code in, or embodied in, a physical product - (including a physical distribution medium), accompanied by a - written offer, valid for at least three years and valid for as - long as you offer spare parts or customer support for that product - model, to give anyone who possesses the object code either (1) a - copy of the Corresponding Source for all the software in the - product that is covered by this License, on a durable physical - medium customarily used for software interchange, for a price no - more than your reasonable cost of physically performing this - conveying of source, or (2) access to copy the - Corresponding Source from a network server at no charge. - - c) Convey individual copies of the object code with a copy of the - written offer to provide the Corresponding Source. This - alternative is allowed only occasionally and noncommercially, and - only if you received the object code with such an offer, in accord - with subsection 6b. - - d) Convey the object code by offering access from a designated - place (gratis or for a charge), and offer equivalent access to the - Corresponding Source in the same way through the same place at no - further charge. You need not require recipients to copy the - Corresponding Source along with the object code. If the place to - copy the object code is a network server, the Corresponding Source - may be on a different server (operated by you or a third party) - that supports equivalent copying facilities, provided you maintain - clear directions next to the object code saying where to find the - Corresponding Source. Regardless of what server hosts the - Corresponding Source, you remain obligated to ensure that it is - available for as long as needed to satisfy these requirements. - - e) Convey the object code using peer-to-peer transmission, provided - you inform other peers where the object code and Corresponding - Source of the work are being offered to the general public at no - charge under subsection 6d. - - A separable portion of the object code, whose source code is excluded -from the Corresponding Source as a System Library, need not be -included in conveying the object code work. - - A "User Product" is either (1) a "consumer product", which means any -tangible personal property which is normally used for personal, family, -or household purposes, or (2) anything designed or sold for incorporation -into a dwelling. In determining whether a product is a consumer product, -doubtful cases shall be resolved in favor of coverage. For a particular -product received by a particular user, "normally used" refers to a -typical or common use of that class of product, regardless of the status -of the particular user or of the way in which the particular user -actually uses, or expects or is expected to use, the product. A product -is a consumer product regardless of whether the product has substantial -commercial, industrial or non-consumer uses, unless such uses represent -the only significant mode of use of the product. - - "Installation Information" for a User Product means any methods, -procedures, authorization keys, or other information required to install -and execute modified versions of a covered work in that User Product from -a modified version of its Corresponding Source. The information must -suffice to ensure that the continued functioning of the modified object -code is in no case prevented or interfered with solely because -modification has been made. - - If you convey an object code work under this section in, or with, or -specifically for use in, a User Product, and the conveying occurs as -part of a transaction in which the right of possession and use of the -User Product is transferred to the recipient in perpetuity or for a -fixed term (regardless of how the transaction is characterized), the -Corresponding Source conveyed under this section must be accompanied -by the Installation Information. But this requirement does not apply -if neither you nor any third party retains the ability to install -modified object code on the User Product (for example, the work has -been installed in ROM). - - The requirement to provide Installation Information does not include a -requirement to continue to provide support service, warranty, or updates -for a work that has been modified or installed by the recipient, or for -the User Product in which it has been modified or installed. Access to a -network may be denied when the modification itself materially and -adversely affects the operation of the network or violates the rules and -protocols for communication across the network. - - Corresponding Source conveyed, and Installation Information provided, -in accord with this section must be in a format that is publicly -documented (and with an implementation available to the public in -source code form), and must require no special password or key for -unpacking, reading or copying. - - 7. Additional Terms. - - "Additional permissions" are terms that supplement the terms of this -License by making exceptions from one or more of its conditions. -Additional permissions that are applicable to the entire Program shall -be treated as though they were included in this License, to the extent -that they are valid under applicable law. If additional permissions -apply only to part of the Program, that part may be used separately -under those permissions, but the entire Program remains governed by -this License without regard to the additional permissions. - - When you convey a copy of a covered work, you may at your option -remove any additional permissions from that copy, or from any part of -it. (Additional permissions may be written to require their own -removal in certain cases when you modify the work.) You may place -additional permissions on material, added by you to a covered work, -for which you have or can give appropriate copyright permission. - - Notwithstanding any other provision of this License, for material you -add to a covered work, you may (if authorized by the copyright holders of -that material) supplement the terms of this License with terms: - - a) Disclaiming warranty or limiting liability differently from the - terms of sections 15 and 16 of this License; or - - b) Requiring preservation of specified reasonable legal notices or - author attributions in that material or in the Appropriate Legal - Notices displayed by works containing it; or - - c) Prohibiting misrepresentation of the origin of that material, or - requiring that modified versions of such material be marked in - reasonable ways as different from the original version; or - - d) Limiting the use for publicity purposes of names of licensors or - authors of the material; or - - e) Declining to grant rights under trademark law for use of some - trade names, trademarks, or service marks; or - - f) Requiring indemnification of licensors and authors of that - material by anyone who conveys the material (or modified versions of - it) with contractual assumptions of liability to the recipient, for - any liability that these contractual assumptions directly impose on - those licensors and authors. - - All other non-permissive additional terms are considered "further -restrictions" within the meaning of section 10. If the Program as you -received it, or any part of it, contains a notice stating that it is -governed by this License along with a term that is a further -restriction, you may remove that term. If a license document contains -a further restriction but permits relicensing or conveying under this -License, you may add to a covered work material governed by the terms -of that license document, provided that the further restriction does -not survive such relicensing or conveying. - - If you add terms to a covered work in accord with this section, you -must place, in the relevant source files, a statement of the -additional terms that apply to those files, or a notice indicating -where to find the applicable terms. - - Additional terms, permissive or non-permissive, may be stated in the -form of a separately written license, or stated as exceptions; -the above requirements apply either way. - - 8. Termination. - - You may not propagate or modify a covered work except as expressly -provided under this License. Any attempt otherwise to propagate or -modify it is void, and will automatically terminate your rights under -this License (including any patent licenses granted under the third -paragraph of section 11). - - However, if you cease all violation of this License, then your -license from a particular copyright holder is reinstated (a) -provisionally, unless and until the copyright holder explicitly and -finally terminates your license, and (b) permanently, if the copyright -holder fails to notify you of the violation by some reasonable means -prior to 60 days after the cessation. - - Moreover, your license from a particular copyright holder is -reinstated permanently if the copyright holder notifies you of the -violation by some reasonable means, this is the first time you have -received notice of violation of this License (for any work) from that -copyright holder, and you cure the violation prior to 30 days after -your receipt of the notice. - - Termination of your rights under this section does not terminate the -licenses of parties who have received copies or rights from you under -this License. If your rights have been terminated and not permanently -reinstated, you do not qualify to receive new licenses for the same -material under section 10. - - 9. Acceptance Not Required for Having Copies. - - You are not required to accept this License in order to receive or -run a copy of the Program. Ancillary propagation of a covered work -occurring solely as a consequence of using peer-to-peer transmission -to receive a copy likewise does not require acceptance. However, -nothing other than this License grants you permission to propagate or -modify any covered work. These actions infringe copyright if you do -not accept this License. Therefore, by modifying or propagating a -covered work, you indicate your acceptance of this License to do so. - - 10. Automatic Licensing of Downstream Recipients. - - Each time you convey a covered work, the recipient automatically -receives a license from the original licensors, to run, modify and -propagate that work, subject to this License. You are not responsible -for enforcing compliance by third parties with this License. - - An "entity transaction" is a transaction transferring control of an -organization, or substantially all assets of one, or subdividing an -organization, or merging organizations. If propagation of a covered -work results from an entity transaction, each party to that -transaction who receives a copy of the work also receives whatever -licenses to the work the party's predecessor in interest had or could -give under the previous paragraph, plus a right to possession of the -Corresponding Source of the work from the predecessor in interest, if -the predecessor has it or can get it with reasonable efforts. - - You may not impose any further restrictions on the exercise of the -rights granted or affirmed under this License. For example, you may -not impose a license fee, royalty, or other charge for exercise of -rights granted under this License, and you may not initiate litigation -(including a cross-claim or counterclaim in a lawsuit) alleging that -any patent claim is infringed by making, using, selling, offering for -sale, or importing the Program or any portion of it. - - 11. Patents. - - A "contributor" is a copyright holder who authorizes use under this -License of the Program or a work on which the Program is based. The -work thus licensed is called the contributor's "contributor version". - - A contributor's "essential patent claims" are all patent claims -owned or controlled by the contributor, whether already acquired or -hereafter acquired, that would be infringed by some manner, permitted -by this License, of making, using, or selling its contributor version, -but do not include claims that would be infringed only as a -consequence of further modification of the contributor version. For -purposes of this definition, "control" includes the right to grant -patent sublicenses in a manner consistent with the requirements of -this License. - - Each contributor grants you a non-exclusive, worldwide, royalty-free -patent license under the contributor's essential patent claims, to -make, use, sell, offer for sale, import and otherwise run, modify and -propagate the contents of its contributor version. - - In the following three paragraphs, a "patent license" is any express -agreement or commitment, however denominated, not to enforce a patent -(such as an express permission to practice a patent or covenant not to -sue for patent infringement). To "grant" such a patent license to a -party means to make such an agreement or commitment not to enforce a -patent against the party. - - If you convey a covered work, knowingly relying on a patent license, -and the Corresponding Source of the work is not available for anyone -to copy, free of charge and under the terms of this License, through a -publicly available network server or other readily accessible means, -then you must either (1) cause the Corresponding Source to be so -available, or (2) arrange to deprive yourself of the benefit of the -patent license for this particular work, or (3) arrange, in a manner -consistent with the requirements of this License, to extend the patent -license to downstream recipients. "Knowingly relying" means you have -actual knowledge that, but for the patent license, your conveying the -covered work in a country, or your recipient's use of the covered work -in a country, would infringe one or more identifiable patents in that -country that you have reason to believe are valid. - - If, pursuant to or in connection with a single transaction or -arrangement, you convey, or propagate by procuring conveyance of, a -covered work, and grant a patent license to some of the parties -receiving the covered work authorizing them to use, propagate, modify -or convey a specific copy of the covered work, then the patent license -you grant is automatically extended to all recipients of the covered -work and works based on it. - - A patent license is "discriminatory" if it does not include within -the scope of its coverage, prohibits the exercise of, or is -conditioned on the non-exercise of one or more of the rights that are -specifically granted under this License. You may not convey a covered -work if you are a party to an arrangement with a third party that is -in the business of distributing software, under which you make payment -to the third party based on the extent of your activity of conveying -the work, and under which the third party grants, to any of the -parties who would receive the covered work from you, a discriminatory -patent license (a) in connection with copies of the covered work -conveyed by you (or copies made from those copies), or (b) primarily -for and in connection with specific products or compilations that -contain the covered work, unless you entered into that arrangement, -or that patent license was granted, prior to 28 March 2007. - - Nothing in this License shall be construed as excluding or limiting -any implied license or other defenses to infringement that may -otherwise be available to you under applicable patent law. - - 12. No Surrender of Others' Freedom. - - If conditions are imposed on you (whether by court order, agreement or -otherwise) that contradict the conditions of this License, they do not -excuse you from the conditions of this License. If you cannot convey a -covered work so as to satisfy simultaneously your obligations under this -License and any other pertinent obligations, then as a consequence you may -not convey it at all. For example, if you agree to terms that obligate you -to collect a royalty for further conveying from those to whom you convey -the Program, the only way you could satisfy both those terms and this -License would be to refrain entirely from conveying the Program. - - 13. Use with the GNU Affero General Public License. - - Notwithstanding any other provision of this License, you have -permission to link or combine any covered work with a work licensed -under version 3 of the GNU Affero General Public License into a single -combined work, and to convey the resulting work. The terms of this -License will continue to apply to the part which is the covered work, -but the special requirements of the GNU Affero General Public License, -section 13, concerning interaction through a network will apply to the -combination as such. - - 14. Revised Versions of this License. - - The Free Software Foundation may publish revised and/or new versions of -the GNU General Public License from time to time. Such new versions will -be similar in spirit to the present version, but may differ in detail to -address new problems or concerns. - - Each version is given a distinguishing version number. If the -Program specifies that a certain numbered version of the GNU General -Public License "or any later version" applies to it, you have the -option of following the terms and conditions either of that numbered -version or of any later version published by the Free Software -Foundation. If the Program does not specify a version number of the -GNU General Public License, you may choose any version ever published -by the Free Software Foundation. - - If the Program specifies that a proxy can decide which future -versions of the GNU General Public License can be used, that proxy's -public statement of acceptance of a version permanently authorizes you -to choose that version for the Program. - - Later license versions may give you additional or different -permissions. However, no additional obligations are imposed on any -author or copyright holder as a result of your choosing to follow a -later version. - - 15. Disclaimer of Warranty. - - THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY -APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT -HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY -OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, -THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR -PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM -IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF -ALL NECESSARY SERVICING, REPAIR OR CORRECTION. - - 16. Limitation of Liability. - - IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING -WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS -THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY -GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE -USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF -DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD -PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), -EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF -SUCH DAMAGES. - - 17. Interpretation of Sections 15 and 16. - - If the disclaimer of warranty and limitation of liability provided -above cannot be given local legal effect according to their terms, -reviewing courts shall apply local law that most closely approximates -an absolute waiver of all civil liability in connection with the -Program, unless a warranty or assumption of liability accompanies a -copy of the Program in return for a fee. - - END OF TERMS AND CONDITIONS diff --git a/src/ifccobie/COPYING.LESSER b/src/ifccobie/COPYING.LESSER deleted file mode 100644 index 0a041280bd..0000000000 --- a/src/ifccobie/COPYING.LESSER +++ /dev/null @@ -1,165 +0,0 @@ - GNU LESSER GENERAL PUBLIC LICENSE - Version 3, 29 June 2007 - - Copyright (C) 2007 Free Software Foundation, Inc. - Everyone is permitted to copy and distribute verbatim copies - of this license document, but changing it is not allowed. - - - This version of the GNU Lesser General Public License incorporates -the terms and conditions of version 3 of the GNU General Public -License, supplemented by the additional permissions listed below. - - 0. Additional Definitions. - - As used herein, "this License" refers to version 3 of the GNU Lesser -General Public License, and the "GNU GPL" refers to version 3 of the GNU -General Public License. - - "The Library" refers to a covered work governed by this License, -other than an Application or a Combined Work as defined below. - - An "Application" is any work that makes use of an interface provided -by the Library, but which is not otherwise based on the Library. -Defining a subclass of a class defined by the Library is deemed a mode -of using an interface provided by the Library. - - A "Combined Work" is a work produced by combining or linking an -Application with the Library. The particular version of the Library -with which the Combined Work was made is also called the "Linked -Version". - - The "Minimal Corresponding Source" for a Combined Work means the -Corresponding Source for the Combined Work, excluding any source code -for portions of the Combined Work that, considered in isolation, are -based on the Application, and not on the Linked Version. - - The "Corresponding Application Code" for a Combined Work means the -object code and/or source code for the Application, including any data -and utility programs needed for reproducing the Combined Work from the -Application, but excluding the System Libraries of the Combined Work. - - 1. Exception to Section 3 of the GNU GPL. - - You may convey a covered work under sections 3 and 4 of this License -without being bound by section 3 of the GNU GPL. - - 2. Conveying Modified Versions. - - If you modify a copy of the Library, and, in your modifications, a -facility refers to a function or data to be supplied by an Application -that uses the facility (other than as an argument passed when the -facility is invoked), then you may convey a copy of the modified -version: - - a) under this License, provided that you make a good faith effort to - ensure that, in the event an Application does not supply the - function or data, the facility still operates, and performs - whatever part of its purpose remains meaningful, or - - b) under the GNU GPL, with none of the additional permissions of - this License applicable to that copy. - - 3. Object Code Incorporating Material from Library Header Files. - - The object code form of an Application may incorporate material from -a header file that is part of the Library. You may convey such object -code under terms of your choice, provided that, if the incorporated -material is not limited to numerical parameters, data structure -layouts and accessors, or small macros, inline functions and templates -(ten or fewer lines in length), you do both of the following: - - a) Give prominent notice with each copy of the object code that the - Library is used in it and that the Library and its use are - covered by this License. - - b) Accompany the object code with a copy of the GNU GPL and this license - document. - - 4. Combined Works. - - You may convey a Combined Work under terms of your choice that, -taken together, effectively do not restrict modification of the -portions of the Library contained in the Combined Work and reverse -engineering for debugging such modifications, if you also do each of -the following: - - a) Give prominent notice with each copy of the Combined Work that - the Library is used in it and that the Library and its use are - covered by this License. - - b) Accompany the Combined Work with a copy of the GNU GPL and this license - document. - - c) For a Combined Work that displays copyright notices during - execution, include the copyright notice for the Library among - these notices, as well as a reference directing the user to the - copies of the GNU GPL and this license document. - - d) Do one of the following: - - 0) Convey the Minimal Corresponding Source under the terms of this - License, and the Corresponding Application Code in a form - suitable for, and under terms that permit, the user to - recombine or relink the Application with a modified version of - the Linked Version to produce a modified Combined Work, in the - manner specified by section 6 of the GNU GPL for conveying - Corresponding Source. - - 1) Use a suitable shared library mechanism for linking with the - Library. A suitable mechanism is one that (a) uses at run time - a copy of the Library already present on the user's computer - system, and (b) will operate properly with a modified version - of the Library that is interface-compatible with the Linked - Version. - - e) Provide Installation Information, but only if you would otherwise - be required to provide such information under section 6 of the - GNU GPL, and only to the extent that such information is - necessary to install and execute a modified version of the - Combined Work produced by recombining or relinking the - Application with a modified version of the Linked Version. (If - you use option 4d0, the Installation Information must accompany - the Minimal Corresponding Source and Corresponding Application - Code. If you use option 4d1, you must provide the Installation - Information in the manner specified by section 6 of the GNU GPL - for conveying Corresponding Source.) - - 5. Combined Libraries. - - You may place library facilities that are a work based on the -Library side by side in a single library together with other library -facilities that are not Applications and are not covered by this -License, and convey such a combined library under terms of your -choice, if you do both of the following: - - a) Accompany the combined library with a copy of the same work based - on the Library, uncombined with any other library facilities, - conveyed under the terms of this License. - - b) Give prominent notice with the combined library that part of it - is a work based on the Library, and explaining where to find the - accompanying uncombined form of the same work. - - 6. Revised Versions of the GNU Lesser General Public License. - - The Free Software Foundation may publish revised and/or new versions -of the GNU Lesser General Public License from time to time. Such new -versions will be similar in spirit to the present version, but may -differ in detail to address new problems or concerns. - - Each version is given a distinguishing version number. If the -Library as you received it specifies that a certain numbered version -of the GNU Lesser General Public License "or any later version" -applies to it, you have the option of following the terms and -conditions either of that published version or of any later version -published by the Free Software Foundation. If the Library as you -received it does not specify a version number of the GNU Lesser -General Public License, you may choose any version of the GNU Lesser -General Public License ever published by the Free Software Foundation. - - If the Library as you received it specifies that a proxy can decide -whether future versions of the GNU Lesser General Public License shall -apply, that proxy's public statement of acceptance of any version is -permanent authorization for you to choose that version for the -Library. diff --git a/src/ifccobie/cobie.py b/src/ifccobie/cobie.py deleted file mode 100755 index c5cde601eb..0000000000 --- a/src/ifccobie/cobie.py +++ /dev/null @@ -1,1667 +0,0 @@ -#!/usr/bin/env python3 - -# IfcCOBie - Extract COBie data from IFC to spreadsheets -# Copyright (C) 2019, 2020, 2021 Dion Moult -# -# This file is part of IfcCOBie. -# -# IfcCOBie 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. -# -# IfcCOBie 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 IfcCOBie. If not, see . - -# This can be packaged with `pyinstaller --onefile --clean --icon=icon.ico bimtester.py` - -import os -import time -import argparse -import datetime -import logging -import ifcopenshell -import ifcopenshell.util.selector -import ifcopenshell.util.placement - - -class IfcCobieParser: - def __init__(self, logger, selector): - self.selector = selector - self.logger = logger - self.file = None - self.sheets = [ - "contacts", - "facilities", - "floors", - "spaces", - "zones", - "types", - "components", - "systems", - "assemblies", - "connections", - "spares", - "resources", - "jobs", - "impacts", - "documents", - "attributes", - "coordinates", - "issues", - ] - for sheet in self.sheets: - setattr(self, sheet, {}) - self.picklists = { - "Category-Role": [], - "Category-Facility": [], - "FloorType": [], - "Category-Space": [], - "ZoneType": [], - "Category-Product": [], - "AssetType": [], - "DurationUnit": ["day"], # See note about hardcoded day below - "Category-Element": [], - "SpareType": [], - "ApprovalBy": [], - "StageType": [], - "objType": [], - } - self.default_date = (datetime.datetime(1970, 1, 1) + datetime.timedelta(seconds=-2177452801)).isoformat() - - def parse(self, file, type_query=".COBieType", component_query=".COBie", custom_data={}): - self.custom_data = custom_data - for sheet in self.sheets: - if sheet not in self.custom_data: - self.custom_data[sheet] = {} - - if isinstance(file, str): - self.file = ifcopenshell.open(file) - else: - self.file = file - - self.type_assets = self.selector.parse(self.file, type_query) - self.component_assets = self.selector.parse(self.file, component_query) - self.get_contacts() - self.get_facilities() - self.get_floors() - self.get_spaces() - self.get_zones() - self.get_types() - self.get_components() - self.get_systems() - self.get_assemblies() - self.get_connections() - self.get_spares() - self.get_resources() - self.get_jobs() - self.get_impacts() - self.get_documents() - self.get_attributes() - self.get_coordinates() - self.get_issues() - - def get_contacts(self): - histories = self.file.by_type("IfcOwnerHistory") - for history in histories: - email = self.get_email_from_history(history) - if not email: - continue - postal_address = self.get_postal_address_from_history(history) - self.contacts[email] = { - "CreatedBy": email, - "CreatedOn": datetime.datetime.fromtimestamp(history.CreationDate).isoformat() - if history.CreationDate - else datetime.datetime.now().isoformat(), - "Category": self.get_category_from_history(history), - "Company": history.OwningUser.TheOrganization.Name or "n/a", - "Phone": self.get_phone_from_history(history), - "ExtSystem": self.get_ext_system_from_history(history), - "ExtObject": self.get_ext_object_from_history(history), - "ExtIdentifier": history.OwningUser.ThePerson.Id - if self.file.schema == "IFC2X3" - else history.OwningUser.ThePerson.Identification, - "Department": self.get_department_from_history(history), - "OrganizationCode": (history.OwningUser.TheOrganization.Id or "n/a") - if self.file.schema == "IFC2X3" - else (history.OwningUser.TheOrganization.Identification or "n/a"), - "GivenName": self.get_name_from_person(history.OwningUser.ThePerson, "GivenName"), - "FamilyName": self.get_name_from_person(history.OwningUser.ThePerson, "FamilyName"), - "Street": self.get_lines_from_address(postal_address), - "PostalBox": self.get_attribute_from_address(postal_address, "PostalBox"), - "Town": self.get_attribute_from_address(postal_address, "Town"), - "StateRegion": self.get_attribute_from_address(postal_address, "Region"), - "PostalCode": self.get_attribute_from_address(postal_address, "PostalCode"), - "Country": self.get_attribute_from_address(postal_address, "Country"), - } - for field, key in self.custom_data["contacts"].items(): - self.contacts[email][field] = self.get_element_value(history, key) - - def get_facilities(self): - buildings = self.file.by_type("IfcBuilding") - for building in buildings: - building_name = self.get_object_name(building) - units = self.get_units_from_building(building) - self.facilities[building_name] = { - "CreatedBy": self.get_email_from_history(building.OwnerHistory), - "CreatedOn": self.get_created_on_from_history(building.OwnerHistory), - "Category": self.get_category_from_object(building, "Category-Facility"), - "ProjectName": self.get_project_name_from_building(building), - "SiteName": self.get_site_name_from_building(building), - "LinearUnits": self.get_unit_type_from_units(units, "LENGTHUNIT"), - "AreaUnits": self.get_unit_type_from_units(units, "AREAUNIT"), - "VolumeUnits": self.get_unit_type_from_units(units, "VOLUMEUNIT"), - "CostUnit": self.get_monetary_unit_from_units(units), - "AreaMeasurement": self.get_area_measurement_from_building(building), - "ExternalSystem": self.get_ext_system_from_history(building.OwnerHistory), - "ExternalProjectObject": self.get_ext_project_object(), - "ExternalProjectIdentifier": self.get_project_globalid_from_building(building), - "ExternalSiteObject": self.get_ext_site_object(), - "ExternalSiteIdentifier": self.get_site_globalid_from_building(building), - "ExternalFacilityObject": self.get_ext_object(building), - "ExternalFacilityIdentifier": building.GlobalId, - "Description": self.get_object_attribute(building, "Description", default="n/a"), - "ProjectDescription": self.get_object_attribute( - self.get_parent_spatial_element(building, "IfcProject"), "Description", default="n/a" - ), - "SiteDescription": self.get_object_attribute( - self.get_parent_spatial_element(building, "IfcSite"), "Description", default="n/a" - ), - "Phase": self.get_object_attribute( - self.get_parent_spatial_element(building, "IfcProject"), "Phase", default="n/a" - ), - } - for field, key in self.custom_data["facilities"].items(): - self.facilities[building_name][field] = self.get_element_value(building, key) - - def get_floors(self): - storeys = self.file.by_type("IfcBuildingStorey") - for storey in storeys: - storey_name = self.get_object_name(storey) - self.floors[storey_name] = { - "CreatedBy": self.get_email_from_history(storey.OwnerHistory), - "CreatedOn": self.get_created_on_from_history(storey.OwnerHistory), - "Category": self.get_category_from_object(storey, "FloorType"), - "ExtSystem": self.get_ext_system_from_history(storey.OwnerHistory), - "ExtObject": self.get_ext_object(storey), - "ExtIdentifier": storey.GlobalId, - "Description": self.get_object_attribute(storey, "Description", default="n/a"), - "Elevation": self.get_object_attribute(storey, "Elevation", default="n/a"), - "Height": self.get_height_from_storey(storey), - } - for field, key in self.custom_data["floors"].items(): - self.floors[storey_name][field] = self.get_element_value(storey, key) - - def get_spaces(self): - spaces = self.file.by_type("IfcSpace") - for space in spaces: - space_name = self.get_object_name(space) - self.spaces[space_name] = { - "CreatedBy": self.get_email_from_history(space.OwnerHistory), - "CreatedOn": self.get_created_on_from_history(space.OwnerHistory), - "Category": self.get_category_from_object(space, "Category-Space"), - "FloorName": self.get_object_attribute( - self.get_parent_spatial_element(space, "IfcBuildingStorey"), - "Name", - is_primary_key=True, - default="n/a", - ), - "Description": self.get_object_attribute(space, "Description", default="n/a"), - "ExtSystem": self.get_ext_system_from_history(space.OwnerHistory), - "ExtObject": self.get_ext_object(space), - "ExtIdentifier": space.GlobalId, - "RoomTag": self.get_pset_value_from_object(space, "COBie_Space", "RoomTag", "n/a"), - "UsableHeight": self.get_usable_height_from_space(space), - "GrossArea": self.get_gross_area_from_space(space), - "NetArea": self.get_net_area_from_space(space), - } - for field, key in self.custom_data["spaces"].items(): - self.spaces[space_name][field] = self.get_element_value(space, key) - - def get_zones(self): - zones = self.file.by_type("IfcZone") - for zone in zones: - zone_name = self.get_object_name(zone) - self.zones[zone_name] = { - "CreatedBy": self.get_email_from_history(zone.OwnerHistory), - "CreatedOn": self.get_created_on_from_history(zone.OwnerHistory), - "Category": self.get_category_from_object(zone, "ZoneType"), - "SpaceNames": self.get_grouped_product_names_from_object(zone, "IfcSpace"), - "ExtSystem": self.get_ext_system_from_history(zone.OwnerHistory), - "ExtObject": self.get_ext_object(zone), - "ExtIdentifier": zone.GlobalId, - "Description": self.get_object_attribute(zone, "Description", default="n/a"), - } - for field, key in self.custom_data["zones"].items(): - self.zones[zone_name][field] = self.get_element_value(zone, key) - - def get_types(self): - types = self.file.by_type("IfcTypeObject") - for type in self.type_assets: - # The responsibility matrix states to parse IfcMaterial and - # IfcMaterialLayerSet too, but it doesn't make much sense, so I - # don't parse it. - type_name = self.get_object_name(type) - self.types[type_name] = { - "CreatedBy": self.get_email_from_history(type.OwnerHistory), - "CreatedOn": self.get_created_on_from_history(type.OwnerHistory), - "Category": self.get_category_from_object(type, "Category-Product"), - # The responsibility matrix states two possible fallbacks. I - # choose the 'n/a' option as opposed to repeating the name. - "Description": self.get_object_attribute(type, "Description", default="n/a"), - "AssetType": self.get_pset_value_from_object(type, "COBie_Asset", "AssetType", "n/a", "AssetType"), - "Manufacturer": self.get_contact_pset_value_from_object( - type, "Pset_ManufacturerTypeInformation", "Manufacturer" - ), - "ModelNumber": self.get_pset_value_from_object( - type, "Pset_ManufacturerTypeInformation", "ModelLabel", "n/a" - ), - # The responsibility matrix talks about using the Pset_Warranty - # values, but Pset_Warranty only applies to objects, not types, - # and so they are ignored. - "WarrantyGuarantorParts": self.get_contact_pset_value_from_object( - type, "COBie_Warranty", "WarrantyGuarantorParts" - ), - "WarrantyDurationParts": self.get_pset_value_from_object( - type, "COBie_Warranty", "WarrantyDurationParts", 0 - ), - "WarrantyGuarantorLabor": self.get_contact_pset_value_from_object( - type, "COBie_Warranty", "WarrantyGuarantorLabor" - ), - "WarrantyDurationLabor": self.get_pset_value_from_object( - type, "COBie_Warranty", "WarrantyDurationLabor", 0 - ), - # TODO: this may be derived from the duration values above, but - # until it is clarified, it will be hardcoded as 'day' - "WarrantyDurationUnit": "day", - "ExtSystem": self.get_ext_system_from_history(type.OwnerHistory), - "ExtObject": self.get_ext_object(type), - "ExtIdentifier": type.GlobalId, - "ReplacementCost": self.get_pset_value_from_object( - type, "COBie_EconomicImpactValues", "ReplacementCost", "n/a" - ), - "ExpectedLife": self.get_expected_life_from_type(type), - # See note about WarrantyDurationUnit above - "DurationUnit": "day", - "NominalLength": self.get_pset_value_from_object(type, "COBie_Specification", "NominalLength", 0), - "NominalWidth": self.get_pset_value_from_object(type, "COBie_Specification", "NominalWidth", 0), - "NominalHeight": self.get_pset_value_from_object(type, "COBie_Specification", "NominalHeight", 0), - "ModelReference": self.get_pset_value_from_object( - type, "Pset_ManufacturerTypeInformation", "ModelReference", "n/a" - ), - "Shape": self.get_pset_value_from_object(type, "COBie_Specification", "Shape", "n/a"), - "Size": self.get_pset_value_from_object(type, "COBie_Specification", "Size", "n/a"), - # The responsbility matrix allows the British spelling of - # "colour". I, however, do not. - "Color": self.get_pset_value_from_object(type, "COBie_Specification", "Color", "n/a"), - "Finish": self.get_pset_value_from_object(type, "COBie_Specification", "Finish", "n/a"), - "Grade": self.get_pset_value_from_object(type, "COBie_Specification", "Grade", "n/a"), - "Material": self.get_pset_value_from_object(type, "COBie_Specification", "Material", "n/a"), - "Constituents": self.get_pset_value_from_object(type, "COBie_Specification", "Constituents", "n/a"), - "Features": self.get_pset_value_from_object(type, "COBie_Specification", "Features", "n/a"), - "AccessibilityPerformance": self.get_pset_value_from_object( - type, "COBie_Specification", "AccessibilityPerformance", "n/a" - ), - "CodePerformance": self.get_pset_value_from_object( - type, "COBie_Specification", "CodePerformance", "n/a" - ), - "SustainabilityPerformance": self.get_pset_value_from_object( - type, "COBie_Specification", "SustainabilityPerformance", "n/a" - ), - } - for field, key in self.custom_data["types"].items(): - self.types[type_name][field] = self.get_element_value(type, key) - - def get_components(self): - components = self.file.by_type("IfcElement") - for component in components: - if not self.is_object_a_component_asset(component): - self.logger.warning("A component which is not an asset was found for %s", component) - continue - component_name = self.get_object_name(component) - self.components[component_name] = { - "CreatedBy": self.get_email_from_history(component.OwnerHistory), - "CreatedOn": self.get_created_on_from_history(component.OwnerHistory), - "TypeName": self.get_type_name_from_object(component), - "Space": self.get_space_name_from_component(component), - "Description": self.get_object_attribute(component, "Description", default="n/a"), - "ExtSystem": self.get_ext_system_from_history(component.OwnerHistory), - "ExtObject": self.get_ext_object(component), - "ExtIdentifier": component.GlobalId, - "SerialNumber": self.get_pset_value_from_object( - component, "Pset_ManufacturerOccurence", "SerialNumber", "n/a" - ), - "InstallationDate": self.get_pset_value_from_object( - component, "COBie_Component", "InstallationDate", self.default_date - ), - "WarrantyStartDate": self.get_pset_value_from_object( - component, "COBie_Component", "WarrantyStartDate", self.default_date - ), - "TagNumber": self.get_pset_value_from_object(component, "COBie_Component", "TagNumber", "n/a"), - "BarCode": self.get_pset_value_from_object(component, "Pset_ManufacturerOccurence", "BarCode", "n/a"), - "AssetIdentifier": self.get_pset_value_from_object( - component, "COBie_Component", "AssetIdentifier", "n/a" - ), - } - for field, key in self.custom_data["components"].items(): - self.components[component_name][field] = self.get_element_value(component, key) - - def get_systems(self): - systems = self.file.by_type("IfcSystem") - for system in systems: - system_name = self.get_object_name(system) - self.systems[system_name] = { - "CreatedBy": self.get_email_from_history(system.OwnerHistory), - "CreatedOn": self.get_created_on_from_history(system.OwnerHistory), - "Category": self.get_category_from_object(system, "Category-Element"), - "ComponentNames": self.get_grouped_product_names_from_object(system, "IfcProduct"), - "ExtSystem": self.get_ext_system_from_history(system.OwnerHistory), - "ExtObject": self.get_ext_object(system), - "ExtIdentifier": system.GlobalId, - "Description": self.get_object_attribute(system, "Description", default="n/a"), - } - for field, key in self.custom_data["systems"].items(): - self.systems[system_name][field] = self.get_element_value(system, key) - - def get_assemblies(self): - assemblies = self.file.by_type("IfcRelAggregates") - for assembly in assemblies: - assembly_name = self.get_object_name(assembly) - if not self.is_object_a_component_asset(assembly.RelatingObject): - continue - self.assemblies[assembly_name] = { - "CreatedBy": self.get_email_from_history(assembly.OwnerHistory), - "CreatedOn": self.get_created_on_from_history(assembly.OwnerHistory), - "SheetName": "Assembly", - "ParentName": self.get_object_name(assembly.RelatingObject), - "ChildNames": ",".join([o.Name if o.Name else "" for o in assembly.RelatedObjects]), - "AssemblyType": "n/a", # I don't understand this field - "ExtSystem": self.get_ext_system_from_history(assembly.OwnerHistory), - "ExtObject": self.get_ext_object(assembly), - "ExtIdentifier": assembly.GlobalId, - "Description": self.get_object_attribute(assembly, "Description", default="n/a"), - } - for field, key in self.custom_data["assemblies"].items(): - self.assemblies[assembly_name][field] = self.get_element_value(assembly, key) - - def get_connections(self): - connections = self.file.by_type("IfcRelConnects") - for connection in connections: - connection_name = self.get_object_name(connection) - self.connections[connection_name] = { - "CreatedBy": self.get_email_from_history(connection.OwnerHistory), - "CreatedOn": self.get_created_on_from_history(connection.OwnerHistory), - # There is ambiguity for what the ConnectionType mapping should be - "ConnectionType": self.get_object_attribute(connection, "Description", default="n/a"), - "SheetName": "Connections", - "RowName1": self.get_row_name_from_connection(connection, "RelatingElement"), - "RowName2": self.get_row_name_from_connection(connection, "RelatedElement"), - "RealizingElement": self.get_port_name_from_connection(connection, "RealizingElement"), - "PortName1": self.get_port_name_from_connection(connection, "RelatingPort"), - "PortName2": self.get_port_name_from_connection(connection, "RelatedPort"), - "ExtSystem": self.get_ext_system_from_history(connection.OwnerHistory), - "ExtObject": self.get_ext_object(connection), - "ExtIdentifier": connection.GlobalId, - "Description": self.get_object_attribute(connection, "Description", default="n/a"), - } - for field, key in self.custom_data["connections"].items(): - self.connections[connection_name][field] = self.get_element_value(connection, key) - - def get_spares(self): - spares = self.file.by_type("IfcConstructionProductResource") - for spare in spares: - spare_name = self.get_object_name(spare) - self.spares[spare_name] = { - "CreatedBy": self.get_email_from_history(spare.OwnerHistory), - "CreatedOn": self.get_created_on_from_history(spare.OwnerHistory), - "Category": self.get_category_from_object(spare, "SpareType"), - "TypeName": self.get_type_name_from_object(spare), - "Suppliers": self.get_contact_pset_value_from_object(spare, "COBie_Spare", "Suppliers"), - "ExtSystem": self.get_ext_system_from_history(spare.OwnerHistory), - "ExtObject": self.get_ext_object(spare), - "ExtIdentifier": spare.GlobalId, - "Description": self.get_object_attribute(spare, "Description", default="n/a"), - "SetNumber": self.get_contact_pset_value_from_object(spare, "COBie_Spare", "SetNumber"), - "PartNumber": self.get_contact_pset_value_from_object(spare, "COBie_Spare", "PartNumber"), - } - for field, key in self.custom_data["spares"].items(): - self.spares[spare_name][field] = self.get_element_value(spare, key) - - def get_resources(self): - resources = self.file.by_type("IfcConstructionProductResource") - for resource in resources: - resource_name = self.get_object_name(resource) - self.resources[resource_name] = { - "CreatedBy": self.get_email_from_history(resource.OwnerHistory), - "CreatedOn": self.get_created_on_from_history(resource.OwnerHistory), - "Category": self.get_object_attribute(resource, "ObjectType", picklist="ResourceType", default="n/a"), - "ExtSystem": self.get_ext_system_from_history(resource.OwnerHistory), - "ExtObject": self.get_ext_object(resource), - "ExtIdentifier": resource.GlobalId, - "Description": self.get_object_attribute(resource, "Description", default="n/a"), - } - for field, key in self.custom_data["resources"].items(): - self.resources[resource_name][field] = self.get_element_value(resource, key) - - def get_jobs(self): - jobs = self.file.by_type("IfcTask") - for job in jobs: - job_name = self.get_object_name(job) - task_time = job.TaskTime - self.jobs[job_name] = { - "CreatedBy": self.get_email_from_history(job.OwnerHistory), - "CreatedOn": self.get_created_on_from_history(job.OwnerHistory), - "Category": self.get_object_attribute(job, "ObjectType", picklist="JobType", default="n/a"), - "Status": self.get_object_attribute(job, "Status", picklist="JobStatusType", default="n/a"), - "TypeName": self.get_type_name_from_object(job), - "Description": self.get_object_attribute(job, "Description", default="n/a"), - "Duration": self.get_object_attribute(task_time, "ScheduleDuration", default=0), - "DurationUnit": "day", - "Start": self.get_object_attribute(task_time, "ScheduleStart", default=0), - "TaskStartUnit": "day", - "Frequency": self.get_object_attribute(task_time.Recurrence, "Occurrences", default=0) - if hasattr(task_time, "Recurrence") - else 0, - "FrequencyUnit": "day", - "ExtSystem": self.get_ext_system_from_history(job.OwnerHistory), - "ExtObject": self.get_ext_object(job), - "ExtIdentifier": job.GlobalId, - "TaskNumber": self.get_object_attribute(job, "Identification"), - "Priors": self.get_priors_from_job(job), - "ResourceNames": self.get_resource_names_from_job(job), - } - for field, key in self.custom_data["jobs"].items(): - self.jobs[job_name][field] = self.get_element_value(job, key) - - # Impacts is not explicitly defined as a mapping in the responsibliity - # matrix. This is my best guess. This data should not be relied upon until - # this is clarified. - def get_impacts(self): - impacts = self.file.by_type("IfcPropertySet") - for impact in impacts: - if impact.Name != "Pset_EnvironmentalImpactValues" or not impact.HasProperties: - continue - for property in impact.HasProperties: - property_name = "{}-{}".format(property.id(), self.get_object_name(property)) - self.impacts[property_name] = { - "CreatedBy": self.get_email_from_history(impact.OwnerHistory), - "CreatedOn": self.get_created_on_from_history(impact.OwnerHistory), - "ImpactType": None, - "ImpactStage": None, - "SheetName": "Impacts", - "RowName": "n/a", - "Value": self.get_property_value(property), - "Unit": "{}{}".format(property.Unit.Prefix, property.Unit.Name) - if hasattr(property, "Unit") and property.Unit - else "n/a", - "LeadInTime": self.get_property_value(property, name="LeadInTime"), - "Duration": self.get_property_value(property, name="Duration"), - "LeadOutTime": self.get_property_value(property, name="LeadOutTime"), - "ExtSystem": self.get_ext_system_from_history(impact.OwnerHistory), - "ExtObject": self.get_ext_object(impact), - "ExtIdentifier": impact.GlobalId, - "Description": self.get_object_attribute(impact, "Description", default="n/a"), - } - for field, key in self.custom_data["impacts"].items(): - self.impacts[impact_name][field] = self.get_element_value(impact, key) - - def get_documents(self): - documents = self.file.by_type("IfcDocumentInformation") - for document in documents: - document_name = self.get_object_name(document) - self.documents[document_name] = { - "CreatedBy": self.get_email_from_history(document.OwnerHistory), - "CreatedOn": self.get_created_on_from_history(document.OwnerHistory), - "Category": "n/a", # I am not sure what this mapping is meant to be - "ApprovalBy": self.get_object_attribute( - document, "IntendedUse", picklist="ApprovalBy", default="Information Only" - ), - "Stage": self.get_object_attribute(document, "Scope", picklist="StageType", default="Required"), - "SheetName": "Documents", - "RowName": "n/a", - "Directory": self.get_directory_from_document(document), - "File": self.get_file_from_document(document), - "ExtSystem": self.get_ext_system_from_history(document.OwnerHistory), - "ExtObject": self.get_ext_object(document), - "ExtIdentifier": document.GlobalId, - "Description": self.get_object_attribute(document, "Description", default=document_name), - "Reference": document_name, - } - for field, key in self.custom_data["documents"].items(): - self.documents[document_name][field] = self.get_element_value(document, key) - - # Attributes is not explicitly defined as a mapping in the responsibliity - # matrix. This is my best guess. This data should not be relied upon until - # this is clarified. - def get_attributes(self): - attributes = self.file.by_type("IfcPropertySet") - for attribute in attributes: - if not attribute.HasProperties: - continue - for property in attribute.HasProperties: - property_name = "{}-{}".format(property.id(), self.get_object_name(property)) - self.attributes[property_name] = { - "CreatedBy": self.get_email_from_history(attribute.OwnerHistory), - "CreatedOn": self.get_created_on_from_history(attribute.OwnerHistory), - "Category": "n/a", # I am not sure what this mapping is meant to be - "SheetName": "Attributes", - "RowName": "n/a", # I am not sure what this mapping is meant to be - "Value": self.get_property_value(property), - "Unit": "{}{}".format( - property.Unit.Prefix if hasattr(property.Unit, "Prefix") else "", - property.Unit.Name if hasattr(property.Unit, "Name") else "n/a", - ) - if hasattr(property, "Unit") and property.Unit - else "n/a", - "ExtSystem": self.get_ext_system_from_history(attribute.OwnerHistory), - "ExtObject": self.get_ext_object(attribute), - "ExtIdentifier": attribute.GlobalId, - "Description": self.get_object_attribute(attribute, "Description", default="n/a"), - # I'm holding off implementing this until I understand a bit - # more about attributes - "AllowedValues": "n/a", - } - - def get_coordinates(self): - coordinates = ( - self.file.by_type("IfcBuildingStorey") + self.file.by_type("IfcSpace") + self.file.by_type("IfcProduct") - ) - for coordinate in coordinates: - coordinate_name = "{}/{}".format(coordinate.is_a(), self.get_object_name(coordinate)) - mat = ifcopenshell.util.placement.get_local_placement(coordinate.ObjectPlacement) - x, y, z = mat[:,3][:3] - self.coordinates[coordinate_name] = { - "CreatedBy": self.get_email_from_history(coordinate.OwnerHistory), - "CreatedOn": self.get_created_on_from_history(coordinate.OwnerHistory), - "Category": "Location", # I am not sure what this mapping is meant to be - "SheetName": "Coordinates", - "RowName": "n/a", - "CoordinateXAxis": x, - "CoordinateYAxis": y, - "CoordinateZAxis": z, - "ExtSystem": self.get_ext_system_from_history(coordinate.OwnerHistory), - "ExtObject": self.get_ext_object(coordinate), - "ExtIdentifier": coordinate.GlobalId, - # Holding off implementing this, see Bug #688: - # https://github.com/IfcOpenShell/IfcOpenShell/issues/688 - "ClockwiseRotation": "n/a", # X axis - "ElevationalRotation": "n/a", # Y axis - "YawRotation": "n/a", # Z axis - } - - # I don't fully understand this worksheet. Don't trust this data. - def get_issues(self): - issues = self.file.by_type("IfcApproval") - for issue in issues: - issue_name = self.get_object_name(issue) - self.issues[issue_name] = { - "CreatedBy": self.get_email_from_history(issue.OwnerHistory), - "CreatedOn": self.get_created_on_from_history(issue.OwnerHistory), - "Type": "n/a", # How do we get to the Pset_Risk from the IfcApproval? - "Risk": "n/a", # How do we get to the Pset_Risk from the IfcApproval? - "Chance": "n/a", # How do we get to the Pset_Risk from the IfcApproval? - "Impact": "n/a", # How do we get to the Pset_Risk from the IfcApproval? - "SheetName1": "n/a", - "RowName1": "n/a", - "SheetName2": "n/a", - "RowName2": "n/a", - "Description": self.get_object_attribute(issue, "Description", default="n/a"), - "Owner": self.get_email_from_history(issue.RequestingApproval), # Is this correct? - "Mitigation": "n/a", - "ExtSystem": self.get_ext_system_from_history(issue.OwnerHistory), - "ExtObject": self.get_ext_object(issue), - "ExtIdentifier": issue.GlobalId, - } - - def get_directory_from_document(self, document): - if self.file.schema == "IFC2X3": - if document.HasDocumentReferences: - for reference in document.HasDocumentReferences: - return self.get_object_attribute(reference, "Location", default="n/a") - else: - return self.get_object_attribute(document, "Location", default="n/a") - - def get_file_from_document(self, document): - if self.file.schema == "IFC2X3": - if document.HasDocumentReferences: - for reference in document.HasDocumentReferences: - return self.get_object_attribute(reference, "Name", default="n/a") - else: - self.get_object_attribute(document, "Identification", default="n/a") - - def get_resource_names_from_job(self, job): - names = [] - if job.OperatesOn and job.OperatesOn.RelatedObjects: - for object in job.OperatesOn.RelatedObjects: - names.append(object.Name) - return ",".join(names) - - def get_priors_from_job(self, job): - # The responsibility matrix is vague as to whether it expects a task - # name or a task identification. I chose task name. - if ( - job.IsSuccessorFrom - and job.IsSuccessorFrom.RelatingProcess - and job.IsSuccessorFrom.RelatingProcess.is_a("IfcTask") - ): - return self.get_object_name(job.IsSuccessorFrom.RelatingProcess) - - def get_row_name_from_connection(self, connection, key): - if not connection.is_a("IfcRelConnectsElements"): - return None - object = getattr(connection, key) - if self.is_object_a_component_asset(object): - return object.Name - self.logger.error("The connected object relationship %s is not a component asset for %s", key, connection) - - def get_port_name_from_connection(self, connection, key): - if not connection.is_a("IfcRelConnectsPorts"): - return None - object = getattr(connection, key) - if object and self.is_object_a_component_asset(object): - return object.Name - self.logger.error("The connected object relationship %s is not a component asset for %s", key, connection) - - def is_object_a_component_asset(self, obj): - return obj in self.component_assets - - def get_space_name_from_component(self, component): - for relationship in component.ContainedInStructure: - if relationship.RelatingStructure.is_a("IfcSpace") and relationship.RelatingStructure.Name: - return relationship.RelatingStructure.Name - self.logger.error("A related space name could not be determined for %s", component) - - def get_type_name_from_object(self, object): - if self.file.schema == "IFC2X3": - for relationship in object.IsDefinedBy: - if relationship.is_a("IfcRelDefinesByType") and relationship.RelatingType.Name: - return relationship.RelatingType.Name - else: - for relationship in object.IsTypedBy: - if relationship.RelatingType.Name: - return relationship.RelatingType.Name - self.logger.error("A related type name could not be determined for %s", object) - - def get_expected_life_from_type(self, type): - if self.file.schema == "IFC2X3": - return self.get_pset_value_from_object(type, "COBie_ServiceLife", "ServiceLifeDuration", "n/a") - return self.get_pset_value_from_object(type, "Pset_ServiceLife", "ServiceLifeDuration", "n/a") - - def get_contact_pset_value_from_object(self, object, pset_name, property_name): - result = self.get_pset_value_from_object(object, pset_name, property_name) - if not result: - self.logger.error("No property %s in %s was found for %s", property_name, pset_name, object) - if result not in self.contacts: - self.logger.error("A coresponding %s contact in %s was not found for %s", property_name, pset_name, object) - return result - - def get_pset_value_from_object(self, object, pset_name, property_name, default=None, picklist=None): - pset = self.get_pset_from_object(object, pset_name) - if not pset: - if picklist: - self.picklists[picklist].append(default) - return default - prop = self.get_property_from_pset(pset, property_name, default) - if picklist: - self.picklists[picklist].append(prop) - return prop - - def get_grouped_product_names_from_object(self, object, type): - names = [] - for relationship in object.IsGroupedBy: - for related_object in relationship.RelatedObjects: - if related_object.is_a(type): - names.append(related_object.Name) - if names: - return ",".join(names) - self.logger.error("No related %s were found for %s", type, object) - - def get_net_area_from_space(self, space): - qto = self.get_qto_from_object(space, "Qto_SpaceBaseQuantities") - if not qto: - return "n/a" - return self.get_property_from_qto(qto, "NetFloorArea", "AreaValue") - - def get_gross_area_from_space(self, space): - qto = self.get_qto_from_object(space, "Qto_SpaceBaseQuantities") - if not qto: - return "n/a" - return self.get_property_from_qto(qto, "GrossFloorArea", "AreaValue") - - def get_usable_height_from_space(self, space): - qto = self.get_qto_from_object(space, "Qto_SpaceBaseQuantities") - if not qto: - return "n/a" - return self.get_property_from_qto(qto, "FinishCeilingHeight", "LengthValue") - - def get_qto_from_object(self, object, name): - for relationship in object.IsDefinedBy: - if ( - relationship.is_a("IfcRelDefinesByProperties") - and relationship.RelatingPropertyDefinition.is_a("IfcQuantitySet") - and relationship.RelatingPropertyDefinition.Name == name - ): - return relationship.RelatingPropertyDefinition - self.logger.warning("The qto %s was not found for %s", name, object) - - def get_property_from_qto(self, qto, name, attribute): - for property in qto.Quantities: - if property.Name == name: - return getattr(property, attribute) - self.logger.warning("The quantity value %s was not found for %s", name, qto) - return "n/a" - - def get_property_from_pset(self, pset, name, default=None): - for prop in pset.HasProperties: - if prop.Name == name: - return prop.NominalValue.wrappedValue - self.logger.warning("The property %s was not found for %s", name, pset) - return default - - def get_property_value(self, prop, name=None): - if not prop.is_a("IfcPropertySingleValue"): - return "n/a" - if name is not None and prop.Name != name: - return "n/a" - value = self.get_object_attribute(prop, "NominalValue", default=None) - if value: - return value.wrappedValue - return "n/a" - - def get_pset_from_object(self, object, name): - if object.is_a("IfcTypeObject"): - if object.HasPropertySets: - for pset in object.HasPropertySets: - if pset.is_a("IfcPropertySet") and pset.Name == name: - return pset - else: - for relationship in object.IsDefinedBy: - if ( - relationship.is_a("IfcRelDefinesByProperties") - and relationship.RelatingPropertyDefinition.is_a("IfcPropertySet") - and relationship.RelatingPropertyDefinition.Name == name - ): - return relationship.RelatingPropertyDefinition - self.logger.warning("The pset %s was not found for %s", name, object) - - def get_height_from_storey(self, storey): - for relationship in storey.IsDefinedBy: - if not relationship.RelatingPropertyDefinition.is_a("IfcElementQuantity"): - continue - for quantity in relationship.RelatingPropertyDefinition.Quantities: - if quantity.is_a("IfcQuantityLength") and quantity.LengthValue: - return quantity.LengthValue - self.logger.warning("A height length value was not found for %s", storey) - return "n/a" - - def get_created_on_from_history(self, history): - if history.CreationDate: - return datetime.datetime.fromtimestamp(history.CreationDate).isoformat() - self.logger.warning("A created on date was not found for %s", history) - return self.default_date - - def get_object_attribute(self, object, attribute, is_primary_key=False, picklist=None, default=None): - result = getattr(object, attribute) - if result: - if picklist: - self.picklists[picklist].append(result) - return result - if is_primary_key: - self.logger.error("The primary key attribute %s was not found for %s", attribute, object) - else: - self.logger.warning("The attribute %s was not found for %s", attribute, object) - return default - - def get_ext_project_object(self): - self.picklists["objType"].append("IfcProject") - return "IfcProject" - - def get_ext_site_object(self): - self.picklists["objType"].append("IfcSite") - return "IfcSite" - - def get_ext_object(self, object): - self.picklists["objType"].append(object.is_a()) - return object.is_a() - - def get_ext_system_from_history(self, history): - return history.OwningApplication.ApplicationFullName - - def get_object_name(self, object): - if not object.Name: - self.logger.error("A primary key name was not found for %s", object) - return "Object{}".format(object.id()) - return object.Name - - def get_area_measurement_from_building(self, building): - for relationship in building.IsDefinedBy: - if ( - relationship.RelatingPropertyDefinition.is_a("IfcElementQuantity") - and relationship.RelatingPropertyDefinition.MethodOfMeasurement - ): - return relationship.RelatingPropertyDefinition.MethodOfMeasurement - self.logger.warning("A method of measurement was not defined for %s", building) - - def get_unit_type_from_units(self, units, type): - for unit in units: - if unit.UnitType == type: - if unit.is_a("IfcSIUnit") and unit.Prefix: - return "{}{}".format(unit.Prefix, unit.Name) - return unit.Name - self.logger.error("A unit %s was not defined in this project for %s", type, units) - - def get_monetary_unit_from_units(self, units): - for unit in units: - if unit.is_a("IfcMonetaryUnit"): - return unit.Currency - self.logger.error("A monetary unit could not be found for %s", units) - - def get_project_globalid_from_building(self, building): - return self.get_parent_spatial_element(building, "IfcProject").GlobalId - - def get_site_globalid_from_building(self, building): - return self.get_parent_spatial_element(building, "IfcSite").GlobalId - - def get_project_name_from_building(self, building): - project = self.get_parent_spatial_element(building, "IfcProject") - if project.Name: - return project.Name - self.logger.error("The project name is empty for %s", project) - return "n/a" - - def get_site_name_from_building(self, building): - site = self.get_parent_spatial_element(building, "IfcSite") - if site.Name: - return site.Name - self.logger.error("The site name is empty for %s", site) - return "n/a" - - def get_units_from_building(self, building): - return self.get_parent_spatial_element(building, "IfcProject").UnitsInContext.Units - - def get_parent_spatial_element(self, child, name): - for relationship in child.Decomposes: - if relationship.RelatingObject.is_a(name): - return relationship.RelatingObject - return self.get_parent_spatial_element(relationship.RelatingObject, name) - return None - - def get_category_from_object(self, object, picklist): - class_identification = None - class_name = None - for association in object.HasAssociations: - if not association.is_a("IfcRelAssociatesClassification"): - continue - if not association.RelatingClassification.is_a("IfcClassificationReference"): - continue - if self.file.schema == "IFC2X3": - class_identification = association.RelatingClassification.ItemReference - else: - class_identification = association.RelatingClassification.Identification - class_name = association.RelatingClassification.Name - break - if not class_identification or class_name: - self.logger.error("The classification has invalid identification and name for %s", object) - result = "{}:{}".format(class_identification, class_name) - self.picklists[picklist].append(result) - return result - # The responsibility matrix lists a fallback, but it is a very - # cumbersome check, and so it is not implemented here. - - def get_name_from_person(self, person, attribute): - name = getattr(person, attribute) - if not name or not name.isalpha(): - self.logger.warning('The person\'s %s seems to be badly formatted ("%s") for %s', attribute, name, person) - return name if name else "n/a" - - def get_lines_from_address(self, address): - result = self.get_attribute_from_address(address, "AddressLines") - if isinstance(result, tuple): - return ", ".join(result) - return result - - def get_attribute_from_address(self, address, attribute): - result = getattr(address, attribute) - if not result: - self.logger.warning("The address %s seems to not exist for %s", attribute, address) - return "n/a" - return result - - def get_email_from_history(self, history): - person = history.OwningUser.ThePerson - organisation = history.OwningUser.TheOrganization - email = self.get_email_from_person_or_organisation(person) - if email: - return email - - email = self.get_email_from_person_or_organisation(organisation) - if email: - return email - - given_name = person.GivenName if person.GivenName else "unknown" - family_name = person.FamilyName if person.FamilyName else "unknown" - organisation_name = organisation.Name if organisation.Name else "unknown" - - if given_name == "unknown" and family_name == "unknown" and organisation_name == "unknown": - self.logger.error("No primary key could be determined from %s", history) - - return "{}{}@{}".format(given_name, family_name, organisation_name) - - def get_postal_address_from_history(self, history): - for address in history.OwningUser.ThePerson.Addresses or []: - if address.is_a("IfcPostalAddress"): - return address - for address in history.OwningUser.TheOrganization.Addresses or []: - if address.is_a("IfcPostalAddress"): - return address - return self.file.createIfcPostalAddress() - - def get_category_from_history(self, history): - roles = [] - both = history.OwningUser - person = both.ThePerson - organisation = both.TheOrganization - both_roles = list(both.Roles) if both.Roles else [] - person_roles = list(person.Roles) if person.Roles else [] - organisation_roles = list(organisation.Roles) if organisation.Roles else [] - for role in both_roles + person_roles + organisation_roles: - roles.append(self.get_role(role)) - result = ",".join(set(roles)) - if not result: - self.logger.error("No roles could be found for %s", history) - return - self.picklists["Category-Role"].append(result) - return result - - def get_phone_from_history(self, history): - person = history.OwningUser.ThePerson - organisation = history.OwningUser.TheOrganization - phone = self.get_phone_from_person_or_organisation(person) - if phone: - return phone - phone = self.get_phone_from_person_or_organisation(organisation) - if phone: - return phone - return "n/a" - - def get_ext_object_from_history(self, history): - result = history.OwningUser.is_a() - self.picklists["objType"].append(result) - return result - - def get_department_from_history(self, history): - organisation = history.OwningUser.TheOrganization - department = self.get_internal_location_from_organisation(organisation) - if department: - return department - last_department = None - for relationship in organisation.Relates: - for related_organisation in relationship.RelatedOrganizations: - department = self.get_internal_location_from_organisation(related_organisation) - if department: - last_department = department - if last_department: - return last_department - return history.OwningUser.TheOrganization.Name or "n/a" - - def get_internal_location_from_organisation(self, organisation): - for address in organisation.Addresses or []: - if address.is_a("IfcPostalAddress"): - return address.InternalLocation - self.logger.warning("An internal location was not found for %s", organisation) - - def get_role(self, role): - if role.Role == "USERDEFINED": - return role.UserDefinedRole - return role.Role - - def get_phone_from_person_or_organisation(self, person_or_org): - for address in person_or_org.Addresses or []: - if address.is_a("IfcTelecomAddress"): - return address.TelephoneNumbers[0] - self.logger.warning("A phone was not found for {}", person_or_org) - - def get_email_from_person_or_organisation(self, person_or_org): - for address in person_or_org.Addresses or []: - if address.is_a("IfcTelecomAddress"): - return address.ElectronicMailAddresses[0] - self.logger.warning("An email address was not found for {}", person_or_org) - - def get_element_value(self, element, key): - value = self.selector.get_element_value(element, key) - if hasattr(value, "wrappedValue"): - return value.wrappedValue - return value - - -class CobieWriter: - def __init__(self, parser, filename=None): - self.filename = filename - self.parser = parser - self.sheets = [] - self.sheet_data = {} - self.colours = { - "r": "fdff8e", # Required - "i": "fdcd94", # Internal reference - "e": "cd95ff", # External reference - "o": "cdffc8", # Optional - "s": "c0c0c0", # Secondary information - "p": "9ccaff", # Project specific - "n": "000000", # Not used - } - - def write(self): - self.sheets = [ - "Contact", - "Facility", - "Floor", - "Space", - "Zone", - "Type", - "Component", - "System", - "Assembly", - "Connection", - "Spare", - "Resource", - "Job", - "Impact", - "Document", - "Attribute", - "Coordinate", - "Issue", - ] - self.write_data( - "Contact", - self.parser.contacts, - "Email", - [ - "Email", - "CreatedBy", - "CreatedOn", - "Category", - "Company", - "Phone", - "ExtSystem", - "ExtObject", - "ExtIdentifier", - "Department", - "OrganizationCode", - "GivenName", - "FamilyName", - "Street", - "PostalBox", - "Town", - "StateRegion", - "PostalCode", - "Country", - ], - "ririrreeeoooooooooo", - self.parser.custom_data["contacts"], - ) - self.write_data( - "Facility", - self.parser.facilities, - "Name", - [ - "Name", - "CreatedBy", - "CreatedOn", - "Category", - "ProjectName", - "SiteName", - "LinearUnits", - "AreaUnits", - "VolumeUnits", - "CostUnit", - "AreaMeasurement", - "ExternalSystem", - "ExternalProjectObject", - "ExternalProjectIdentifier", - "ExternalSiteObject", - "ExternalSiteIdentifier", - "ExternalFacilityObject", - "ExternalFacilityIdentifier", - "Description", - "ProjectDescription", - "SiteDescription", - "Phase", - ], - "ririrriiiireeeeeeeoooo", - self.parser.custom_data["facilities"], - ) - self.write_data( - "Floor", - self.parser.floors, - "Name", - [ - "Name", - "CreatedBy", - "CreatedOn", - "Category", - "ExtSystem", - "ExtObject", - "ExtIdentifier", - "Description", - "Elevation", - "Height", - ], - "ririeeeooo", - self.parser.custom_data["floors"], - ) - self.write_data( - "Space", - self.parser.spaces, - "Name", - [ - "Name", - "CreatedBy", - "CreatedOn", - "Category", - "FloorName", - "Description", - "ExtSystem", - "ExtObject", - "ExtIdentifier", - "RoomTag", - "UsableHeight", - "GrossArea", - "NetArea", - ], - "ririireeeoooo", - self.parser.custom_data["spaces"], - ) - self.write_data( - "Zone", - self.parser.zones, - "Name", - [ - "Name", - "CreatedBy", - "CreatedOn", - "Category", - "SpaceNames", - "ExtSystem", - "ExtObject", - "ExtIdentifier", - "Description", - ], - "ririieeeo", - self.parser.custom_data["zones"], - ) - self.write_data( - "Type", - self.parser.types, - "Name", - [ - "Name", - "CreatedBy", - "CreatedOn", - "Category", - "Description", - "AssetType", - "Manufacturer", - "ModelNumber", - "WarrantyGuarantorParts", - "WarrantyDurationParts", - "WarrantyGuarantorLabor", - "WarrantyDurationLabor", - "WarrantyDurationUnit", - "ExtSystem", - "ExtObject", - "ExtIdentifier", - "ReplacementCost", - "ExpectedLife", - "DurationUnit", - "NominalLength", - "NominalWidth", - "NominalHeight", - "ModelReference", - "Shape", - "Size", - "Color", - "Finish", - "Grade", - "Material", - "Constituents", - "Features", - "AccessibilityPerformance", - "CodePerformance", - "SustainabilityPerformance", - ], - "riririoooooooeeeooooooooooooooooooo", - self.parser.custom_data["types"], - ) - self.write_data( - "Component", - self.parser.components, - "Name", - [ - "Name", - "CreatedBy", - "CreatedOn", - "TypeName", - "Space", - "Description", - "ExtSystem", - "ExtObject", - "ExtIdentifier", - "SerialNumber", - "InstallationDate", - "WarrantyStartDate", - "TagNumber", - "BarCode", - "AssetIdentifier", - ], - "ririireeeoooooo", - self.parser.custom_data["components"], - ) - self.write_data( - "System", - self.parser.systems, - "Name", - [ - "Name", - "CreatedBy", - "CreatedOn", - "Category", - "ComponentNames", - "ExtSystem", - "ExtObject", - "ExtIdentifier", - "Description", - ], - "ririieeeo", - self.parser.custom_data["systems"], - ) - self.write_data( - "Assembly", - self.parser.assemblies, - "Name", - [ - "Name", - "CreatedBy", - "CreatedOn", - "SheetName", - "ParentName", - "ChildNames", - "AssemblyType", - "ExtSystem", - "ExtObject", - "ExtIdentifier", - "Description", - ], - "rirrrrreeeo", - self.parser.custom_data["assemblies"], - ) - self.write_data( - "Connection", - self.parser.connections, - "Name", - [ - "Name", - "CreatedBy", - "CreatedOn", - "ConnectionType", - "SheetName", - "RowName1", - "RowName2", - "RealizingElement", - "PortName1", - "PortName2", - "ExtSystem", - "ExtObject", - "ExtIdentifier", - "Description", - ], - "ririiiiiiieeeo", - self.parser.custom_data["connections"], - ) - self.write_data( - "Spare", - self.parser.spares, - "Name", - [ - "Name", - "CreatedBy", - "CreatedOn", - "Category", - "TypeName", - "Suppliers", - "ExtSystem", - "ExtObject", - "ExtIdentifier", - "Description", - "SetNumber", - "PartNumber", - ], - "ririiieeeooo", - self.parser.custom_data["spares"], - ) - self.write_data( - "Resource", - self.parser.resources, - "Name", - [ - "Name", - "CreatedBy", - "CreatedOn", - "Category", - "ExtSystem", - "ExtObject", - "ExtIdentifier", - "Description", - ], - "ririeeeo", - self.parser.custom_data["resources"], - ) - self.write_data( - "Job", - self.parser.jobs, - "Name", - [ - "Name", - "CreatedBy", - "CreatedOn", - "Category", - "Status", - "TypeName", - "Description", - "Duration", - "DurationUnit", - "Start", - "TaskStartUnit", - "Frequency", - "FrequencyUnit", - "ExtSystem", - "ExtObject", - "ExtIdentifier", - "TaskNumber", - "Priors", - "ResourceNames", - ], - "ririiirriririeeeoii", - self.parser.custom_data["jobs"], - ) - self.write_data( - "Impact", - self.parser.impacts, - "Name", - [ - "Name", - "CreatedBy", - "CreatedOn", - "ImpactType", - "ImpactStage", - "SheetName", - "RowName", - "Value", - "Unit", - "LeadInTime", - "Duration", - "LeadOutTime", - "ExtSystem", - "ExtObject", - "ExtIdentifier", - "Description", - ], - "ririiiirioooeeeo", - self.parser.custom_data["impacts"], - ) - self.write_data( - "Document", - self.parser.documents, - "Name", - [ - "Name", - "CreatedBy", - "CreatedOn", - "Category", - "ApprovalBy", - "Stage", - "SheetName", - "RowName", - "Directory", - "File", - "ExtSystem", - "ExtObject", - "ExtIdentifier", - "Description", - "Reference", - ], - "ririiiiirreeeoo", - self.parser.custom_data["documents"], - ) - self.write_data( - "Attribute", - self.parser.attributes, - "Name", - [ - "Name", - "CreatedBy", - "CreatedOn", - "Category", - "SheetName", - "RowName", - "Value", - "Unit", - "ExtSystem", - "ExtObject", - "ExtIdentifier", - "Description", - "AllowedValues", - ], - "ririiirreeeoo", - ) - self.write_data( - "Coordinate", - self.parser.coordinates, - "Name", - [ - "Name", - "CreatedBy", - "CreatedOn", - "Category", - "SheetName", - "RowName", - "CoordinateXAxis", - "CoordinateYAxis", - "CoordinateZAxis", - "ExtSystem", - "ExtObject", - "ExtIdentifier", - "ClockwiseRotation", - "ElevationalRotation", - "YawRotation", - ], - "ririiooooeeeooo", - ) - self.write_data( - "Issue", - self.parser.issues, - "Name", - [ - "Name", - "CreatedBy", - "CreatedOn", - "Type", - "Risk", - "Chance", - "Impact", - "SheetName1", - "RowName1", - "SheetName2", - "RowName2", - "Description", - "Owner", - "Mitigation", - "ExtSystem", - "ExtObject", - "ExtIdentifier", - ], - "ririooooooooooeee", - ) - - def write_data(self, sheet, data, primary_key, fieldnames, colours, custom_data={}): - self.sheet_data[sheet] = {"headers": fieldnames + list(custom_data.keys()), "colours": colours, "rows": []} - for name, row in data.items(): - row[primary_key] = name - values = [] - for fieldname in fieldnames: - values.append(row[fieldname]) - for fieldname in custom_data.keys(): - values.append(row[fieldname]) - self.sheet_data[sheet]["rows"].append(values) - - -class CobieCsvWriter(CobieWriter): - def write(self): - import csv - - super().write() - for sheet, data in self.sheet_data.items(): - with open(os.path.join(self.filename, "{}.csv".format(sheet)), "w", newline="", encoding="utf-8") as file: - writer = csv.writer(file) - writer.writerow(data["headers"]) - for row in data["rows"]: - writer.writerow(row) - - -class CobieXlsWriter(CobieWriter): - def write(self): - from xlsxwriter import Workbook - - super().write() - self.workbook = Workbook(self.filename + ".xlsx") - - self.cell_formats = {} - for key, value in self.colours.items(): - self.cell_formats[key] = self.workbook.add_format() - self.cell_formats[key].set_bg_color(value) - - for sheet in self.sheets: - self.write_worksheet(sheet) - self.workbook.close() - - def write_worksheet(self, name): - worksheet = self.workbook.add_worksheet(name) - r = 0 - c = 0 - for header in self.sheet_data[name]["headers"]: - cell = worksheet.write(r, c, header, self.cell_formats["s"]) - c += 1 - c = 0 - r += 1 - for row in self.sheet_data[name]["rows"]: - c = 0 - for col in row: - if c >= len(self.sheet_data[name]["colours"]): - cell_format = "p" - else: - cell_format = self.sheet_data[name]["colours"][c] - cell = worksheet.write(r, c, col, self.cell_formats[cell_format]) - c += 1 - r += 1 - - -class CobieOdsWriter(CobieWriter): - def write(self): - from odf.opendocument import OpenDocumentSpreadsheet - from odf.style import Style, TableCellProperties - - super().write() - self.doc = OpenDocumentSpreadsheet() - - self.cell_formats = {} - for key, value in self.colours.items(): - style = Style(name=key, family="table-cell") - style.addElement(TableCellProperties(backgroundcolor="#" + value)) - self.doc.automaticstyles.addElement(style) - self.cell_formats[key] = style - - for sheet in self.sheets: - self.write_table(sheet) - self.doc.save(self.filename, True) - - def write_table(self, name): - from odf.table import Table, TableRow, TableCell - from odf.text import P - - table = Table(name=name) - tr = TableRow() - for header in self.sheet_data[name]["headers"]: - tc = TableCell(valuetype="string", stylename="s") - tc.addElement(P(text=header)) - tr.addElement(tc) - table.addElement(tr) - for row in self.sheet_data[name]["rows"]: - tr = TableRow() - c = 0 - for col in row: - if c >= len(self.sheet_data[name]["colours"]): - cell_format = "p" - else: - cell_format = self.sheet_data[name]["colours"][c] - tc = TableCell(valuetype="string", stylename=cell_format) - tc.addElement(P(text=col)) - tr.addElement(tc) - c += 1 - table.addElement(tr) - self.doc.spreadsheet.addElement(table) - - -if __name__ == "__main__": - parser = argparse.ArgumentParser(description="Converts COBie IFC MVD into its spreadsheet equivalent") - parser.add_argument("input", type=str, help="Specify an IFC file to process") - parser.add_argument("output", type=str, help="The output directory for CSV or filename for other formats") - parser.add_argument("-l", "--log", type=str, help="Specify where errors should be logged", default="process.log") - parser.add_argument( - "-f", "--format", type=str, help="Choose which format to export in (csv/ods/xlsx)", default="csv" - ) - parser.add_argument( - "-c", "--components", type=str, help="A custom selector for components. Defaults to COBie", default=".COBie" - ) - parser.add_argument( - "-t", "--types", type=str, help="A custom selector for types. Defaults to COBieType", default=".COBieType" - ) - parser.add_argument( - "-d", - "--data", - type=str, - help="JSON file containing custom data to be appended to the COBie spreadsheet template", - default="", - ) - args = vars(parser.parse_args()) - - print("Processing IFC file ...") - - start = time.time() - logging.basicConfig(filename=args["log"], filemode="a", level=logging.DEBUG) - logger = logging.getLogger("IFCtoCOBie") - logger.info("Starting conversion") - selector = ifcopenshell.util.selector.Selector() - parser = IfcCobieParser(logger, selector) - if args["data"]: - with open(bpy.context.scene.BIMProperties.cobie_json_file, "r") as f: - custom_data = json.load(f) - else: - custom_data = {} - parser.parse(args["input"], args["types"], args["components"], custom_data) - - print("Generating reports ...") - - if args["format"] == "xlsx": - writer = CobieXlsWriter(parser, args["output"]) - elif args["format"] == "ods": - writer = CobieOdsWriter(parser, args["output"]) - else: - writer = CobieCsvWriter(parser, args["output"]) - writer.write() - - logger.info("Finished conversion in %ss", time.time() - start) - print("# All reports are complete :-)") diff --git a/src/ifccobie/get_maintainable_assets.py b/src/ifccobie/get_maintainable_assets.py deleted file mode 100644 index 591a080088..0000000000 --- a/src/ifccobie/get_maintainable_assets.py +++ /dev/null @@ -1,79 +0,0 @@ - -# IfcCOBie - Extract COBie data from IFC to spreadsheets -# Copyright (C) 2019, 2020, 2021 Dion Moult -# -# This file is part of IfcCOBie. -# -# IfcCOBie 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. -# -# IfcCOBie 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 IfcCOBie. If not, see . - -import json -import ifcopenshell -import ifcopenshell.util.selector - -with open("../blenderbim/blenderbim/bim/schema/entity_descriptions.json") as f: - entity_descriptions = json.load(f) -with open("../blenderbim/blenderbim/bim/schema/enum_descriptions.json") as f: - enum_descriptions = json.load(f) - -print('{|class="wikitable"') -print("! IFC Class") -print("! Predefined Type") - - -def print_entity(entity): - print("|-") - print("| " + entity.name()) - print("| ") - if entity.name() in entity_descriptions: - print( - "{} ... [https://standards.buildingsmart.org/IFC/DEV/IFC4_3/RC1/HTML/link/{}.htm read more]".format( - entity_descriptions[entity.name()], entity.name().lower() - ) - ) - else: - print( - "No description provided ... [https://standards.buildingsmart.org/IFC/DEV/IFC4_3/RC1/HTML/link/{}.htm read more]".format( - entity_descriptions[entity.name()], entity.name().lower() - ) - ) - for attribute in entity.attributes(): - if attribute.name() == "PredefinedType": - enum = attribute.type_of_attribute().declared_type() - print("\nThe following predefined types are defined:\n") - for item in enum.enumeration_items(): - # print('|-') - # print('| ' + entity.name()) - # print('| ' + item) - if enum.name() in enum_descriptions and item in enum_descriptions[enum.name()]: - print( - "* '''{}''' - {} ... [https://standards.buildingsmart.org/IFC/DEV/IFC4_3/RC1/HTML/link/{}.htm read more]".format( - item, enum_descriptions[enum.name()][item], enum.name().lower() - ) - ) - else: - print( - "* '''{}''' - No description provided ... [https://standards.buildingsmart.org/IFC/DEV/IFC4_3/RC1/HTML/link/{}.htm read more]".format( - item, enum.name().lower() - ) - ) - - for subtype in entity.subtypes(): - print_entity(subtype) - - -for asset in ifcopenshell.util.selector.cobie_component_assets: - schema = ifcopenshell.ifcopenshell_wrapper.schema_by_name("IFC4") - print_entity(schema.declaration_by_name(asset)) - -print("|}") diff --git a/src/ifccobie/icon.ico b/src/ifccobie/icon.ico deleted file mode 100644 index b9eee9acb9dc5d899b66ab21088981ec5bbc2f7d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 104035 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 ZQ9L2_>W{|do_Lq~ki&3w(&j+l{r^sW^C Date: Fri, 22 Sep 2023 17:17:15 +1000 Subject: [PATCH 139/190] Refactor IfcFM to make it easier to exclude things --- src/ifcfm/ifcfm/__init__.py | 34 ++++++++------ src/ifcfm/ifcfm/basic.py | 34 ++++++-------- src/ifcfm/ifcfm/cobie.py | 92 ++++++++++++++++--------------------- 3 files changed, 72 insertions(+), 88 deletions(-) diff --git a/src/ifcfm/ifcfm/__init__.py b/src/ifcfm/ifcfm/__init__.py index 8eaefb279b..5b81f02367 100644 --- a/src/ifcfm/ifcfm/__init__.py +++ b/src/ifcfm/ifcfm/__init__.py @@ -41,27 +41,21 @@ class Parser: self.file = None self.preset = preset self.categories = {} - self.get_category_elements = {} - self.get_element_data = {} + self.config = None self.get_custom_element_data = {} self.duplicate_keys = [] if isinstance(preset, str): module = importlib.import_module(f"ifcfm.{preset}") - self.get_category_elements = getattr(module, "get_category_elements") - self.get_element_data = getattr(module, "get_element_data") - elif isinstance(preset, dict): - self.get_category_elements = preset["get_category_elements"] - self.get_element_data = preset["get_element_data"] + self.config = getattr(module, "config") else: - self.get_category_elements = getattr(preset, "get_category_elements") - self.get_element_data = getattr(preset, "get_element_data") + self.config = preset def parse(self, ifc_file): - for category_name, get_category_elements in self.get_category_elements.items(): + for category_name, category_config in self.config["categories"].items(): self.categories.setdefault(category_name, {}) - for element in get_category_elements(ifc_file): - get_element_data = self.get_element_data[category_name] + for element in category_config["get_category_elements"](ifc_file): + get_element_data = category_config["get_element_data"] if isinstance(get_element_data, dict): data = {} @@ -71,7 +65,7 @@ class Parser: data = get_element_data(ifc_file, element) or {} get_custom_element_data = self.get_custom_element_data.get(category_name, lambda x, y: None) - if isinstance(get_element_data, dict): + if isinstance(get_custom_element_data, dict): custom_data = {} for key, query in get_custom_element_data.items(): custom_data[key] = ifcopenshell.util.selector.get_element_value(element, query) @@ -87,6 +81,15 @@ class Parser: self.duplicate_keys.append((self.categories[category_name][key], data)) self.categories[category_name][key] = data + def exclude_categories(self, names): + for name in names: + if name in self.config["categories"]: + del self.config["categories"][name] + + def exclude_element_data(self, category, names): + headers = self.config["categories"][category]["headers"] + self.config["categories"][category]["headers"] = [h for h in headers if h not in names] + class Writer: def __init__(self, parser): @@ -105,8 +108,9 @@ class Writer: empty = self.config.get("empty", empty) bool_true = self.config.get("bool_true", bool_true) bool_false = self.config.get("bool_false", bool_false) - for category, data in self.parser.categories.items(): - headers = self.config.get("categories", {}).get(category, {}).get("headers", []) + for category, config in self.config["categories"].items(): + data = self.parser.categories.get(category, None) + headers = config["headers"] if not data: self.categories[category] = {"headers": headers, "rows": []} diff --git a/src/ifcfm/ifcfm/basic.py b/src/ifcfm/ifcfm/basic.py index 5e3d81d3dd..f4ccb81ffd 100644 --- a/src/ifcfm/ifcfm/basic.py +++ b/src/ifcfm/ifcfm/basic.py @@ -228,26 +228,6 @@ def get_property(psets, pset_name, prop_name, decimals=None): return round(result, decimals) -get_category_elements = { - "Facilities": get_facilities, - "Storeys": get_storeys, - "Spaces": get_spaces, - "Zones": get_zones, - "ElementTypes": get_element_types, - "Elements": get_elements, - "Systems": get_systems, -} - -get_element_data = { - "Facilities": get_facility_data, - "Storeys": get_storey_data, - "Spaces": get_space_data, - "Zones": get_zone_data, - "ElementTypes": get_element_type_data, - "Elements": get_element_data, - "Systems": get_system_data, -} - config = { "colours": { "h": "dddddd", # Header data @@ -278,6 +258,8 @@ config = { ], "colours": "ppppreeeeesss", "sort": [{"name": "Name", "order": "ASC"}], + "get_category_elements": get_facilities, + "get_element_data": get_facility_data, }, "Storeys": { "headers": [ @@ -292,6 +274,8 @@ config = { ], "colours": "ppreeees", "sort": [{"name": "Elevation", "order": "ASC"}, {"name": "Name", "order": "ASC"}], + "get_category_elements": get_storeys, + "get_element_data": get_storey_data, }, "Spaces": { "headers": [ @@ -308,11 +292,15 @@ config = { ], "colours": "ppprreeess", "sort": [{"name": "LevelName", "order": "ASC"}, {"name": "Name", "order": "ASC"}], + "get_category_elements": get_spaces, + "get_element_data": get_space_data, }, "Zones": { "headers": ["Name", "SpaceName", "AuthorOrganizationName", "AuthorDate", "ModelSoftware", "ModelID"], "colours": "prreee", "sort": [{"name": "Name", "order": "ASC"}], + "get_category_elements": get_zones, + "get_element_data": get_zone_data, }, "ElementTypes": { "headers": [ @@ -333,6 +321,8 @@ config = { ], "colours": "pppreeeeesssss", "sort": [{"name": "ModelObject", "order": "ASC"}, {"name": "Name", "order": "ASC"}], + "get_category_elements": get_element_types, + "get_element_data": get_element_type_data, }, "Elements": { "headers": [ @@ -356,6 +346,8 @@ config = { ], "colours": "prrrreeeeesssssss", "sort": [{"name": "TypeName", "order": "ASC"}, {"name": "Name", "order": "ASC"}], + "get_category_elements": get_elements, + "get_element_data": get_element_data, }, "Systems": { "headers": [ @@ -369,6 +361,8 @@ config = { ], "colours": "pppreee", "sort": [{"name": "Name", "order": "ASC"}], + "get_category_elements": get_systems, + "get_element_data": get_system_data, }, }, } diff --git a/src/ifcfm/ifcfm/cobie.py b/src/ifcfm/ifcfm/cobie.py index 9eb6eb0d7c..6f385f1278 100644 --- a/src/ifcfm/ifcfm/cobie.py +++ b/src/ifcfm/ifcfm/cobie.py @@ -30,6 +30,8 @@ import ifcopenshell.util.classification # https://github.com/opensourceBIM/COBie-plugins/tree/master/COBieShared/src/org/bimserver/cobie/shared/serialization/util # Some settings are also defined here: # https://github.com/opensourceBIM/COBie-plugins/blob/master/COBiePlugins/lib/IfcToCobieConfig.xml +# Note that the following categories are not implemented in the BIMServer COBie-Plugins: +# Impact, Coordinate, Issue, Picklist def get_contacts(ifc_file): @@ -494,16 +496,16 @@ def get_type_data(ifc_file, element): pset_metadata = {} pset_mapping = { "manufacturer": {"Manufacturer"}, - "model_number": {"ModelNumber", "ArticleNumber", "ModelLabel"}, + "model_number": {"ModelNumber", "ArticleNumber", "ModelReference"}, "warranty_guarantor_parts": {"WarrantyGuarantorParts", "PointOfContact"}, "warranty_guarantor_labor": {"WarrantyGuarantorLabor", "PointOfContact"}, "warranty_description": {"WarrantyDescription", "WarrantyIdentifier"}, "replacement_cost": {"ReplacementCost", "Replacement Cost", "Replacement", "Cost"}, - "nominal_length": {"NominalLength", "OverallLength"}, - "nominal_width": {"NominalWidth", "Width"}, + "nominal_length": {"NominalLength", "OverallLength", "Length"}, + "nominal_width": {"NominalWidth", "OverallWidth", "Width"}, # https://github.com/opensourceBIM/COBie-plugins/blob/master/COBiePlugins/lib/IfcToCobieConfig.xml#L104 "nominal_height": {"NominalHeight", "Height"}, # Original has a typo "Heght" - "model_reference": {"ModelReference", "Reference"}, + "model_reference": {"ModelLabel"}, # I believe this is what the intention was, not "ModelReference". "shape": {"Shape"}, "size": {"Size"}, "color": {"Color", "Colour"}, @@ -1139,52 +1141,6 @@ def get_sheet_name(element): return "Resource" -get_category_elements = { - "Contact": get_contacts, - "Facility": get_facilities, - "Floor": get_floors, - "Space": get_spaces, - "Zone": get_zones, - "Type": get_types, - "Component": get_components, - "System": get_systems, - "Assembly": get_assemblies, - "Connection": get_connections, - "Spare": get_spares, - "Resource": get_resources, - "Job": get_jobs, - # "Impact": get_impacts, # Not implemented for some reason in BIMServer COBie-Plugins - "Document": get_documents, - "Attribute": get_attributes, - # Not implemented for some reason in BIMServer COBie-Plugins - # "Coordinate": get_coordinates, - # "Issue": get_issues, - # "Picklist": get_picklists, -} - -get_element_data = { - "Contact": get_contact_data, - "Facility": get_facility_data, - "Floor": get_floor_data, - "Space": get_space_data, - "Zone": get_zone_data, - "Type": get_type_data, - "Component": get_component_data, - "System": get_system_data, - "Assembly": get_assembly_data, - "Connection": get_connection_data, - "Spare": get_spare_data, - "Resource": get_resource_data, - "Job": get_job_data, - # "Impact": get_impact_data, - "Document": get_document_data, - "Attribute": get_attribute_data, - # "Coordinate": get_coordinate_data, - # "Issue": get_issue_data, - # "Picklist": get_picklist_data, -} - - config = { "colours": { "h": "c0c0c0", # Header data @@ -1225,6 +1181,8 @@ config = { ], "colours": "rrrrrreeeoooooooooo", "sort": [{"name": "Email", "order": "ASC"}], + "get_category_elements": get_contacts, + "get_element_data": get_contact_data, }, "Facility": { "headers": [ @@ -1253,6 +1211,8 @@ config = { ], "colours": "ririrriiiireeeeeeeoooo", "sort": [{"name": "Name", "order": "ASC"}], + "get_category_elements": get_facilities, + "get_element_data": get_facility_data, }, "Floor": { "headers": [ @@ -1269,6 +1229,8 @@ config = { ], "colours": "ririeeeooo", "sort": [{"name": "Elevation", "order": "ASC"}, {"name": "Name", "order": "ASC"}], + "get_category_elements": get_floors, + "get_element_data": get_floor_data, }, "Space": { "headers": [ @@ -1288,6 +1250,8 @@ config = { ], "colours": "ririrreeeoooo", "sort": [{"name": "FloorName", "order": "ASC"}, {"name": "Name", "order": "ASC"}], + "get_category_elements": get_spaces, + "get_element_data": get_space_data, }, "Zone": { "headers": [ @@ -1303,6 +1267,8 @@ config = { ], "colours": "ririreeeo", "sort": [{"name": "Name", "order": "ASC"}], + "get_category_elements": get_zones, + "get_element_data": get_zone_data, }, "Type": { "headers": [ @@ -1344,6 +1310,8 @@ config = { ], "colours": "riririiriririeeeooiorrroooooooooooo", "sort": [{"name": "ExternalObject", "order": "ASC"}, {"name": "Name", "order": "ASC"}], + "get_category_elements": get_types, + "get_element_data": get_type_data, }, "Component": { "headers": [ @@ -1369,6 +1337,8 @@ config = { {"name": "TypeName", "order": "ASC"}, {"name": "Name", "order": "ASC"}, ], + "get_category_elements": get_components, + "get_element_data": get_component_data, }, "System": { "headers": [ @@ -1384,8 +1354,10 @@ config = { ], "colours": "ririieeeo", "sort": [{"name": "Name", "order": "ASC"}], + "get_category_elements": get_systems, + "get_element_data": get_system_data, }, - "Assembly": { + "Assembly": { # Note that this is technically "not required" "headers": [ "Name", "CreatedBy", @@ -1401,8 +1373,10 @@ config = { ], "colours": "ririiiieeeo", "sort": [{"name": "Name", "order": "ASC"}], + "get_category_elements": get_assemblies, + "get_element_data": get_assembly_data, }, - "Connection": { + "Connection": { # Note that this is technically "not required" "headers": [ "Name", "CreatedBy", @@ -1421,6 +1395,8 @@ config = { ], "colours": "ririiiiiiieeeo", "sort": [{"name": "Name", "order": "ASC"}], + "get_category_elements": get_connections, + "get_element_data": get_connection_data, }, "Spare": { "headers": [ @@ -1439,6 +1415,8 @@ config = { ], "colours": "ririiieeeooo", "sort": [{"name": "Name", "order": "ASC"}], + "get_category_elements": get_spares, + "get_element_data": get_spare_data, }, "Resource": { "headers": [ @@ -1453,6 +1431,8 @@ config = { ], "colours": "ririeeeo", "sort": [{"name": "Name", "order": "ASC"}], + "get_category_elements": get_resources, + "get_element_data": get_resource_data, }, "Job": { "headers": [ @@ -1478,6 +1458,8 @@ config = { ], "colours": "ririiirriririeeeoii", "sort": [{"name": "TypeName", "order": "ASC"}, {"name": "TaskNumber", "order": "ASC"}], + "get_category_elements": get_jobs, + "get_element_data": get_job_data, }, "Document": { "headers": [ @@ -1499,6 +1481,8 @@ config = { ], "colours": "ririiiiirreeeoo", "sort": [{"name": "Name", "order": "ASC"}], + "get_category_elements": get_documents, + "get_element_data": get_document_data, }, "Attribute": { "headers": [ @@ -1518,6 +1502,8 @@ config = { ], "colours": "ririiirreeeoo", "sort": [{"name": "Category", "order": "ASC"}, {"name": "Name", "order": "ASC"}], + "get_category_elements": get_attributes, + "get_element_data": get_attribute_data, }, - } + }, } From 9eba3b6eaaed809365f187f2dd1be867d80f9030 Mon Sep 17 00:00:00 2001 From: "A. R. S" Date: Wed, 20 Sep 2023 09:46:57 +0200 Subject: [PATCH 140/190] Try to get unit from EnumerationReference for IfcPropertyEnumeratedValue --- src/ifcopenshell-python/ifcopenshell/util/unit.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/util/unit.py b/src/ifcopenshell-python/ifcopenshell/util/unit.py index 43eae4fd18..a96c424835 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/unit.py +++ b/src/ifcopenshell-python/ifcopenshell/util/unit.py @@ -362,12 +362,18 @@ def get_property_unit(prop, ifc_file): if not unit_assignment: return entity = prop.wrapped_data.declaration().as_entity() + measure_class = None if prop.is_a("IfcPhysicalSimpleQuantity"): measure_class = entity.attribute_by_index(3).type_of_attribute().declared_type().name() elif prop.is_a("IfcPropertySingleValue") and prop.NominalValue: measure_class = prop.NominalValue.is_a() - elif prop.is_a("IfcPropertyEnumeratedValue") and prop.EnumerationValues: - measure_class = prop.EnumerationValues[0].is_a() + elif prop.is_a("IfcPropertyEnumeratedValue"): + if prop.EnumerationReference: + unit = getattr(prop.EnumerationReference, "Unit", None) + if unit: + return unit + if prop.EnumerationValues: + measure_class = prop.EnumerationValues[0].is_a() elif prop.is_a("IfcPropertyListValue") and prop.ListValues: measure_class = prop.ListValues[0].is_a() elif prop.is_a("IfcPropertyBoundedValue"): @@ -393,6 +399,8 @@ def get_property_unit(prop, ifc_file): else: table_units[f"{attribute}Unit"] = None return table_units + if measure_class is None: + return unit_type = get_measure_unit_type(measure_class) units = [u for u in unit_assignment.Units if getattr(u, "UnitType", None) == unit_type] if units: From d3258693b2384b135289c6d38f10f9070d89f1ff Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 22 Sep 2023 11:41:50 +0500 Subject: [PATCH 141/190] Clear constraint and transform lock on duplicating array child object Previously it was locked even though it's not part of an array anymore. --- .../bim/module/geometry/operator.py | 24 ++++--------------- src/blenderbim/blenderbim/tool/blender.py | 12 ++++++---- src/blenderbim/blenderbim/tool/model.py | 18 ++++++++++++++ 3 files changed, 30 insertions(+), 24 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/geometry/operator.py b/src/blenderbim/blenderbim/bim/module/geometry/operator.py index f17d68fb54..272b6706f0 100644 --- a/src/blenderbim/blenderbim/bim/module/geometry/operator.py +++ b/src/blenderbim/blenderbim/bim/module/geometry/operator.py @@ -737,10 +737,7 @@ class OverrideDuplicateMove(bpy.types.Operator): tool.Blender.remove_data_block(temp_data) if new: - array_pset = ifcopenshell.util.element.get_pset(new, "BBIM_Array") - if array_pset: - array_pset = tool.Ifc.get().by_id(array_pset["id"]) - ifcopenshell.api.run("pset.remove_pset", tool.Ifc.get(), product=new, pset=array_pset) + tool.Model.remove_array_from_element(new) old_to_new[tool.Ifc.get_entity(obj)] = [new] if new.is_a("IfcRelSpaceBoundary"): tool.Boundary.decorate_boundary(new_obj) @@ -807,10 +804,7 @@ class OverrideDuplicateMoveLinked(bpy.types.Operator): # Copy the actual class new = blenderbim.core.root.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=new_obj) if new: - array_pset = ifcopenshell.util.element.get_pset(new, "BBIM_Array") - if array_pset: - array_pset = tool.Ifc.get().by_id(array_pset["id"]) - ifcopenshell.api.run("pset.remove_pset", tool.Ifc.get(), product=new, pset=array_pset) + tool.Model.remove_array_from_element(new) old_to_new[tool.Ifc.get_entity(obj)] = new # Recreate decompositions tool.Root.recreate_decompositions(relationships, old_to_new) @@ -1054,12 +1048,7 @@ class OverrideDuplicateMoveAggregate(bpy.types.Operator): ) if new_entity: - # Checks if the object belongs to an Ifc Array - array_pset = ifcopenshell.util.element.get_pset(new_entity, "BBIM_Array") - if array_pset: - array_pset = tool.Ifc.get().by_id(array_pset["id"]) - ifcopenshell.api.run("pset.remove_pset", tool.Ifc.get(), product=new_entity, pset=array_pset) - + tool.Model.remove_array_from_element(new_entity) blenderbim.core.aggregate.unassign_object( tool.Ifc, tool.Aggregate, @@ -1187,12 +1176,7 @@ class RefreshAggregate(bpy.types.Operator): ) if new_entity: - # Checks if the object belongs to an Ifc Array - array_pset = ifcopenshell.util.element.get_pset(new_entity, "BBIM_Array") - if array_pset: - array_pset = tool.Ifc.get().by_id(array_pset["id"]) - ifcopenshell.api.run("pset.remove_pset", tool.Ifc.get(), product=new_entity, pset=array_pset) - + tool.Model.remove_array_from_element(new_entity) blenderbim.core.aggregate.unassign_object( tool.Ifc, tool.Aggregate, diff --git a/src/blenderbim/blenderbim/tool/blender.py b/src/blenderbim/blenderbim/tool/blender.py index 31027b4290..d544a67787 100644 --- a/src/blenderbim/blenderbim/tool/blender.py +++ b/src/blenderbim/blenderbim/tool/blender.py @@ -484,6 +484,13 @@ class Blender: if obj: return obj + @classmethod + def lock_transform(cls, obj, lock_state=True): + for prop in ("lock_location", "lock_rotation", "lock_scale"): + attr = getattr(obj, prop) + for axis_idx in range(3): + attr[axis_idx] = lock_state + class Modifier: @classmethod def is_eligible_for_railing_modifier(cls, obj): @@ -560,10 +567,7 @@ class Blender: modifier_data = list(cls.get_modifiers_data(parent_element))[item] children = cls.get_children_objects(modifier_data) for child_obj in children: - for prop in ("lock_location", "lock_rotation", "lock_scale"): - attr = getattr(child_obj, prop) - for axis_idx in range(3): - attr[axis_idx] = lock_state + Blender.lock_transform(child_obj, lock_state) @classmethod def remove_constraints(cls, parent_element): diff --git a/src/blenderbim/blenderbim/tool/model.py b/src/blenderbim/blenderbim/tool/model.py index 1c12b9a92a..58f0826a1c 100644 --- a/src/blenderbim/blenderbim/tool/model.py +++ b/src/blenderbim/blenderbim/tool/model.py @@ -539,6 +539,24 @@ class Model(blenderbim.core.tool.Model): ] return axes + @classmethod + def remove_array_from_element(cls, element): + array_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") + if not array_pset: + return + + array_pset_data = array_pset["Data"] + array_pset = tool.Ifc.get().by_id(array_pset["id"]) + ifcopenshell.api.run("pset.remove_pset", tool.Ifc.get(), product=element, pset=array_pset) + + # remove constraints + obj = tool.Ifc.get_object(element) + if not array_pset_data and ( + constraint := next((c for c in obj.constraints if c.type == "CHILD_OF"), None) + ): # skip array parents + obj.constraints.remove(constraint) + tool.Blender.lock_transform(obj, False) + @classmethod def regenerate_array(cls, parent, data, keep_objs=False): tool.Blender.Modifier.Array.remove_constraints(tool.Ifc.get_entity(parent)) From d6b4c1f4547d43078c2ae034c5e9ff21c38bdc0b Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 22 Sep 2023 12:16:36 +0500 Subject: [PATCH 142/190] Setup arrays constraints on loading .ifc #3753 --- src/blenderbim/blenderbim/bim/import_ifc.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/blenderbim/blenderbim/bim/import_ifc.py b/src/blenderbim/blenderbim/bim/import_ifc.py index ecfff3bcf0..db041a46d6 100644 --- a/src/blenderbim/blenderbim/bim/import_ifc.py +++ b/src/blenderbim/blenderbim/bim/import_ifc.py @@ -20,6 +20,7 @@ import os import re import bpy import time +import json import bmesh import logging import mathutils @@ -306,6 +307,7 @@ class IfcImporter: self.set_default_context() self.profile_code("Setting default context") self.setup_viewport_camera() + self.setup_arrays() self.update_progress(100) bpy.context.window_manager.progress_end() @@ -1908,6 +1910,15 @@ class IfcImporter: bpy.ops.view3d.view_selected() bpy.ops.object.select_all(action="DESELECT") + def setup_arrays(self): + for element in self.file.by_type("IfcElement"): + pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Array") + if not pset_data["Data"]: # skip array children + continue + for i in range(len(json.loads(pset_data["Data"]))): + tool.Blender.Modifier.Array.set_children_lock_state(element, i, True) + tool.Blender.Modifier.Array.constrain_children_to_parent(element) + class IfcImportSettings: def __init__(self): From a604cf2b2a030c6b561a9cec51db1c9dca749b5a Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 22 Sep 2023 15:24:33 +0500 Subject: [PATCH 143/190] Duplicate the array if all it's elements are selected #3763 Demo - https://imgur.com/a/0wYZOPl --- .../bim/module/geometry/operator.py | 68 ++++++++++++++++--- src/blenderbim/blenderbim/tool/model.py | 61 ++++++++++++----- 2 files changed, 104 insertions(+), 25 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/geometry/operator.py b/src/blenderbim/blenderbim/bim/module/geometry/operator.py index 272b6706f0..23e620f7a4 100644 --- a/src/blenderbim/blenderbim/bim/module/geometry/operator.py +++ b/src/blenderbim/blenderbim/bim/module/geometry/operator.py @@ -553,7 +553,8 @@ class OverrideDelete(bpy.types.Operator): with context.temp_override(active_object=array_parent_obj): bpy.ops.bim.remove_array(item=i) else: - break + break # allows to remove only n last layers of an array + class OverrideOutlinerDelete(bpy.types.Operator): bl_idname = "bim.override_outliner_delete" @@ -702,11 +703,20 @@ class OverrideDuplicateMove(bpy.types.Operator): return {"FINISHED"} def _execute(self, context): + objects_to_duplicate = set(context.selected_objects) + + # handle arrays + arrays_to_duplicate, array_children = self.process_arrays(context) + objects_to_duplicate -= array_children + for child in array_children: + child.select_set(False) + self.new_active_obj = None # Track decompositions so they can be recreated after the operation - relationships = tool.Root.get_decomposition_relationships(context.selected_objects) + relationships = tool.Root.get_decomposition_relationships(objects_to_duplicate) old_to_new = {} - for obj in context.selected_objects: + + for obj in objects_to_duplicate: element = tool.Ifc.get_entity(obj) if element and element.is_a("IfcAnnotation") and element.ObjectType == "DRAWING": continue # For now, don't copy drawings until we stabilise a bit more. It's tricky. @@ -737,14 +747,56 @@ class OverrideDuplicateMove(bpy.types.Operator): tool.Blender.remove_data_block(temp_data) if new: - tool.Model.remove_array_from_element(new) - old_to_new[tool.Ifc.get_entity(obj)] = [new] + # TODO: handle array data for other cases of duplication + array_data = arrays_to_duplicate.get(obj, None) + tool.Model.handle_array_on_copied_element(new, array_data) + if array_data: + for child in tool.Blender.Modifier.Array.get_all_children_objects(new): + child.select_set(True) + + # TODO: add new array children to recreate their decomposition too + old_to_new[element] = [new] if new.is_a("IfcRelSpaceBoundary"): tool.Boundary.decorate_boundary(new_obj) + # Recreate decompositions tool.Root.recreate_decompositions(relationships, old_to_new) blenderbim.bim.handler.refresh_ui_data() + def process_arrays(self, context): + selected_objects = set(context.selected_objects) + array_parents = set() + arrays_to_create = dict() + array_children = set() # will be ignored during the duplication + + for obj in context.selected_objects: + element = tool.Ifc.get_entity(obj) + if not element: + continue + pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") + if not pset: + continue + array_parents.add(tool.Ifc.get().by_guid(pset["Parent"])) + + for array_parent in array_parents: + array_parent_obj = tool.Ifc.get_object(array_parent) + if array_parent_obj not in selected_objects: + continue + + array_data = [] + for modifier_data in tool.Blender.Modifier.Array.get_modifiers_data(array_parent): + children = set(tool.Blender.Modifier.Array.get_children_objects(modifier_data)) + if children.issubset(selected_objects): + modifier_data["children"] = [] + array_data.append(modifier_data) + array_children.update(children) + else: + break # allows to duplicate only n first layers of an array + + if array_data: + arrays_to_create[array_parent_obj] = array_data + + return arrays_to_create, array_children class OverrideDuplicateMoveLinkedMacro(bpy.types.Macro): bl_idname = "bim.override_object_duplicate_move_linked_macro" @@ -804,7 +856,7 @@ class OverrideDuplicateMoveLinked(bpy.types.Operator): # Copy the actual class new = blenderbim.core.root.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=new_obj) if new: - tool.Model.remove_array_from_element(new) + tool.Model.handle_array_on_copied_element(new) old_to_new[tool.Ifc.get_entity(obj)] = new # Recreate decompositions tool.Root.recreate_decompositions(relationships, old_to_new) @@ -1048,7 +1100,7 @@ class OverrideDuplicateMoveAggregate(bpy.types.Operator): ) if new_entity: - tool.Model.remove_array_from_element(new_entity) + tool.Model.handle_array_on_copied_element(new_entity) blenderbim.core.aggregate.unassign_object( tool.Ifc, tool.Aggregate, @@ -1176,7 +1228,7 @@ class RefreshAggregate(bpy.types.Operator): ) if new_entity: - tool.Model.remove_array_from_element(new_entity) + tool.Model.handle_array_on_copied_element(new_entity) blenderbim.core.aggregate.unassign_object( tool.Ifc, tool.Aggregate, diff --git a/src/blenderbim/blenderbim/tool/model.py b/src/blenderbim/blenderbim/tool/model.py index 58f0826a1c..e2a2c29773 100644 --- a/src/blenderbim/blenderbim/tool/model.py +++ b/src/blenderbim/blenderbim/tool/model.py @@ -540,29 +540,56 @@ class Model(blenderbim.core.tool.Model): return axes @classmethod - def remove_array_from_element(cls, element): - array_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") - if not array_pset: - return + def handle_array_on_copied_element(cls, element, array_data=None): + """if no `array_data` is provided then an array will be removed from the element""" - array_pset_data = array_pset["Data"] - array_pset = tool.Ifc.get().by_id(array_pset["id"]) - ifcopenshell.api.run("pset.remove_pset", tool.Ifc.get(), product=element, pset=array_pset) + if array_data is None: + array_pset = ifcopenshell.util.element.get_pset(element, "BBIM_Array") + if not array_pset: + return - # remove constraints - obj = tool.Ifc.get_object(element) - if not array_pset_data and ( - constraint := next((c for c in obj.constraints if c.type == "CHILD_OF"), None) - ): # skip array parents - obj.constraints.remove(constraint) - tool.Blender.lock_transform(obj, False) + array_pset_data = array_pset["Data"] + array_pset = tool.Ifc.get().by_id(array_pset["id"]) + ifcopenshell.api.run("pset.remove_pset", tool.Ifc.get(), product=element, pset=array_pset) + + # remove constraints + obj = tool.Ifc.get_object(element) + if not array_pset_data: # skip array parents + constraint = next((c for c in obj.constraints if c.type == "CHILD_OF"), None) + if constraint: + matrix = obj.matrix_world.copy() + obj.constraints.remove(constraint) + # keep the matrix before the constraint + # otherwise object will jump to some previous position + obj.matrix_world = matrix + tool.Blender.lock_transform(obj, False) + + else: + obj = tool.Ifc.get_object(element) + array_pset = tool.Pset.get_element_pset(element, "BBIM_Array") + default_data = '[{"children": []}]' + ifcopenshell.api.run( + "pset.edit_pset", + tool.Ifc.get(), + pset=array_pset, + properties={"Parent": element.GlobalId, "Data": default_data}, + ) + + tool.Model.regenerate_array(obj, array_data) + + json_data = json.dumps(array_data) + ifcopenshell.api.run("pset.edit_pset", tool.Ifc.get(), pset=array_pset, properties={"Data": json_data}) + + for i in range(len(array_data)): + tool.Blender.Modifier.Array.set_children_lock_state(element, i, True) + tool.Blender.Modifier.Array.constrain_children_to_parent(element) @classmethod - def regenerate_array(cls, parent, data, keep_objs=False): - tool.Blender.Modifier.Array.remove_constraints(tool.Ifc.get_entity(parent)) + def regenerate_array(cls, parent_obj, data, keep_objs=False): + tool.Blender.Modifier.Array.remove_constraints(tool.Ifc.get_entity(parent_obj)) unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) - obj_stack = [parent] + obj_stack = [parent_obj] for array in data: if array["sync_children"]: From e3f2f2175df16be52bd7b5617b59cbb79d9e9c33 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Fri, 22 Sep 2023 16:00:20 +0500 Subject: [PATCH 144/190] Moving common code for duplication operators --- .../bim/module/geometry/operator.py | 65 +------------------ src/blenderbim/blenderbim/tool/geometry.py | 24 +++++++ 2 files changed, 27 insertions(+), 62 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/geometry/operator.py b/src/blenderbim/blenderbim/bim/module/geometry/operator.py index 23e620f7a4..a2a01392f5 100644 --- a/src/blenderbim/blenderbim/bim/module/geometry/operator.py +++ b/src/blenderbim/blenderbim/bim/module/geometry/operator.py @@ -680,27 +680,7 @@ class OverrideDuplicateMove(bpy.types.Operator): return len(context.selected_objects) > 0 def execute(self, context): - # Deep magick from the dawn of time - if IfcStore.get_file(): - IfcStore.execute_ifc_operator(self, context) - if self.new_active_obj: - context.view_layer.objects.active = self.new_active_obj - return {"FINISHED"} - - new_active_obj = None - for obj in context.selected_objects: - new_obj = obj.copy() - if obj.data: - new_obj.data = obj.data.copy() - if obj == context.active_object: - new_active_obj = new_obj - for collection in obj.users_collection: - collection.objects.link(new_obj) - obj.select_set(False) - new_obj.select_set(True) - if new_active_obj: - context.view_layer.objects.active = new_active_obj - return {"FINISHED"} + return tool.Geometry.duplicate_move_operator_execute(self, context, linked=False) def _execute(self, context): objects_to_duplicate = set(context.selected_objects) @@ -814,25 +794,7 @@ class OverrideDuplicateMoveLinked(bpy.types.Operator): return len(context.selected_objects) > 0 def execute(self, context): - # Deep magick from the dawn of time - if IfcStore.get_file(): - IfcStore.execute_ifc_operator(self, context) - if self.new_active_obj: - context.view_layer.objects.active = self.new_active_obj - return {"FINISHED"} - - new_active_obj = None - for obj in context.selected_objects: - new_obj = obj.copy() - if obj == context.active_object: - new_active_obj = new_obj - for collection in obj.users_collection: - collection.objects.link(new_obj) - obj.select_set(False) - new_obj.select_set(True) - if new_active_obj: - context.view_layer.objects.active = new_active_obj - return {"FINISHED"} + return tool.Geometry.duplicate_move_operator_execute(self, context, linked=True) def _execute(self, context): self.new_active_obj = None @@ -881,28 +843,7 @@ class OverrideDuplicateMoveAggregate(bpy.types.Operator): return len(context.selected_objects) > 0 def execute(self, context): - # Deep magick from the dawn of time - if IfcStore.get_file(): - IfcStore.execute_ifc_operator(self, context) - if self.new_active_obj: - context.view_layer.objects.active = self.new_active_obj - return {"FINISHED"} - - new_active_obj = None - - for obj in context.selected_objects: - new_obj = obj.copy() - if obj.data: - new_obj.data = obj.data.copy() - if obj == context.active_object: - new_active_obj = new_obj - for collection in obj.users_collection: - collection.objects.link(new_obj) - obj.select_set(False) - new_obj.select_set(True) - if new_active_obj: - context.view_layer.objects.active = new_active_obj - return {"FINISHED"} + return tool.Geometry.duplicate_move_operator_execute(self, context, linked=False) def _execute(self, context): self.new_active_obj = None diff --git a/src/blenderbim/blenderbim/tool/geometry.py b/src/blenderbim/blenderbim/tool/geometry.py index 8e624d61f0..5860f369f2 100644 --- a/src/blenderbim/blenderbim/tool/geometry.py +++ b/src/blenderbim/blenderbim/tool/geometry.py @@ -641,3 +641,27 @@ class Geometry(blenderbim.core.tool.Geometry): @classmethod def get_model_representations(cls): return tool.Ifc.get().by_type("IfcShapeRepresentation") + + @classmethod + def duplicate_move_operator_execute(cls, operator, context, linked=False): + # Deep magick from the dawn of time + if IfcStore.get_file(): + IfcStore.execute_ifc_operator(operator, context) + if operator.new_active_obj: + context.view_layer.objects.active = operator.new_active_obj + return {"FINISHED"} + + new_active_obj = None + for obj in context.selected_objects: + new_obj = obj.copy() + if linked and obj.data: + new_obj.data = obj.data.copy() + if obj == context.active_object: + new_active_obj = new_obj + for collection in obj.users_collection: + collection.objects.link(new_obj) + obj.select_set(False) + new_obj.select_set(True) + if new_active_obj: + context.view_layer.objects.active = new_active_obj + return {"FINISHED"} From 4549be0d055746aebe9bb7edc72673ea2ca5bf74 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 23 Sep 2023 14:50:36 +1000 Subject: [PATCH 145/190] Autodetect dense meshes to make it faster when loading so users don't need to go into advanced mode. --- src/blenderbim/blenderbim/bim/import_ifc.py | 36 ++++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/import_ifc.py b/src/blenderbim/blenderbim/bim/import_ifc.py index db041a46d6..3074b4b7b0 100644 --- a/src/blenderbim/blenderbim/bim/import_ifc.py +++ b/src/blenderbim/blenderbim/bim/import_ifc.py @@ -146,12 +146,15 @@ class MaterialCreator: faces_remap = None texture_map = None if coordinates.is_a("IfcIndexedPolygonalTextureMap"): - faces_remap = [[coordinates_remap[i-1] for i in tex_coord_index.TexCoordsOf.CoordIndex] - for tex_coord_index in coordinates.TexCoordIndices] + faces_remap = [ + [coordinates_remap[i - 1] for i in tex_coord_index.TexCoordsOf.CoordIndex] + for tex_coord_index in coordinates.TexCoordIndices + ] texture_map = [tex_coord_index.TexCoordIndex for tex_coord_index in coordinates.TexCoordIndices] elif coordinates.is_a("IfcIndexedTriangleTextureMap"): - faces_remap = [[coordinates_remap[i-1] for i in triangle_face] - for triangle_face in coordinates.MappedTo.CoordIndex] + faces_remap = [ + [coordinates_remap[i - 1] for i in triangle_face] for triangle_face in coordinates.MappedTo.CoordIndex + ] texture_map = coordinates.TexCoordIndex # apply uv to each face @@ -166,7 +169,7 @@ class MaterialCreator: ) # apply uv to each loop for loop, i in zip(bface.loops, texCoordIndex): - loop[uv_layer].uv = coordinates.TexCoords.TexCoordsList[i-1] + loop[uv_layer].uv = coordinates.TexCoords.TexCoordsList[i - 1] # Finish up, write the bmesh back to the mesh bm.to_mesh(self.mesh) @@ -263,6 +266,8 @@ class IfcImporter: self.profile_code("Calculate unit scale") self.calculate_model_offset() self.profile_code("Calculate model offset") + self.predict_dense_mesh() + self.profile_code("Predict dense mesh") self.set_units() self.profile_code("Set units") self.create_project() @@ -348,6 +353,7 @@ class IfcImporter: ) if self.body_contexts: self.settings.set_context_ids(self.body_contexts) + self.settings_body_2d.set_context_ids(self.body_contexts) # Annotation ContextType is to accommodate broken Revit files # See https://github.com/Autodesk/revit-ifc/issues/187 self.plan_contexts = [ @@ -498,6 +504,26 @@ class IfcImporter: products.extend(self.get_products_from_shape_representation(inverse_element)) return products + def predict_dense_mesh(self): + threshold = 10000 # Just from experience. + + faces = [len(e.CfsFaces) for e in self.file.by_type("IfcClosedShell")] + if faces and max(faces) > threshold: + self.ifc_import_settings.should_use_native_meshes = True + return + + if self.file.schema == "IFC2X3": + return + + faces = [len(e.Faces) for e in self.file.by_type("IfcPolygonalFaceSet")] + if faces and max(faces) > threshold: + self.ifc_import_settings.should_use_native_meshes = True + return + + faces = [len(e.CoordIndex) for e in self.file.by_type("IfcTriangulatedFaceSet")] + if faces and max(faces) > threshold: + self.ifc_import_settings.should_use_native_meshes = True + def calculate_model_offset(self): props = bpy.context.scene.BIMGeoreferenceProperties if props.has_blender_offset: From 73d1de5dcf66ba8e84ebf8615be953d2c3161076 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sat, 23 Sep 2023 14:51:05 +1000 Subject: [PATCH 146/190] New debug feature to override the display type. --- .../blenderbim/bim/module/debug/__init__.py | 3 ++- .../blenderbim/bim/module/debug/operator.py | 11 +++++++++++ src/blenderbim/blenderbim/bim/module/debug/prop.py | 10 ++++++++++ src/blenderbim/blenderbim/bim/module/debug/ui.py | 4 ++++ 4 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/blenderbim/blenderbim/bim/module/debug/__init__.py b/src/blenderbim/blenderbim/bim/module/debug/__init__.py index 1c0de63341..74d59a6eec 100644 --- a/src/blenderbim/blenderbim/bim/module/debug/__init__.py +++ b/src/blenderbim/blenderbim/bim/module/debug/__init__.py @@ -20,18 +20,19 @@ import bpy from . import ui, prop, operator classes = ( + operator.ConvertToBlender, operator.CopyDebugInformation, operator.CreateAllShapes, operator.CreateShapeFromStepId, operator.InspectFromObject, operator.InspectFromStepId, + operator.OverrideDisplayType, operator.ParseExpress, operator.PrintIfcFile, operator.PrintObjectPlacement, operator.ProfileImportIFC, operator.PurgeHdf5Cache, operator.PurgeIfcLinks, - operator.ConvertToBlender, operator.RewindInspector, operator.SelectExpressFile, operator.SelectHighPolygonMeshes, diff --git a/src/blenderbim/blenderbim/bim/module/debug/operator.py b/src/blenderbim/blenderbim/bim/module/debug/operator.py index 9eecda3251..32cfbce6d3 100644 --- a/src/blenderbim/blenderbim/bim/module/debug/operator.py +++ b/src/blenderbim/blenderbim/bim/module/debug/operator.py @@ -443,3 +443,14 @@ class PurgeHdf5Cache(bpy.types.Operator): def execute(self, context): core.purge_hdf5_cache(tool.Debug) return {"FINISHED"} + + +class OverrideDisplayType(bpy.types.Operator): + bl_idname = "bim.override_display_type" + bl_label = "Override Display Type" + display: bpy.props.StringProperty() + + def execute(self, context): + for obj in context.selected_objects: + obj.display_type = self.display + return {"FINISHED"} diff --git a/src/blenderbim/blenderbim/bim/module/debug/prop.py b/src/blenderbim/blenderbim/bim/module/debug/prop.py index 08e7699a61..0825b0f0f6 100644 --- a/src/blenderbim/blenderbim/bim/module/debug/prop.py +++ b/src/blenderbim/blenderbim/bim/module/debug/prop.py @@ -40,3 +40,13 @@ class BIMDebugProperties(PropertyGroup): inverse_attributes: CollectionProperty(name="Inverse Attributes", type=Attribute) inverse_references: CollectionProperty(name="Inverse References", type=Attribute) express_file: StringProperty(name="Express File") + display_type: EnumProperty( + items=[ + ("BOUNDS", "Bounds", ""), + ("WIRE", "Wire", ""), + ("SOLID", "Solid", ""), + ("TEXTURED", "Textured", ""), + ], + name="Display Type", + default="BOUNDS", + ) diff --git a/src/blenderbim/blenderbim/bim/module/debug/ui.py b/src/blenderbim/blenderbim/bim/module/debug/ui.py index e8629f7854..205c08f480 100644 --- a/src/blenderbim/blenderbim/bim/module/debug/ui.py +++ b/src/blenderbim/blenderbim/bim/module/debug/ui.py @@ -83,6 +83,10 @@ class BIM_PT_debug(Panel): ).percentile = context.scene.BIMDebugProperties.percentile_of_polygons row.prop(props, "percentile_of_polygons", text="") + row = layout.split(factor=0.5, align=True) + row.prop(props, "display_type", text="") + row.operator("bim.override_display_type").display = context.scene.BIMDebugProperties.display_type + if context.active_object and context.active_object.data: mprops = context.active_object.data.BIMMeshProperties row = layout.row() From e74e8244179a4a5ed0997f17f753945dbf507e3b Mon Sep 17 00:00:00 2001 From: "Sigma Dimensions (Yass)" <79010126+myoualid@users.noreply.github.com> Date: Sat, 23 Sep 2023 16:04:01 +0100 Subject: [PATCH 147/190] Proposal to use resource's parent cost, reducing manual insertions --- .../calculate_cost_item_resource_value.py | 24 ++++------------- .../ifcopenshell/util/resource.py | 27 +++++++++++++++++++ 2 files changed, 32 insertions(+), 19 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/api/cost/calculate_cost_item_resource_value.py b/src/ifcopenshell-python/ifcopenshell/api/cost/calculate_cost_item_resource_value.py index 2787b0048c..e947654b3a 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/cost/calculate_cost_item_resource_value.py +++ b/src/ifcopenshell-python/ifcopenshell/api/cost/calculate_cost_item_resource_value.py @@ -18,6 +18,7 @@ import ifcopenshell.api import ifcopenshell.util.date +import ifcopenshell.util.resource class Usecase: @@ -105,8 +106,10 @@ class Usecase: total_cost = 0 for resource in resources: - cost = self.get_cost(resource) - quantity = self.get_quantity(resource) + cost = ifcopenshell.util.resource.get_cost(resource) + quantity = ifcopenshell.util.resource.get_quantity(resource) + if not cost: + cost = ifcopenshell.util.resource.get_parent_cost(resource) # Concept to standardise - Not defined in schema, but this makes manual scheduling of resources 10x faster and less duplicate data. if not cost or not quantity: continue total_cost += cost * quantity @@ -114,20 +117,3 @@ class Usecase: if total_cost: cost_value = ifcopenshell.api.run("cost.add_cost_value", self.file, parent=self.settings["cost_item"]) cost_value.AppliedValue = self.file.createIfcMonetaryMeasure(total_cost) - - def get_cost(self, resource): - total = 0 - for cost_value in resource.BaseCosts or []: - total += cost_value.AppliedValue.wrappedValue if cost_value.AppliedValue else 0 - return total - - def get_quantity(self, resource): - total = 0 - if resource.BaseQuantity: - return resource.BaseQuantity[3] - if resource.Usage and resource.Usage.ScheduleWork: - # For now we assume either hourly or daily depending on how duration is stored - duration = ifcopenshell.util.date.ifc2datetime(resource.Usage.ScheduleWork) - if duration.days: - return duration.days - return duration.seconds / 60 / 60 diff --git a/src/ifcopenshell-python/ifcopenshell/util/resource.py b/src/ifcopenshell-python/ifcopenshell/util/resource.py index f1e17dcc42..f3ce2082e0 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/resource.py +++ b/src/ifcopenshell-python/ifcopenshell/util/resource.py @@ -113,5 +113,32 @@ def get_resource_required_work(resource): iso_string = f"P{required_work}D" return iso_string + def get_nested_resources(resource): return [object for rel in resource.IsNestedBy or [] for object in rel.RelatedObjects] + + +def get_cost(resource): + total = 0 + for cost_value in resource.BaseCosts or []: + total += cost_value.AppliedValue.wrappedValue if cost_value.AppliedValue else 0 + return total + + +def get_quantity(resource): + total = 0 + if resource.BaseQuantity: + return resource.BaseQuantity[3] + if resource.Usage and resource.Usage.ScheduleWork: + # For now we assume either hourly or daily depending on how duration is stored + duration = ifcopenshell.util.date.ifc2datetime(resource.Usage.ScheduleWork) + if duration.days: + return duration.days + return duration.seconds / 60 / 60 + +def get_parent_cost(resource): + if not resource.Nests: + return + else: + cost = get_cost(resource.Nests[0].RelatingObject) + return cost From 1c057b1ce17b78231b6734d22b64b830d693cee1 Mon Sep 17 00:00:00 2001 From: Massimo Fabbro Date: Sat, 23 Sep 2023 18:09:59 +0200 Subject: [PATCH 148/190] Add new covering tool. Mock up and initial functions --- src/blenderbim/blenderbim/bim/__init__.py | 1 + .../bim/module/covering/__init__.py | 41 +++++ .../covering/ops.authoring.covering.dat | Bin 0 -> 224 bytes .../blenderbim/bim/module/covering/prop.py | 40 +++++ .../bim/module/covering/workspace.py | 166 ++++++++++++++++++ .../blenderbim/bim/module/model/__init__.py | 3 +- .../blenderbim/bim/module/model/covering.py | 64 +++++++ .../blenderbim/bim/module/model/prop.py | 2 +- .../blenderbim/bim/module/model/space.py | 20 --- src/blenderbim/blenderbim/core/covering.py | 42 +++++ src/blenderbim/blenderbim/core/spatial.py | 5 - src/blenderbim/blenderbim/core/tool.py | 13 +- src/blenderbim/blenderbim/tool/covering.py | 49 ++++++ src/blenderbim/blenderbim/tool/spatial.py | 109 ++++++++++-- src/blenderbim/blenderbim_icons.blend | Bin 216648 -> 217861 bytes src/blenderbim/pytest.ini | 1 + .../test/bim/feature/covering.feature | 15 ++ 17 files changed, 530 insertions(+), 41 deletions(-) create mode 100644 src/blenderbim/blenderbim/bim/module/covering/__init__.py create mode 100644 src/blenderbim/blenderbim/bim/module/covering/ops.authoring.covering.dat create mode 100644 src/blenderbim/blenderbim/bim/module/covering/prop.py create mode 100644 src/blenderbim/blenderbim/bim/module/covering/workspace.py create mode 100644 src/blenderbim/blenderbim/bim/module/model/covering.py create mode 100644 src/blenderbim/blenderbim/core/covering.py create mode 100644 src/blenderbim/blenderbim/tool/covering.py create mode 100644 src/blenderbim/test/bim/feature/covering.feature diff --git a/src/blenderbim/blenderbim/bim/__init__.py b/src/blenderbim/blenderbim/bim/__init__.py index 5f2e0fe87e..515abba2a3 100644 --- a/src/blenderbim/blenderbim/bim/__init__.py +++ b/src/blenderbim/blenderbim/bim/__init__.py @@ -78,6 +78,7 @@ modules = { "augin": None, "debug": None, "ifcgit": None, + "covering": None, # Uncomment this line to enable loading of the demo module. Happy hacking! # The name "demo" must correlate to a folder name in `bim/module/`. # "demo": None, diff --git a/src/blenderbim/blenderbim/bim/module/covering/__init__.py b/src/blenderbim/blenderbim/bim/module/covering/__init__.py new file mode 100644 index 0000000000..73c39a93af --- /dev/null +++ b/src/blenderbim/blenderbim/bim/module/covering/__init__.py @@ -0,0 +1,41 @@ +# BlenderBIM Add-on - OpenBIM Blender Add-on +# Copyright (C) 2020, 2021 Dion Moult +# +# This file is part of BlenderBIM Add-on. +# +# BlenderBIM Add-on 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. +# +# BlenderBIM Add-on 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 BlenderBIM Add-on. If not, see . + +import bpy +from . import prop, workspace + +classes = ( + prop.BIMCoveringProperties, + workspace.Hotkey, +) + + +def register(): + if not bpy.app.background: + bpy.utils.register_tool(workspace.CoveringTool, after={"bim.structural_tool"}, separator=False, group=False) + bpy.types.Scene.BIMCoveringProperties = bpy.props.PointerProperty(type=prop.BIMCoveringProperties) +# bpy.types.Object.BIMObjectSpatialProperties = bpy.props.PointerProperty(type=prop.BIMObjectSpatialProperties) +# bpy.types.Scene.BIMSpatialManagerProperties = bpy.props.PointerProperty(type=prop.BIMSpatialManagerProperties) + + +def unregister(): + if not bpy.app.background: + bpy.utils.unregister_tool(workspace.CoveringTool) + del bpy.types.Scene.BIMCoveringProperties +# del bpy.types.Object.BIMObjectSpatialProperties +# del bpy.types.Scene.BIMSpatialManagerProperties diff --git a/src/blenderbim/blenderbim/bim/module/covering/ops.authoring.covering.dat b/src/blenderbim/blenderbim/bim/module/covering/ops.authoring.covering.dat new file mode 100644 index 0000000000000000000000000000000000000000..8a860d314934be7d05b9bc5343cb1983ecb4467c GIT binary patch literal 224 zcmWG@_GkG2pMk-5>XI7{b2p~=PW9FDox1VDk{cUSHeP`691MM@I?Q!2teLp+K>o~q zu{9HGOlv0Y+p+P$zSw;`AiR9rnu+-{^KDE2um2Cj4410^!!S$?M8m}J(f^Ua)2C1W O!!VLCTnr|MjRpX>XPIdL literal 0 HcmV?d00001 diff --git a/src/blenderbim/blenderbim/bim/module/covering/prop.py b/src/blenderbim/blenderbim/bim/module/covering/prop.py new file mode 100644 index 0000000000..d66aac26cd --- /dev/null +++ b/src/blenderbim/blenderbim/bim/module/covering/prop.py @@ -0,0 +1,40 @@ +# BlenderBIM Add-on - OpenBIM Blender Add-on +# Copyright (C) 2020, 2021 Dion Moult +# +# This file is part of BlenderBIM Add-on. +# +# BlenderBIM Add-on 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. +# +# BlenderBIM Add-on 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 BlenderBIM Add-on. If not, see . + +import bpy +from blenderbim.bim.prop import StrProperty, Attribute +#from blenderbim.bim.module.spatial.data import SpatialData +from bpy.types import PropertyGroup +from bpy.props import ( + PointerProperty, + StringProperty, + EnumProperty, + BoolProperty, + IntProperty, + FloatProperty, + FloatVectorProperty, + CollectionProperty, +) +import blenderbim.tool as tool +import blenderbim.core.geometry +import ifcopenshell + + +class BIMCoveringProperties(PropertyGroup): + pass +# depth: bpy.props.FloatProperty(name="Depth", default=0.1, subtype="DISTANCE", description="Flooring depth") diff --git a/src/blenderbim/blenderbim/bim/module/covering/workspace.py b/src/blenderbim/blenderbim/bim/module/covering/workspace.py new file mode 100644 index 0000000000..336d3bc2ef --- /dev/null +++ b/src/blenderbim/blenderbim/bim/module/covering/workspace.py @@ -0,0 +1,166 @@ +# BlenderBIM Add-on - OpenBIM Blender Add-on +# Copyright (C) 2023 @Andrej730 +# +# This file is part of BlenderBIM Add-on. +# +# BlenderBIM Add-on 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. +# +# BlenderBIM Add-on 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 BlenderBIM Add-on. If not, see . + + +import os +import bpy +import blenderbim.tool as tool +from blenderbim.bim.helper import prop_with_search +from blenderbim.bim.module.model.data import AuthoringData +from bpy.types import WorkSpaceTool +from blenderbim.bim.ifc import IfcStore +import blenderbim.bim.handler + + +# declaring it here to avoid circular import problems +class Operator: + def execute(self, context): + IfcStore.execute_ifc_operator(self, context) + blenderbim.bim.handler.refresh_ui_data() + return {"FINISHED"} + +class CoveringTool(WorkSpaceTool): + bl_space_type = "VIEW_3D" + bl_context_mode = "OBJECT" + bl_idname = "bim.covering_tool" + bl_label = "Covering Tool" + bl_description = "Create and edit coverings" + bl_icon = os.path.join(os.path.dirname(__file__), "ops.authoring.covering") + bl_widget = None + bl_keymap = tool.Blender.get_default_selection_keypmap() + ( + ("bim.covering_hotkey", {"type": "A", "value": "PRESS", "shift": True}, {"properties": [("hotkey", "S_A")]}), + ) + + @classmethod + def draw_settings(cls, context, layout, ws_tool): + CoveringToolUI.draw(context, layout, ifc_element_type="IfcCoveringType") + +def add_layout_hotkey(layout, text, hotkey, description): + args = ["covering", layout, text, hotkey, description] + tool.Blender.add_layout_hotkey_operator(*args) + +class CoveringToolUI: + @classmethod + def draw(cls, context, layout, ifc_element_type = None): + cls.layout = layout + cls.props = context.scene.BIMModelProperties + cls.covering_props = context.scene.BIMCoveringProperties + + row = cls.layout.row(align=True) + if not tool.Ifc.get(): + row.label(text="No IFC Project", icon="ERROR") + return + + if not AuthoringData.is_loaded: + AuthoringData.load(ifc_element_type) + elif AuthoringData.data["ifc_element_type"] != ifc_element_type: + AuthoringData.load(ifc_element_type) + + + if context.region.type == "TOOL_HEADER": + cls.draw_header_interface() + elif context.region.type in ("UI", "WINDOW"): + cls.draw_basic_bim_tool_interface() + + cls.draw_default_interface() + + + + @classmethod + def draw_header_interface(cls): + cls.draw_type_selection_interface() + + @classmethod + def draw_default_interface(cls): + if AuthoringData.data["ifc_classes"]: + row = cls.layout.row(align=True) + row.label(text="", icon="EVENT_SHIFT") + row.label(text="", icon="EVENT_A") + active_obj = bpy.context.active_object + element = tool.Ifc.get_entity(active_obj) + if element and bpy.context.selected_objects and element.is_a("IfcWall"): + op = row.operator("bim.add_instance_flooring_coverings_from_walls") + else: + op = row.operator("bim.add_constr_type_instance", text="Add") + op.from_invoke = True + if cls.props.relating_type_id.isnumeric(): + op.relating_type_id = int(cls.props.relating_type_id) + + @classmethod + def draw_type_selection_interface(cls): + # shared by both sidebar and header + row = cls.layout.row(align=True) + if AuthoringData.data["ifc_classes"]: + row = cls.layout.row(align=True) + row.label(text="", icon="FILE_3D") + prop_with_search(row, cls.props, "relating_type_id", text="") + row.operator("bim.launch_type_manager", icon="LIGHTPROBE_GRID", text="") + else: + row.label(text=f"No {AuthoringData.data['ifc_element_type']} Found", icon="ERROR") + row = cls.layout.row() + row.operator("bim.launch_type_manager", icon="LIGHTPROBE_GRID", text="Launch Type Manager") + + @classmethod + def draw_basic_bim_tool_interface(cls): + cls.draw_type_selection_interface() + + if AuthoringData.data["ifc_classes"]: + if cls.props.ifc_class: + box = cls.layout.box() + if AuthoringData.data["type_thumbnail"]: + box.template_icon(icon_value=AuthoringData.data["type_thumbnail"], scale=5) + else: + op = box.operator("bim.load_type_thumbnails", text="Load Thumbnails", icon="FILE_REFRESH") + op.ifc_class = cls.props.ifc_class + + +class Hotkey(bpy.types.Operator, Operator): + bl_idname = "bim.covering_hotkey" + bl_label = "Hotkey" + bl_options = {"REGISTER", "UNDO"} + hotkey: bpy.props.StringProperty() + description: bpy.props.StringProperty() + + @classmethod + def poll(cls, context): + return tool.Ifc.get() + + @classmethod + def description(cls, context, operator): + return operator.description or "" + + def _execute(self, context): +# self.props = context.scene.BIMCoveringProperties + getattr(self, f"hotkey_{self.hotkey}")() + + def invoke(self, context, event): + # https://blender.stackexchange.com/questions/276035/how-do-i-make-operators-remember-their-property-values-when-called-from-a-hotkey + # self.props = context.scene.BIMSpatialProperties + return self.execute(context) + + def draw(self, context): + pass + + def hotkey_S_A(self): + active_obj = bpy.context.active_object + element = tool.Ifc.get_entity(active_obj) + if element and bpy.context.selected_objects and element.is_a("IfcWall"): + bpy.ops.bim.add_instance_flooring_coverings_from_walls() + else: + bpy.ops.bim.add_constr_type_instance() + diff --git a/src/blenderbim/blenderbim/bim/module/model/__init__.py b/src/blenderbim/blenderbim/bim/module/model/__init__.py index 8220be3df9..7786a0d8e5 100644 --- a/src/blenderbim/blenderbim/bim/module/model/__init__.py +++ b/src/blenderbim/blenderbim/bim/module/model/__init__.py @@ -28,6 +28,7 @@ from . import ( roof, slab, space, + covering, stair, window, opening, @@ -106,7 +107,7 @@ classes = ( slab.SetArcIndex, space.GenerateSpace, space.GenerateSpacesFromWalls, - space.GenerateFlooringCoveringsFromWalls, + covering.AddInstanceFlooringCoveringsFromWalls, space.ToggleSpaceVisibility, mep.FitFlowSegments, mep.RegenerateDistributionElement, diff --git a/src/blenderbim/blenderbim/bim/module/model/covering.py b/src/blenderbim/blenderbim/bim/module/model/covering.py new file mode 100644 index 0000000000..da5f09cb06 --- /dev/null +++ b/src/blenderbim/blenderbim/bim/module/model/covering.py @@ -0,0 +1,64 @@ +# BlenderBIM Add-on - OpenBIM Blender Add-on +# Copyright (C) 2023 Dion Moult +# +# This file is part of BlenderBIM Add-on. +# +# BlenderBIM Add-on 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. +# +# BlenderBIM Add-on 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 BlenderBIM Add-on. If not, see . + + +import bpy +import ifcopenshell +import blenderbim.tool as tool +import blenderbim.core.covering as core + + +class AddInstanceFlooringCoveringsFromWalls(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.add_instance_flooring_coverings_from_walls" + bl_label = "Add Typed Covering From Walls" + bl_options = {"REGISTER", "UNDO"} + bl_description = "Add instance flooring coverings from selected walls. The active object must be a wall and layered vertically" + + @classmethod + def poll(cls, context): + active_obj = bpy.context.active_object + element = tool.Ifc.get_entity(active_obj) + if element: + if element.is_a("IfcWall") and tool.Model.get_usage_type(element) == "LAYER2": + return context.selected_objects + + def _execute(self, context): + # This only works based on a 2D plan only considering the standard + # walls (i.e. prismatic) in the active object storey. + # In order to run, the active object must be a wall and + # there must be selected walls + + active_obj = bpy.context.active_object + if not active_obj: + self.report({"ERROR"}, "No active object. Please select a wall") + return + + element = tool.Ifc.get_entity(active_obj) + if element and not element.is_a("IfcWall"): + return self.report({"ERROR"}, "The active object is not a wall. Please select a wall.") + + container = ifcopenshell.util.element.get_container(element) + if not container: + self.report({"ERROR"}, "The wall is not contained.") + + if not bpy.context.selected_objects: + self.report({"ERROR"}, "No selected objects found. Please select walls.") + return + + core.add_instance_flooring_coverings_from_walls(tool.Ifc, tool.Spatial, tool.Collector, tool.Geometry) + diff --git a/src/blenderbim/blenderbim/bim/module/model/prop.py b/src/blenderbim/blenderbim/bim/module/model/prop.py index ab2534cfa8..2723e7904e 100644 --- a/src/blenderbim/blenderbim/bim/module/model/prop.py +++ b/src/blenderbim/blenderbim/bim/module/model/prop.py @@ -132,7 +132,7 @@ class BIMModelProperties(PropertyGroup): y: bpy.props.FloatProperty(name="Y", default=0.5, subtype="DISTANCE", description="Size by Y axis for the opening") z: bpy.props.FloatProperty(name="Z", default=0.5, subtype="DISTANCE", description="Size by Z axis for the opening") # Used for things like walls, doors, flooring, skirting, etc - rl1: bpy.props.FloatProperty(name="RL", default=1, subtype="DISTANCE", description="Z offset for walls") + rl1: bpy.props.FloatProperty(name="RL", default=1, subtype="DISTANCE", description="Z offset for walls") # Used for things like windows, other hosted furniture, and MEP rl2: bpy.props.FloatProperty(name="RL", default=1, subtype="DISTANCE", description="Z offset for windows") # Used for plan calculation points such as in room generation diff --git a/src/blenderbim/blenderbim/bim/module/model/space.py b/src/blenderbim/blenderbim/bim/module/model/space.py index efbcd9bdca..5f0e8edaf3 100644 --- a/src/blenderbim/blenderbim/bim/module/model/space.py +++ b/src/blenderbim/blenderbim/bim/module/model/space.py @@ -235,26 +235,6 @@ class GenerateSpacesFromWalls(bpy.types.Operator, tool.Ifc.Operator): # there must be selected walls core.generate_spaces_from_walls(tool.Ifc, tool.Spatial, tool.Collector) -class GenerateFlooringCoveringsFromWalls(bpy.types.Operator, tool.Ifc.Operator): - bl_idname = "bim.generate_flooring_coverings_from_walls" - bl_label = "Generate Flooring Coverings From Walls" - bl_options = {"REGISTER", "UNDO"} - bl_description = "Generate flooring coverings from selected walls. The active object must be a wall" - - @classmethod - def poll(cls, context): - active_obj = bpy.context.active_object - element = tool.Ifc.get_entity(active_obj) - if element: - return context.selected_objects and element.is_a("IfcWall") - - def _execute(self, context): - # This only works based on a 2D plan only considering the standard - # walls (i.e. prismatic) in the active object storey. - # In order to run, the active object must be a wall and - # there must be selected walls - core.generate_flooring_coverings_from_walls(tool.Ifc, tool.Spatial, tool.Collector) - class ToggleSpaceVisibility(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.toggle_space_visibility" bl_label = "Toggle Space Visibility" diff --git a/src/blenderbim/blenderbim/core/covering.py b/src/blenderbim/blenderbim/core/covering.py new file mode 100644 index 0000000000..61aadfcd7a --- /dev/null +++ b/src/blenderbim/blenderbim/core/covering.py @@ -0,0 +1,42 @@ +# BlenderBIM Add-on - OpenBIM Blender Add-on +# Copyright (C) 2021 Dion Moult +# +# This file is part of BlenderBIM Add-on. +# +# BlenderBIM Add-on 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. +# +# BlenderBIM Add-on 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 BlenderBIM Add-on. If not, see . + + +def add_instance_flooring_coverings_from_walls(ifc, spatial, collector, geometry): + z = spatial.get_active_obj_z() + union = spatial.get_union_shape_from_selected_objects() + for i, linear_ring in enumerate(union.interiors): + poly = spatial.get_buffered_poly_from_linear_ring(linear_ring) + bm = spatial.get_bmesh_from_polygon(poly, h=0) + + name = "Covering" + str(i) + obj = spatial.get_named_obj_from_bmesh(name, bmesh = bm) + + spatial.set_obj_origin_to_bboxcenter(obj) + spatial.traslate_obj_to_z_location(obj, z) + spatial.link_obj_to_active_collection(obj) + + points = spatial.get_2d_vertices_from_obj(obj) + + spatial.assign_type_to_obj(obj) + spatial.assign_container_to_obj(obj) + + spatial.assign_swept_area_outer_curve_from_2d_vertices(obj, vertices = points) + body = spatial.get_body_representation(obj) + spatial.regen_obj_representation(ifc, geometry, obj, body) + diff --git a/src/blenderbim/blenderbim/core/spatial.py b/src/blenderbim/blenderbim/core/spatial.py index 7864ad0845..7f2beb3aba 100644 --- a/src/blenderbim/blenderbim/core/spatial.py +++ b/src/blenderbim/blenderbim/core/spatial.py @@ -18,7 +18,6 @@ import blenderbim.core import bpy -import ifcopenshell def reference_structure(ifc, spatial, structure=None, element=None): @@ -126,7 +125,6 @@ def select_decomposed_elements(spatial): #HERE STARTS SPATIAL TOOL def generate_spaces_from_walls(ifc, spatial, collector): - #props = bpy.context.scene.BIMModelProperties container, active_obj = spatial.get_container_and_active_obj() if not active_obj: @@ -176,9 +174,6 @@ def generate_spaces_from_walls(ifc, spatial, collector): ifc, collector, spatial, structure_obj=container_obj, element_obj=obj ) -def generate_flooring_coverings_from_walls(ifc, spatial, collector): - pass - def toggle_space_visibility(ifc, spatial): model = ifc.get() spaces = model.by_type("IfcSpace") diff --git a/src/blenderbim/blenderbim/core/tool.py b/src/blenderbim/blenderbim/core/tool.py index b249df13f3..df3f4793ce 100644 --- a/src/blenderbim/blenderbim/core/tool.py +++ b/src/blenderbim/blenderbim/core/tool.py @@ -811,7 +811,7 @@ class Spatial: def set_relative_object_matrix(cls, target_obj, relative_to_obj, matrix): pass def show_scene_objects(cls): pass #HERE STARTS SPATIAL TOOL - def get_container_and_active_obj(cls): pass +# def get_container_and_active_obj(cls): pass def get_union_shape_from_selected_objects(cls, selected_objects): pass def get_boundary_elements(cls, selected_objects): pass def get_polygons(cls, boundary_elements): pass @@ -820,8 +820,17 @@ class Spatial: def get_purged_inner_holes_poly(cls, union_geom, min_area): pass def get_poly_valid_interior_list(cls, poly, min_area, interiors_list): pass def get_buffered_poly_from_linear_ring(cls, linear_ring): pass - def get_bmesh_from_polygon(cls, poly, mat, h): pass + def get_bmesh_from_polygon(cls, poly, h): pass + def get_named_obj_from_bmesh(cls, name, bmesh): pass def set_obj_origin_to_bboxcenter(cls, obj): pass + def get_active_obj_z(cls, obj): pass + def traslate_obj_to_z_location(cls, obj): pass + def link_obj_to_active_collection(cls, obj): pass + def get_2d_vertices_from_obj(cls, obj): pass + def assign_swept_area_outer_curve_from_2d_vertices(cls, obj, vertices): pass + def get_body_representation(cls, obj): pass + def assign_type_to_obj(cls, obj): pass + def regen_obj_representation(cls, ifc, geometry, obj, body): pass def toggle_spaces_visibility_wired_and_textured(cls, spaces): pass diff --git a/src/blenderbim/blenderbim/tool/covering.py b/src/blenderbim/blenderbim/tool/covering.py new file mode 100644 index 0000000000..cfbae2f807 --- /dev/null +++ b/src/blenderbim/blenderbim/tool/covering.py @@ -0,0 +1,49 @@ +# BlenderBIM Add-on - OpenBIM Blender Add-on +# Copyright (C) 2021 Dion Moult +# +# This file is part of BlenderBIM Add-on. +# +# BlenderBIM Add-on 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. +# +# BlenderBIM Add-on 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 BlenderBIM Add-on. If not, see . + +import bpy +import bmesh +import shapely +import ifcopenshell +import blenderbim.core.tool +import blenderbim.core.root +import blenderbim.core.spatial +import blenderbim.core.geometry +import blenderbim.tool as tool +import json +from math import pi +from mathutils import Vector, Matrix +from shapely import Polygon, MultiPolygon + +class Covering(blenderbim.core.tool.Covering): + @classmethod +# def toggle_spaces_visibility_wired_and_textured(cls, spaces): +# first_obj = tool.Ifc.get_object(spaces[0]) +# if bpy.data.objects[first_obj.name].display_type == "TEXTURED": +# for space in spaces: +# obj = tool.Ifc.get_object(space) +# bpy.data.objects[obj.name].show_wire = True +# bpy.data.objects[obj.name].display_type = "WIRE" +# return +# +# elif bpy.data.objects[first_obj.name].display_type == "WIRE": +# for space in spaces: +# obj = tool.Ifc.get_object(space) +# bpy.data.objects[obj.name].show_wire = False +# bpy.data.objects[obj.name].display_type = "TEXTURED" +# return diff --git a/src/blenderbim/blenderbim/tool/spatial.py b/src/blenderbim/blenderbim/tool/spatial.py index c6865c9c1d..f838703059 100644 --- a/src/blenderbim/blenderbim/tool/spatial.py +++ b/src/blenderbim/blenderbim/tool/spatial.py @@ -23,6 +23,7 @@ import ifcopenshell import blenderbim.core.tool import blenderbim.core.root import blenderbim.core.spatial +import blenderbim.core.geometry import blenderbim.tool as tool import json from math import pi @@ -270,14 +271,8 @@ class Spatial(blenderbim.core.tool.Spatial): #HERE STARTS SPATIAL TOOL @classmethod - def get_container_and_active_obj(cls): - active_obj = bpy.context.active_object - element = tool.Ifc.get_entity(active_obj) - container = ifcopenshell.util.element.get_container(element) - return container, active_obj - - @classmethod - def get_union_shape_from_selected_objects(cls, selected_objects): + def get_union_shape_from_selected_objects(cls): + selected_objects = bpy.context.selected_objects boundary_elements = cls.get_boundary_elements(selected_objects) polys = cls.get_polygons(boundary_elements) converted_tolerance = cls.get_converted_tolerance(tolerance=0.03) @@ -373,7 +368,8 @@ class Spatial(blenderbim.core.tool.Spatial): return poly @classmethod - def get_bmesh_from_polygon(cls, poly, mat, h): + def get_bmesh_from_polygon(cls, poly, h): + mat = bpy.context.active_object.matrix_world bm = bmesh.new() bm.verts.index_update() bm.edges.index_update() @@ -391,14 +387,25 @@ class Spatial(blenderbim.core.tool.Spatial): bmesh.ops.triangle_fill(bm, edges=bm.edges) bmesh.ops.dissolve_limit(bm, angle_limit=pi / 180 * 5, verts=bm.verts, edges=bm.edges) - extrusion = bmesh.ops.extrude_face_region(bm, geom=bm.faces) - extruded_verts = [g for g in extrusion["geom"] if isinstance(g, bmesh.types.BMVert)] - bmesh.ops.translate(bm, vec=[0.0, 0.0, h], verts=extruded_verts) + if h!=0: + extrusion = bmesh.ops.extrude_face_region(bm, geom=bm.faces) + extruded_verts = [g for g in extrusion["geom"] if isinstance(g, bmesh.types.BMVert)] + bmesh.ops.translate(bm, vec=[0.0, 0.0, h], verts=extruded_verts) bmesh.ops.recalc_face_normals(bm, faces=bm.faces) return bm + @classmethod + def get_named_obj_from_bmesh(cls, name, bmesh): + mesh = bpy.data.meshes.new(name=name) + bmesh.to_mesh(mesh) + bmesh.free() + obj = bpy.data.objects.new(name, mesh) + mat = bpy.context.active_object.matrix_world + obj.matrix_world = mat + return obj + @classmethod def set_obj_origin_to_bboxcenter(cls, obj): mat = obj.matrix_world @@ -415,6 +422,84 @@ class Spatial(blenderbim.core.tool.Spatial): vert.co = inverted @ aux_vector obj.location = newLoc + @classmethod + def get_active_obj_z(cls): + x, y, z = bpy.context.active_object.matrix_world.translation.xyz + return z + + @classmethod + def traslate_obj_to_z_location(cls, obj, z): + if z != 0: + obj.location = obj.location + Vector((0, 0, z)) + + @classmethod + def link_obj_to_active_collection(cls, obj): + bpy.context.view_layer.active_layer_collection.collection.objects.link(obj) + + @classmethod + def get_2d_vertices_from_obj(cls, obj): + points = [] + vectors = [v.co for v in obj.data.vertices.values()] + for vector in vectors: + point = (vector[0], vector[1]) + points.append(point) + + points.append((vectors[0][0], vectors[0][1])) + return points + + @classmethod + def assign_swept_area_outer_curve_from_2d_vertices(cls, obj, vertices): + body = cls.get_body_representation(obj) + model = tool.Ifc.get() + extrusion = tool.Model.get_extrusion(body) + area = extrusion.SweptArea + old_area = area.OuterCurve + + builder = ifcopenshell.util.shape_builder.ShapeBuilder(model) + outer_curve = builder.polyline(vertices, closed = True) + + area.OuterCurve = outer_curve + ifcopenshell.util.element.remove_deep2(tool.Ifc.get(), old_area) + + @classmethod + def get_body_representation(cls, obj): + element = tool.Ifc.get_entity(obj) + model = tool.Ifc.get() + body = ifcopenshell.util.representation.get_representation(element, "Model", "Body", "MODEL_VIEW") + return body + + @classmethod + def assign_type_to_obj(cls, obj): + relating_type_id = bpy.context.scene.BIMModelProperties.relating_type_id + relating_type = tool.Ifc.get().by_id(int(relating_type_id)) + ifc_class = relating_type.is_a() + instance_class = ifcopenshell.util.type.get_applicable_entities(ifc_class, tool.Ifc.get().schema)[0] + bpy.ops.bim.assign_class(obj=obj.name, ifc_class=instance_class) + element = tool.Ifc.get_entity(obj) + blenderbim.core.type.assign_type(tool.Ifc, tool.Type, element=element, type=relating_type) + + @classmethod + def assign_container_to_obj(cls, obj): + active_obj = bpy.context.active_object + element = tool.Ifc.get_entity(active_obj) + container = ifcopenshell.util.element.get_container(element) + container_obj = tool.Ifc.get_object(container) + blenderbim.core.spatial.assign_container( + tool.Ifc, tool.Collector, tool.Spatial, structure_obj=container_obj, element_obj=obj + ) + + @classmethod + def regen_obj_representation(cls, ifc, geometry, obj, body): + blenderbim.core.geometry.switch_representation( + ifc, + geometry, + obj=obj, + representation=body, + should_reload=True, + is_global=True, + should_sync_changes_first=False, + ) + @classmethod def toggle_spaces_visibility_wired_and_textured(cls, spaces): first_obj = tool.Ifc.get_object(spaces[0]) diff --git a/src/blenderbim/blenderbim_icons.blend b/src/blenderbim/blenderbim_icons.blend index df16936ee661f134eba5a2eddbfc6ad063e6683d..387e6815f11d1107f8250a96148300483fb9cf3e 100644 GIT binary patch delta 182560 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 z�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 cF984m&;1jw9o+n5&!@I0I}+lj|)7AI{*Lx delta 181381 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�#2ZAkGWwCW3y83PW4;)6 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≷#6oy8&xu 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 CL}@nw diff --git a/src/blenderbim/pytest.ini b/src/blenderbim/pytest.ini index 4b4d9ae52c..2f55059a87 100644 --- a/src/blenderbim/pytest.ini +++ b/src/blenderbim/pytest.ini @@ -7,6 +7,7 @@ markers = classification context cost + covering debug demo document diff --git a/src/blenderbim/test/bim/feature/covering.feature b/src/blenderbim/test/bim/feature/covering.feature new file mode 100644 index 0000000000..bb7bdaf45d --- /dev/null +++ b/src/blenderbim/test/bim/feature/covering.feature @@ -0,0 +1,15 @@ +@covering +Feature: Covering + Covers covering tool. + +Scenario: Execute generate flooring coverings from walls + Given an empty IFC project + And I load the demo construction library + And I set "scene.BIMModelProperties.ifc_class" to "IfcWallType" + And the variable "element_type" is "[e for e in {ifc}.by_type('IfcWallType') if e.Name == 'WAL100'][0].id()" + And I set "scene.BIMModelProperties.relating_type_id" to "{element_type}" + And I press "bim.add_constr_type_instance" + And the object "IfcWall/Wall" is selected + When I press "bim.add_instance_flooring_coverings_from_walls" + Then nothing happens + From 270a61e73a284c97d8ec2860468da78c63e51d10 Mon Sep 17 00:00:00 2001 From: "Sigma Dimensions (Yass)" <79010126+myoualid@users.noreply.github.com> Date: Sat, 23 Sep 2023 21:26:58 +0100 Subject: [PATCH 149/190] bring back filter operators into new Filter subpanel and change filter by storey to filter by container --- .../blenderbim/bim/module/search/__init__.py | 3 +- .../blenderbim/bim/module/search/operator.py | 40 ++++++++++--------- .../blenderbim/bim/module/search/prop.py | 12 +++--- .../blenderbim/bim/module/search/ui.py | 14 ++++++- 4 files changed, 41 insertions(+), 28 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/search/__init__.py b/src/blenderbim/blenderbim/bim/module/search/__init__.py index 3dd6435f99..da8076d64d 100644 --- a/src/blenderbim/blenderbim/bim/module/search/__init__.py +++ b/src/blenderbim/blenderbim/bim/module/search/__init__.py @@ -20,7 +20,7 @@ import bpy from . import ui, prop, operator classes = ( - operator.ActivateIfcBuildingStoreyFilter, + operator.ActivateContainerFilter, operator.ActivateIfcClassFilter, operator.AddFilter, operator.AddFilterGroup, @@ -58,6 +58,7 @@ classes = ( prop.SearchQueryGroup, prop.IfcSelectorProperties, ui.BIM_PT_search, + ui.BIM_PT_filter, ui.BIM_PT_colour_by_property, ui.BIM_PT_select_similar, ui.BIM_UL_colourscheme, diff --git a/src/blenderbim/blenderbim/bim/module/search/operator.py b/src/blenderbim/blenderbim/bim/module/search/operator.py index c929938632..ee018ccef8 100644 --- a/src/blenderbim/blenderbim/bim/module/search/operator.py +++ b/src/blenderbim/blenderbim/bim/module/search/operator.py @@ -466,8 +466,8 @@ class ToggleFilterSelection(Operator): if props.filter_type == "CLASSES": for ifc_class in props.filter_classes: ifc_class.is_selected = self.selecting_actionbool - elif props.filter_type == "BUILDINGSTOREYS": - for building_storey in props.filter_building_storeys: + elif props.filter_type == "CONTAINER": + for building_storey in props.filter_container: building_storey.is_selected = self.selecting_actionbool return {"FINISHED"} @@ -525,11 +525,11 @@ class ActivateIfcClassFilter(Operator): row.operator("bim.toggle_filter_selection", text="Deselect All").action = "DESELECT" -class ActivateIfcBuildingStoreyFilter(Operator): +class ActivateContainerFilter(Operator): """Filter the current selection by Building Storey""" - bl_idname = "bim.activate_ifc_building_storey_filter" - bl_label = "Filter by Building Storey" + bl_idname = "bim.activate_ifc_container_filter" + bl_label = "Filter by Container" @classmethod def poll(cls, context): @@ -540,27 +540,29 @@ class ActivateIfcBuildingStoreyFilter(Operator): def invoke(self, context, event): props = bpy.context.scene.BIMSearchProperties - props.filter_building_storeys.clear() + props.filter_container.clear() - ifc_building_storeys = {} + containers = {} + containers.setdefault("None", 0) for obj in context.selected_objects: - storey = tool.Misc.get_object_storey(obj) - if not storey: + container = tool.Spatial.get_container(tool.Ifc.get_entity(obj)) + if not container: + containers["None"] += 1 continue - ifc_building_storeys.setdefault(storey.Name, 0) - ifc_building_storeys[storey.Name] += 1 + containers.setdefault(container.Name, 0) + containers[container.Name] += 1 - for name, total in dict(sorted(ifc_building_storeys.items())).items(): - new = props.filter_building_storeys.add() + for name, total in dict(sorted(containers.items())).items(): + new = props.filter_container.add() new.name = name new.total = total - props.filter_type = "BUILDINGSTOREYS" + props.filter_type = "CONTAINER" return context.window_manager.invoke_props_dialog(self, width=250) def execute(self, context): - bpy.context.scene.BIMSearchProperties.filter_building_storeys.clear() + bpy.context.scene.BIMSearchProperties.filter_container.clear() return {"FINISHED"} def draw(self, context): @@ -568,12 +570,12 @@ class ActivateIfcBuildingStoreyFilter(Operator): "BIM_UL_ifc_building_storey_filter", "", context.scene.BIMSearchProperties, - "filter_building_storeys", + "filter_container", context.scene.BIMSearchProperties, - "filter_building_storeys_index", + "filter_container_index", rows=20 - if len(bpy.context.scene.BIMSearchProperties.filter_building_storeys) > 20 - else len(bpy.context.scene.BIMSearchProperties.filter_building_storeys), + if len(bpy.context.scene.BIMSearchProperties.filter_container) > 20 + else len(bpy.context.scene.BIMSearchProperties.filter_container), ) row = self.layout.row(align=True) row.operator("bim.toggle_filter_selection", text="Select All").action = "SELECT" diff --git a/src/blenderbim/blenderbim/bim/module/search/prop.py b/src/blenderbim/blenderbim/bim/module/search/prop.py index bdcc3d223c..53b5db1554 100644 --- a/src/blenderbim/blenderbim/bim/module/search/prop.py +++ b/src/blenderbim/blenderbim/bim/module/search/prop.py @@ -70,15 +70,15 @@ def update_is_class_selected(self, context): new.obj = obj -def update_is_level_selected(self, context): +def update_is_container_selected(self, context): if self.is_selected: for obj in self.unselected_objects: obj.obj.select_set(True) self.unselected_objects.clear() else: for obj in context.selected_objects: - level = tool.Misc.get_object_storey(obj) - if level and level.Name == self.name: + container = tool.Spatial.get_container(tool.Ifc.get_entity(obj)) + if (container and container.Name == self.name) or (not container and self.name== "None"): obj.select_set(False) new = self.unselected_objects.add() new.obj = obj @@ -93,7 +93,7 @@ class BIMFilterClasses(PropertyGroup): class BIMFilterBuildingStoreys(PropertyGroup): name: StringProperty(name="Name") - is_selected: BoolProperty(name="Is Level Selected", default=True, update=update_is_level_selected) + is_selected: BoolProperty(name="Is Level Selected", default=True, update=update_is_container_selected) total: IntProperty(name="Total") unselected_objects: CollectionProperty(type=ObjProperty, name="Unfiltered Objects") @@ -140,8 +140,8 @@ class BIMSearchProperties(PropertyGroup): filter_type: StringProperty(name="Filter Type") filter_classes: CollectionProperty(type=BIMFilterClasses, name="Filter Classes") filter_classes_index: IntProperty(name="Filter Classes Index") - filter_building_storeys: CollectionProperty(type=BIMFilterBuildingStoreys, name="Filter Level") - filter_building_storeys_index: IntProperty(name="Filter Level Index") + filter_container: CollectionProperty(type=BIMFilterBuildingStoreys, name="Filter Level") + filter_container_index: IntProperty(name="Filter Level Index") def get_classes(self, ifc_product): diff --git a/src/blenderbim/blenderbim/bim/module/search/ui.py b/src/blenderbim/blenderbim/bim/module/search/ui.py index 706f5952e9..a2a24c7b68 100644 --- a/src/blenderbim/blenderbim/bim/module/search/ui.py +++ b/src/blenderbim/blenderbim/bim/module/search/ui.py @@ -42,11 +42,21 @@ class BIM_PT_search(Panel): row = self.layout.row(align=True) row.operator("bim.search", text="Search", icon="VIEWZOOM") - return # Temporary for now whilst searching is being upgraded. + return + +class BIM_PT_filter(Panel): + bl_label = "Filter Selection" + bl_idname = "BIM_PT_filter" + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "scene" + bl_parent_id = "BIM_PT_tab_grouping_and_filtering" + + def draw(self, context): row = self.layout.row(align=True) row.operator("bim.activate_ifc_class_filter", icon="FILTER") - row.operator("bim.activate_ifc_building_storey_filter", icon="FILTER") + row.operator("bim.activate_ifc_container_filter", icon="FILTER") class BIM_PT_colour_by_property(Panel): From 8ac5f0318b6346a5892987970f14b11f0b708803 Mon Sep 17 00:00:00 2001 From: "Sigma Dimensions (Yass)" <79010126+myoualid@users.noreply.github.com> Date: Sat, 23 Sep 2023 23:35:17 +0100 Subject: [PATCH 150/190] fix location search filter --- src/blenderbim/blenderbim/bim/helper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blenderbim/blenderbim/bim/helper.py b/src/blenderbim/blenderbim/bim/helper.py index aa2100b0c5..85b0ddf607 100644 --- a/src/blenderbim/blenderbim/bim/helper.py +++ b/src/blenderbim/blenderbim/bim/helper.py @@ -325,7 +325,7 @@ def draw_filter(layout, props, data, module): row.prop(ifc_filter, "value", text="", icon="OUTLINER") elif ifc_filter.type == "location": row = box.row(align=True) - row.prop(ifc_filter, "name", text="", icon="PACKAGE") + row.prop(ifc_filter, "value", text="", icon="PACKAGE") elif ifc_filter.type == "query": row = box.row(align=True) row.prop(ifc_filter, "name", text="", icon="POINTCLOUD_DATA") From cc3e2204e26f66ddc158aafd1f4de96edadef1de Mon Sep 17 00:00:00 2001 From: "Sigma Dimensions (Yass)" <79010126+myoualid@users.noreply.github.com> Date: Sun, 24 Sep 2023 03:17:39 +0100 Subject: [PATCH 151/190] improved template for resource csv imports --- src/ifc4d/ifc4d/csv2ifc.py | 86 ++++++++++------------- src/ifc4d/ifc4d/resource_spreadsheet.csv | 63 +++++++++++++++++ src/ifc4d/ifc4d/resource_spreadsheet.ods | Bin 0 -> 22368 bytes 3 files changed, 102 insertions(+), 47 deletions(-) create mode 100644 src/ifc4d/ifc4d/resource_spreadsheet.csv create mode 100644 src/ifc4d/ifc4d/resource_spreadsheet.ods diff --git a/src/ifc4d/ifc4d/csv2ifc.py b/src/ifc4d/ifc4d/csv2ifc.py index 7ef6a8b6d8..e60d45dd4a 100644 --- a/src/ifc4d/ifc4d/csv2ifc.py +++ b/src/ifc4d/ifc4d/csv2ifc.py @@ -20,6 +20,7 @@ import csv import ifcopenshell import ifcopenshell.api import ifcopenshell.util.unit +import datetime class Csv2Ifc: @@ -28,6 +29,14 @@ class Csv2Ifc: self.file = None self.resources = [] self.units = {} + self.resource_map = { + "CREW": "IfcCrewResource", + "LABOR": "IfcLaborResource", + "EQUIPMENT": "IfcConstructionEquipmentResource", + "SUBCONTRACTOR": "IfcSubContractResource", + "MATERIAL": "IfcConstructionMaterialResource", + "PRODUCT": "IfcConstructionProductResource", + } def execute(self): self.parse_csv() @@ -41,43 +50,46 @@ class Csv2Ifc: for row in reader: if not row[0]: continue - if row[0] == "Hierarchy": + if row[0] == "HIERARCHY": for i, col in enumerate(row): if not col: continue self.headers[col] = i continue - cost_data = self.get_row_resource_data(row) + resource_data = self.get_row_resource_data(row) hierarchy_key = int(row[0]) if hierarchy_key == 1: - self.resources.append(cost_data) + self.resources.append(resource_data) else: - self.parents[hierarchy_key - 1]["children"].append(cost_data) - self.parents[hierarchy_key] = cost_data + self.parents[hierarchy_key - 1]["children"].append(resource_data) + self.parents[hierarchy_key] = resource_data def get_row_resource_data(self, row): - name = row[self.headers["Name"]] - identification = row[self.headers["Identification"]] if "Identification" in self.headers else None + name = row[self.headers["ACTIVITY/RESOURCE NAME"]] + resource_class = self.resource_map[row[self.headers["TYPE"]]] + base_cost_value = row[self.headers["COST"]] + productivity = {} - type = row[self.headers["Type"]] - base_cost_value = row[self.headers["BaseCostValue"]] - base_cost_quantity = row[self.headers["BaseCostQuantity"]] - base_cost_unit = row[self.headers["QuantityUnit"]] + if resource_class in ["IfcConstructionEquipmentResource", "IfcLaborResource"]: + output_ratio = row[self.headers["LABOR OUTPUT"]] + if not output_ratio: + output_ratio = row[self.headers["EQUIPMENT OUTPUT"]] + if output_ratio: + time_consumed = datetime.timedelta(minutes=float(output_ratio) * 60) + time_consumed = ifcopenshell.util.date.datetime2ifc(time_consumed, "IfcDuration") - productivity = { - "BaseQuantityConsumed": row[self.headers["BaseQuantityConsumed"]], - "BaseQuantityProducedName": row[self.headers["BaseQuantityProducedName"]], - "BaseQuantityProducedValue": row[self.headers["BaseQuantityProducedValue"]], - } + productivity = { + "BaseQuantityConsumed": time_consumed, + "BaseQuantityProducedName": row[self.headers["QUANTITY NAME"]], + "BaseQuantityProducedValue": 1, + } return { - "Identification": str(identification).strip() if identification else None, "Name": str(name).strip() if name else None, - "Type": type, + "Description": row[self.headers["DESCRIPTION"]], + "class": resource_class, "BaseCostValue": float(base_cost_value) if base_cost_value else None, - "BaseCostQuantity": float(base_cost_quantity) if base_cost_quantity else None, - "Unit": str(base_cost_unit).strip() if base_cost_unit else None, - "Productivity": productivity if productivity["BaseQuantityProducedName"] else None, + "Productivity": productivity, "children": [], } @@ -92,45 +104,25 @@ class Csv2Ifc: def create_resource(self, resource, parent): if parent is None: - resource["ifc"] = ifcopenshell.api.run("resource.add_resource", self.file, ifc_class=resource["Type"]) + resource["ifc"] = ifcopenshell.api.run("resource.add_resource", self.file, ifc_class=resource["class"]) else: resource["ifc"] = ifcopenshell.api.run( - "resource.add_resource", self.file, parent_resource=parent, ifc_class=resource["Type"] + "resource.add_resource", self.file, parent_resource=parent, ifc_class=resource["class"] ) resource["ifc"].Name = resource["Name"] - resource["ifc"].Identification = resource["Identification"] - productivity = resource["Productivity"] - if productivity: + if resource.get("Description", None): + resource["ifc"].Description = resource.get("Description") + if resource["Productivity"]: pset = ifcopenshell.api.run("pset.add_pset", self.file, product=resource["ifc"], name="EPset_Productivity") ifcopenshell.api.run( "pset.edit_pset", self.file, pset=pset, - properties=productivity, + properties=resource["Productivity"], ) if resource["BaseCostValue"]: cost_value = ifcopenshell.api.run("cost.add_cost_value", self.file, parent=resource["ifc"]) cost_value.AppliedValue = self.file.createIfcMonetaryMeasure(resource["BaseCostValue"]) - if resource["Unit"]: - measure_class = ifcopenshell.util.unit.get_symbol_measure_class(resource["Unit"]) - print(measure_class) - value_component = self.file.create_entity(measure_class, resource["BaseCostQuantity"]) - print(value_component) - unit_component = None - if measure_class == "IfcNumericMeasure": - unit_component = self.create_unit(resource["Unit"]) - else: - unit_type = ifcopenshell.util.unit.get_measure_unit_type(measure_class) - print(unit_type) - unit_assignment = ifcopenshell.util.unit.get_unit_assignment(self.file) - if unit_assignment: - units = [u for u in unit_assignment.Units if getattr(u, "UnitType", None) == unit_type] - if units: - unit_component = units[0] - if not unit_component: - unit_component = self.create_unit(resource["Unit"], unit_type) - print(unit_component) - cost_value.UnitBasis = self.file.createIfcMeasureWithUnit(value_component, unit_component) self.create_resources(resource["children"], resource["ifc"]) def create_unit(self, symbol, unit_type): diff --git a/src/ifc4d/ifc4d/resource_spreadsheet.csv b/src/ifc4d/ifc4d/resource_spreadsheet.csv new file mode 100644 index 0000000000..47fea5d101 --- /dev/null +++ b/src/ifc4d/ifc4d/resource_spreadsheet.csv @@ -0,0 +1,63 @@ +HIERARCHY,TYPE,ACTIVITY/RESOURCE NAME,DESCRIPTION,COST,USAGE,UNIT,QUANTITY NAME,LABOR OUTPUT,EQUIPMENT OUTPUT,Productivity Unit, +1,CREW,CONCRETE WORKS,,,,,,,,, +2,LABOR,BEAMS,,,,,,,,, +3,LABOR,"Beams, 745 kg/m ",3 m span,,,Cubic Meters ,,27.3,8.61,Hr / Meter, +4,LABOR,Foreman,,25,1,Cubic Meters ,Length,27.3,,Hr / Meter, +4,LABOR, Mason,,25,4,Cubic Meters ,Length,109.2,,Hr / Meter, +4,LABOR,Carpenter &Steelman,,25,10,Cubic Meters ,Length,273,,Hr / Meter, +4,LABOR,Laborer,,25,0.25,Cubic Meters ,Length,6.825,,Hr / Meter, +4,EQUIPMENT,Crane,,25,0.125,Cubic Meters ,Length,,1.07625,Hr / Meter, +,,,,,,,,,,, +3,LABOR,"Beams, 745 kg/m ",7.5 m span,,,Cubic Meters ,,22.02,6.94,Hr / Meter, +4,LABOR,Foreman,,25,1,Cubic Meters ,Length,22.02,,Hr / Meter, +4,LABOR, Mason,,25,4,Cubic Meters ,Length,88.08,,Hr / Meter, +4,LABOR,Carpenter &Steelman,,25,10,Cubic Meters ,Length,220.2,,Hr / Meter, +4,LABOR,Laborer,,25,0.25,Cubic Meters ,Length,5.505,,Hr / Meter, +4,EQUIPMENT,Crane,,25,0.125,Cubic Meters ,Length,,0.8675,Hr / Meter, +,,,,,,,,,,, +2,LABOR,SLABS,,,,,,,,, +3,LABOR,In-situ,200 mm thick (concreting & finish only),,,Square Meters ,,0.22,0.2,Hr / Square Meters, +4,LABOR,Foreman,,25,0.5,Square Meters ,GrossArea,0.11,,Hr / Square Meters, +4,LABOR,Laborer,,25,3,Square Meters ,GrossArea,0.66,,Hr / Square Meters, +4,LABOR,Carpenter,,25,4,Square Meters ,GrossArea,0.88,,Hr / Square Meters, +4,LABOR,Steelfixers,,25,2.5,Square Meters ,GrossArea,0.55,,Hr / Square Meters, +4,EQUIPMENT,Crane,,25,0.4,Square Meters ,GrossArea,,0.08,Hr / Square Meters, +,,,,,,,,,,, +2,LABOR,FOUNDATIONS,,,,,,,,, +3,LABOR,Drainage,,25,,Meters,Length,3.5,,Hr / Meter, +,,,,,,,,,,, +,,,,,,,,,,, +2,LABOR,WATERPROOFING,,,,,,,,, +3,LABOR,Drainage,,25,,Square Meters ,GrossArea,0.5,,Hr / Square Meters, +,,,,,,,,,,, +,,,,,,,,,,, +2,LABOR,STAIRS,,,,,,,,, +3,LABOR,STAIRS,"300 mm wide (Incld. forms, rebar, & finish)",,,Cubic Meters ,,102.16,36.89,Hr / Cubic Meters, +4,LABOR,Foreman,,25,0.5,Cubic Meters ,GrossVolume,51.08,,Hr / Cubic Meters, +4,LABOR,Carpenter,,25,6,Cubic Meters ,GrossVolume,612.96,,Hr / Cubic Meters, +4,LABOR,Steelman,,25,2,Cubic Meters ,GrossVolume,204.32,,Hr / Cubic Meters, +4,LABOR,Mason,,25,2,Cubic Meters ,GrossVolume,204.32,,Hr / Cubic Meters, +4,LABOR,Laborer,,25,2.5,Cubic Meters ,GrossVolume,255.4,,Hr / Cubic Meters, +4,EQUIPMENT,Crane,,25,0.375,Cubic Meters ,GrossVolume,,13.83375,Hr / Cubic Meters, +3,LABOR,STAIRS LANDING,"Incld. forms, rebar, & finish",,,Cubic Meters ,,12.76,4.02,Hr / Cubic Meters, +4,LABOR,Foreman,,25,1,Cubic Meters ,GrossVolume,12.76,,Hr / Cubic Meters, +4,LABOR,Carpenter & SteelFixers,,25,10,Cubic Meters ,GrossVolume,127.6,,Hr / Cubic Meters, +4,LABOR,Laborer,,25,4,Cubic Meters ,GrossVolume,51.04,,Hr / Cubic Meters, +4,LABOR,Mason,,25,0.125,Cubic Meters ,GrossVolume,1.595,,Hr / Cubic Meters, +4,EQUIPMENT,Crane,,25,0.125,Cubic Meters ,GrossVolume,,0.5025,Hr / Cubic Meters, +,,,,,,,,,,, +1,CREW,MASONRY,,,,,,,,, +2,LABOR,CONCRETE BLOCKS,,,,,,,,, +3,LABOR,Hollow Blocks 100mm,100mm thick,,,Square Meters ,,1.17,0.29,Hr / Cubic Meters, +4,LABOR,Foreman,,25,0.25,Square Meters ,GrossSideArea,0.2925,,Hr / Cubic Meters, +4,LABOR,Mason,,25,4,Square Meters ,GrossSideArea,4.68,,Hr / Cubic Meters, +4,LABOR,Laborer,,25,2,Square Meters ,GrossSideArea,2.34,,Hr / Cubic Meters, +4,EQUIPMENT,Crane,,25,0.125,Square Meters ,GrossSideArea,,0.03625,Hr / Cubic Meters, +4,EQUIPMENT,Fork Lift,,25,0.125,Square Meters ,GrossSideArea,,0.03625,Hr / Cubic Meters, +4,EQUIPMENT,Truck,,25,0.125,Square Meters ,GrossSideArea,,0.03625,Hr / Cubic Meters, +3,LABOR,Hollow Blocks 200mm,100mm thick,,,Square Meters ,,1.32,0.33,Hr / Cubic Meters, +4,LABOR,Foreman,,25,0.25,Square Meters ,GrossSideArea,0.33,,Hr / Cubic Meters, +4,LABOR,Mason,,25,4,Square Meters ,GrossSideArea,5.28,,Hr / Cubic Meters, +4,LABOR,Laborer,,25,2,Square Meters ,GrossSideArea,2.64,,Hr / Cubic Meters, +4,EQUIPMENT,Crane,,25,0.125,Square Meters ,GrossSideArea,,0.5025,Hr / Cubic Meters, +4,EQUIPMENT,Fork Lift,,25,0.125,Square Meters ,GrossSideArea,,0.5025,Hr / Cubic Meters, diff --git a/src/ifc4d/ifc4d/resource_spreadsheet.ods b/src/ifc4d/ifc4d/resource_spreadsheet.ods new file mode 100644 index 0000000000000000000000000000000000000000..d2deaf274f9f597c12bbe0f91c64ee84cdb7f258 GIT binary patch literal 22368 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 NN1|CicZ8%O`5*IHEerqv literal 0 HcmV?d00001 From 0993736ceabbaa6891fd405e66d884dd7d547348 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 24 Sep 2023 13:14:44 +1000 Subject: [PATCH 152/190] Fix #3791. Add isodate as an optional dependency to ifcopenshell-python. --- .../ifcopenshell/util/date.py | 31 ++++++------------- src/ifcopenshell-python/pyproject.toml | 1 + 2 files changed, 11 insertions(+), 21 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/util/date.py b/src/ifcopenshell-python/ifcopenshell/util/date.py index 27e6df4af0..f7f7c102f1 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/date.py +++ b/src/ifcopenshell-python/ifcopenshell/util/date.py @@ -35,8 +35,7 @@ def timedelta2duration(timedelta): } if components["seconds"]: components["hours"], components["minutes"], components["seconds"] = [ - int(i) - for i in str(datetime.timedelta(seconds=components["seconds"])).split(":") + int(i) for i in str(datetime.timedelta(seconds=components["seconds"])).split(":") ] return isodate.Duration(**components) @@ -122,9 +121,7 @@ def datetime2ifc(dt, ifc_type): if isinstance(dt, datetime.datetime): return dt.isoformat() elif isinstance(dt, datetime.date): - return datetime.datetime.combine( - dt, datetime.datetime.min.time() - ).isoformat() + return datetime.datetime.combine(dt, datetime.datetime.min.time()).isoformat() elif ifc_type == "IfcDate": if isinstance(dt, datetime.datetime): return dt.date().isoformat() @@ -180,9 +177,7 @@ def string_to_duration(duration_string): match = findall(r"(\d+\.?\d*)s", duration_string) if match: seconds = float(match[0]) - return isodate.duration_isoformat( - datetime.timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds) - ) + return isodate.duration_isoformat(datetime.timedelta(days=days, hours=hours, minutes=minutes, seconds=seconds)) def parse_duration(value): @@ -192,9 +187,11 @@ def parse_duration(value): if "P" in value: try: return isodate.parse_duration(value) + except ModuleNotFoundError: + print("Duration parsing not supported: isodate module not found") except: - print("error parsing ISO string duration") - return None + print("Error parsing ISO string duration") + return None else: try: final_string = "P" @@ -204,11 +201,7 @@ def parse_duration(value): final_string += char elif char == "D": final_string += "D" - if ( - "H" in value_upper - or "S" in value_upper - or "MIN" in value_upper - ): + if "H" in value_upper or "S" in value_upper or "MIN" in value_upper: final_string += "T" elif char == "W": final_string += "W" @@ -218,9 +211,7 @@ def parse_duration(value): final_string += "Y" elif char == "H": final_string = ( - final_string[:1] + "T" + final_string[1:] - if "T" not in final_string - else final_string + final_string[:1] + "T" + final_string[1:] if "T" not in final_string else final_string ) final_string += "H" elif char == "M": @@ -229,9 +220,7 @@ def parse_duration(value): final_string += "M" elif char == "S": final_string = ( - final_string[:1] + "T" + final_string[1:] - if "T" not in final_string - else final_string + final_string[:1] + "T" + final_string[1:] if "T" not in final_string else final_string ) final_string += "S" return isodate.parse_duration(final_string) diff --git a/src/ifcopenshell-python/pyproject.toml b/src/ifcopenshell-python/pyproject.toml index dc8db46e66..cf65b9de30 100644 --- a/src/ifcopenshell-python/pyproject.toml +++ b/src/ifcopenshell-python/pyproject.toml @@ -15,6 +15,7 @@ classifiers = [ ] [project.optional-dependencies] geometry = ["mathutils"] +date = ["isodate"] [project.urls] "Homepage" = "http://ifcopenshell.org" "Bug Tracker" = "https://github.com/ifcopenshell/ifcopenshell/issues" From 0707f3724799b8d49b1ce06bb2c8a9cd3cb10e80 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 24 Sep 2023 13:18:32 +1000 Subject: [PATCH 153/190] Minor fix during loading arrays --- src/blenderbim/blenderbim/bim/import_ifc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blenderbim/blenderbim/bim/import_ifc.py b/src/blenderbim/blenderbim/bim/import_ifc.py index 3074b4b7b0..fe3df02658 100644 --- a/src/blenderbim/blenderbim/bim/import_ifc.py +++ b/src/blenderbim/blenderbim/bim/import_ifc.py @@ -1939,7 +1939,7 @@ class IfcImporter: def setup_arrays(self): for element in self.file.by_type("IfcElement"): pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Array") - if not pset_data["Data"]: # skip array children + if pset_data or not pset_data.get("Data", None): # skip array children continue for i in range(len(json.loads(pset_data["Data"]))): tool.Blender.Modifier.Array.set_children_lock_state(element, i, True) From bd81320c08af02d2c39f6d21f54dad2e606c0046 Mon Sep 17 00:00:00 2001 From: "Sigma Dimensions (Yass)" <79010126+myoualid@users.noreply.github.com> Date: Sun, 24 Sep 2023 04:39:30 +0100 Subject: [PATCH 154/190] fix parametric duration/ resource update --- src/blenderbim/blenderbim/bim/module/sequence/prop.py | 8 ++++---- src/blenderbim/blenderbim/tool/sequence.py | 11 ++++++----- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/sequence/prop.py b/src/blenderbim/blenderbim/bim/module/sequence/prop.py index 83c48d5a02..629dd214f3 100644 --- a/src/blenderbim/blenderbim/bim/module/sequence/prop.py +++ b/src/blenderbim/blenderbim/bim/module/sequence/prop.py @@ -224,10 +224,8 @@ def updateTaskDuration(self, context): else: task_time = tool.Ifc.run("sequence.add_task_time", task=task) tool.Ifc.run("sequence.edit_task_time", task_time=task_time, attributes={"ScheduleDuration": duration}) - SequenceData.load() blenderbim.core.sequence.load_task_properties(tool.Sequence) - bpy.ops.bim.load_task_properties() - tool.Sequence.load_resources() + tool.Sequence.refresh_task_resources() def get_schedule_predefined_types(self, context): @@ -332,6 +330,8 @@ def updateAssignedResourceName(self, context): pass def updateAssignedResourceUsage(self, context): + if not bpy.context.scene.BIMResourceProperties.is_resource_update_enabled: + return if not self.schedule_usage: return resource = tool.Ifc.get().by_id(self.ifc_definition_id) @@ -344,7 +344,7 @@ def updateAssignedResourceUsage(self, context): tool.Resource.load_resource_properties() tool.Sequence.refresh_task_resources() blenderbim.bim.module.resource.data.refresh() - blenderbim.bim.module.sequence.data.refresh() + refresh_sequence_data() blenderbim.bim.module.pset.data.refresh() class Task(PropertyGroup): diff --git a/src/blenderbim/blenderbim/tool/sequence.py b/src/blenderbim/blenderbim/tool/sequence.py index 6fea1e3639..038a95473f 100644 --- a/src/blenderbim/blenderbim/tool/sequence.py +++ b/src/blenderbim/blenderbim/tool/sequence.py @@ -409,19 +409,21 @@ class Sequence(blenderbim.core.tool.Sequence): return blenderbim.bim.helper.export_attributes(props.task_time_attributes, callback) @classmethod - def load_task_resources(cls, resources): + def load_task_resources(cls, task): + resources = cls.get_task_resources(task) props = bpy.context.scene.BIMWorkScheduleProperties props.task_resources.clear() + bpy.context.scene.BIMResourceProperties.is_resource_update_enabled = False for resource in resources or []: new = props.task_resources.add() new.ifc_definition_id = resource.id() new.name = resource.Name or "Unnamed" new.schedule_usage = resource.Usage.ScheduleUsage or 0 if resource.Usage else 0 + bpy.context.scene.BIMResourceProperties.is_resource_update_enabled = True @classmethod def load_resources(cls): blenderbim.core.resource.load_resources(tool.Resource) - cls.refresh_task_resources @classmethod def get_task_inputs(cls, task): @@ -1664,17 +1666,16 @@ class Sequence(blenderbim.core.tool.Sequence): return inputs = cls.get_task_inputs(task) outputs = cls.get_task_outputs(task) - resources = cls.get_task_resources(task) cls.load_task_inputs(inputs) cls.load_task_outputs(outputs) - cls.load_task_resources(resources) + cls.load_task_resources(task) @classmethod def refresh_task_resources(cls): task = cls.get_highlighted_task() if not task: return - cls.load_task_resources(cls.get_task_resources(task)) + cls.load_task_resources(task) @classmethod def has_duration(cls, task): From 31dcbc01591707c7e199fa49fabd44d1f18eaf36 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 24 Sep 2023 15:43:10 +1000 Subject: [PATCH 155/190] Splitting by edge now resolved mapped representations, voided, and non-mesh geometries --- .../blenderbim/bim/module/misc/operator.py | 47 ++++++++++++++++++- src/blenderbim/blenderbim/core/misc.py | 8 ---- src/blenderbim/test/core/test_misc.py | 8 ---- 3 files changed, 46 insertions(+), 17 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/misc/operator.py b/src/blenderbim/blenderbim/bim/module/misc/operator.py index 9271b05197..6806c3635a 100644 --- a/src/blenderbim/blenderbim/bim/module/misc/operator.py +++ b/src/blenderbim/blenderbim/bim/module/misc/operator.py @@ -22,6 +22,7 @@ import ifcopenshell import blenderbim.bim.handler import blenderbim.tool as tool import blenderbim.core.misc as core +import blenderbim.core.geometry as core_geometry from blenderbim.bim.ifc import IfcStore from mathutils import Vector, Matrix, Euler @@ -140,7 +141,51 @@ class SplitAlongEdge(bpy.types.Operator, Operator): return context.selected_objects and tool.Ifc.get() def _execute(self, context): - core.split_along_edge(tool.Misc, cutter=context.active_object, objs=context.selected_objects) + cutter = context.active_object + objs = [o for o in context.selected_objects if o != cutter] + + # Splitting only works on meshes + for obj in objs: + # You cannot split meshes if the representation is mapped. + element = tool.Ifc.get_entity(obj) + if element: + relating_type = tool.Root.get_element_type(element) + if relating_type and tool.Root.does_type_have_representations(relating_type): + bpy.ops.bim.unassign_type(related_object=obj.name) + + representation = tool.Geometry.get_active_representation(obj) + core_geometry.switch_representation( + tool.Ifc, + tool.Geometry, + obj=obj, + representation=representation, + should_reload=True, + is_global=True, + should_sync_changes_first=False, + apply_openings=False, + ) + + if not tool.Geometry.is_meshlike(representation): + bpy.ops.bim.update_representation(obj=obj.name, ifc_representation_class="IfcTessellatedFaceSet") + + new_objs = tool.Misc.split_objects_with_cutter(objs, cutter) + for obj in new_objs: + blenderbim.core.root.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=obj) + bpy.ops.bim.update_representation(obj=obj.name) + for obj in objs: + bpy.ops.bim.update_representation(obj=obj.name) + + representation = tool.Geometry.get_active_representation(obj) + core_geometry.switch_representation( + tool.Ifc, + tool.Geometry, + obj=obj, + representation=representation, + should_reload=True, + is_global=True, + should_sync_changes_first=False, + apply_openings=True, + ) class GetConnectedSystemElements(bpy.types.Operator, Operator): diff --git a/src/blenderbim/blenderbim/core/misc.py b/src/blenderbim/blenderbim/core/misc.py index 358f96d5ec..5aff2100c0 100644 --- a/src/blenderbim/blenderbim/core/misc.py +++ b/src/blenderbim/blenderbim/core/misc.py @@ -28,11 +28,3 @@ def resize_to_storey(misc, obj=None, total_storeys=None): misc.move_object_to_elevation(obj, misc.get_storey_elevation_in_si(storey)) misc.scale_object_to_height(obj, height) misc.mark_object_as_edited(obj) - - -def split_along_edge(misc, cutter=None, objs=None): - new_objs = misc.split_objects_with_cutter(objs, cutter) - for obj in new_objs: - misc.run_root_copy_class(obj=obj) - for obj in objs: - misc.mark_object_as_edited(obj) diff --git a/src/blenderbim/test/core/test_misc.py b/src/blenderbim/test/core/test_misc.py index 5f65c62867..9e420c0c23 100644 --- a/src/blenderbim/test/core/test_misc.py +++ b/src/blenderbim/test/core/test_misc.py @@ -39,11 +39,3 @@ class TestResizeToStorey: misc.get_object_storey("obj").should_be_called().will_return("storey") misc.get_storey_height_in_si("storey", 1).should_be_called().will_return(None) subject.resize_to_storey(misc, obj="obj", total_storeys=1) - - -class TestSplitAlongEdge: - def test_run(self, misc): - misc.split_objects_with_cutter(["obj"], "cutter").should_be_called().will_return(["new_obj"]) - misc.run_root_copy_class(obj="new_obj").should_be_called() - misc.mark_object_as_edited("obj").should_be_called() - subject.split_along_edge(misc, cutter="cutter", objs=["obj"]) From 38f5468f19dbdf2cb42e7de73e941a61175966be Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Sun, 24 Sep 2023 15:45:11 +1000 Subject: [PATCH 156/190] We now support separating IFC meshes. --- src/blenderbim/blenderbim/bim/import_ifc.py | 2 +- .../bim/module/geometry/__init__.py | 4 +++ .../bim/module/geometry/operator.py | 33 +++++++++++++++++++ .../blenderbim/bim/module/geometry/ui.py | 15 +++++++++ 4 files changed, 53 insertions(+), 1 deletion(-) diff --git a/src/blenderbim/blenderbim/bim/import_ifc.py b/src/blenderbim/blenderbim/bim/import_ifc.py index fe3df02658..762a482382 100644 --- a/src/blenderbim/blenderbim/bim/import_ifc.py +++ b/src/blenderbim/blenderbim/bim/import_ifc.py @@ -1939,7 +1939,7 @@ class IfcImporter: def setup_arrays(self): for element in self.file.by_type("IfcElement"): pset_data = ifcopenshell.util.element.get_pset(element, "BBIM_Array") - if pset_data or not pset_data.get("Data", None): # skip array children + if not pset_data or not pset_data.get("Data", None): # skip array children continue for i in range(len(json.loads(pset_data["Data"]))): tool.Blender.Modifier.Array.set_children_lock_state(element, i, True) diff --git a/src/blenderbim/blenderbim/bim/module/geometry/__init__.py b/src/blenderbim/blenderbim/bim/module/geometry/__init__.py index c6f2a1e1c9..29503856bc 100644 --- a/src/blenderbim/blenderbim/bim/module/geometry/__init__.py +++ b/src/blenderbim/blenderbim/bim/module/geometry/__init__.py @@ -32,6 +32,7 @@ classes = ( operator.OverrideDuplicateMoveLinkedMacro, operator.OverrideDuplicateMoveMacro, operator.OverrideJoin, + operator.OverrideMeshSeparate, operator.OverrideModeSetEdit, operator.OverrideModeSetObject, operator.OverrideOriginSet, @@ -53,6 +54,7 @@ classes = ( ui.BIM_PT_mesh, ui.BIM_PT_workarounds, ui.BIM_MT_object_set_origin, + ui.BIM_MT_separate, ) @@ -73,6 +75,7 @@ def register(): bpy.types.VIEW3D_MT_object.append(ui.object_menu) bpy.types.OUTLINER_MT_object.append(ui.outliner_menu) bpy.types.VIEW3D_MT_object_context_menu.append(ui.object_menu) + bpy.types.VIEW3D_MT_edit_mesh.append(ui.edit_mesh_menu) wm = bpy.context.window_manager if wm.keyconfigs.addon: km = wm.keyconfigs.addon.keymaps.new(name="Object Mode", space_type="EMPTY") @@ -116,6 +119,7 @@ def unregister(): bpy.types.OBJECT_PT_transform.remove(ui.BIM_PT_transform) bpy.types.OUTLINER_MT_object.remove(ui.outliner_menu) bpy.types.VIEW3D_MT_object_context_menu.remove(ui.outliner_menu) + bpy.types.VIEW3D_MT_edit_mesh.remove(ui.edit_mesh_menu) del bpy.types.Scene.BIMGeometryProperties del bpy.types.Object.BIMGeometryProperties wm = bpy.context.window_manager diff --git a/src/blenderbim/blenderbim/bim/module/geometry/operator.py b/src/blenderbim/blenderbim/bim/module/geometry/operator.py index a2a01392f5..cd0d147e94 100644 --- a/src/blenderbim/blenderbim/bim/module/geometry/operator.py +++ b/src/blenderbim/blenderbim/bim/module/geometry/operator.py @@ -57,6 +57,39 @@ class EditObjectPlacement(bpy.types.Operator, Operator): core.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj) +class OverrideMeshSeparate(bpy.types.Operator, Operator): + bl_idname = "bim.override_mesh_separate" + bl_label = "IFC Mesh Separate" + bl_options = {"REGISTER", "UNDO"} + obj: bpy.props.StringProperty() + type: bpy.props.StringProperty() + + def _execute(self, context): + obj = context.active_object + + # You cannot separate meshes if the representation is mapped. + relating_type = tool.Root.get_element_type(tool.Ifc.get_entity(obj)) + if relating_type and tool.Root.does_type_have_representations(relating_type): + # We toggle edit mode to ensure that once representations are + # unmapped, our Blender mesh only has a single user. + tool.Blender.toggle_edit_mode(context) + bpy.ops.bim.unassign_type(related_object=obj.name) + tool.Blender.toggle_edit_mode(context) + + selected_objects = context.selected_objects + bpy.ops.mesh.separate(type=self.type) + bpy.ops.object.mode_set(mode="OBJECT", toggle=False) + new_objs = [obj] + for new_obj in context.selected_objects: + if new_obj == obj: + continue + # This is not very efficient, it needlessly copies the representations first. + blenderbim.core.root.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=new_obj) + new_objs.append(new_obj) + for new_obj in new_objs: + bpy.ops.bim.update_representation(obj=new_obj.name) + + class OverrideOriginSet(bpy.types.Operator, Operator): bl_idname = "bim.override_origin_set" bl_label = "IFC Origin Set" diff --git a/src/blenderbim/blenderbim/bim/module/geometry/ui.py b/src/blenderbim/blenderbim/bim/module/geometry/ui.py index 92a8167443..a91cf340ee 100644 --- a/src/blenderbim/blenderbim/bim/module/geometry/ui.py +++ b/src/blenderbim/blenderbim/bim/module/geometry/ui.py @@ -32,6 +32,21 @@ def object_menu(self, context): self.layout.menu("BIM_MT_object_set_origin", icon="PLUGIN") +def edit_mesh_menu(self, context): + self.layout.separator() + self.layout.menu("BIM_MT_separate", icon="PLUGIN") + + +class BIM_MT_separate(Menu): + bl_idname = "BIM_MT_separate" + bl_label = "IFC Separate" + + def draw(self, context): + self.layout.operator("bim.override_mesh_separate", icon="PLUGIN", text="IFC Selection").type = "SELECTED" + self.layout.operator("bim.override_mesh_separate", icon="PLUGIN", text="IFC By Material").type = "MATERIAL" + self.layout.operator("bim.override_mesh_separate", icon="PLUGIN", text="IFC By Loose Parts").type = "LOOSE" + + class BIM_MT_object_set_origin(Menu): bl_idname = "BIM_MT_object_set_origin" bl_label = "IFC Set Origin" From be2c7a62fdeabb1e791586822c70e19b5f35fc17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bruno=20Perdig=C3=A3o?= Date: Fri, 22 Sep 2023 16:37:46 -0300 Subject: [PATCH 157/190] remove connections with old objects when duplicating aggregates --- .../bim/module/geometry/operator.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/blenderbim/blenderbim/bim/module/geometry/operator.py b/src/blenderbim/blenderbim/bim/module/geometry/operator.py index cd0d147e94..0c3304e59e 100644 --- a/src/blenderbim/blenderbim/bim/module/geometry/operator.py +++ b/src/blenderbim/blenderbim/bim/module/geometry/operator.py @@ -1109,6 +1109,17 @@ class OverrideDuplicateMoveAggregate(bpy.types.Operator): recreate_data_structure(new_root_entity) + # Remove connections with old objects + for new in old_to_new.values(): + for connection in new[0].ConnectedTo: + entity = connection.RelatedElement + if entity in old_to_new.keys(): + core.remove_connection(tool.Geometry, connection=connection) + for connection in new[0].ConnectedFrom: + entity = connection.RelatingElement + if entity in old_to_new.keys(): + core.remove_connection(tool.Geometry, connection=connection) + old_objs = [] for old, new in old_to_new.items(): old_objs.append(tool.Ifc.get_object(old)) @@ -1255,6 +1266,18 @@ class RefreshAggregate(bpy.types.Operator): for parent in parents: duplicate_children(parent) + + # Remove connections with old objects + for new in old_to_new.values(): + for connection in new[0].ConnectedTo: + entity = connection.RelatedElement + if entity in old_to_new.keys(): + core.remove_connection(tool.Geometry, connection=connection) + for connection in new[0].ConnectedFrom: + entity = connection.RelatingElement + if entity in old_to_new.keys(): + core.remove_connection(tool.Geometry, connection=connection) + old_objs = [] for old, new in old_to_new.items(): old_objs.append(tool.Ifc.get_object(old)) From b2f95bfa60fa69d1789255f8e34a9943e61780e8 Mon Sep 17 00:00:00 2001 From: "Sigma Dimensions (Yass)" <79010126+myoualid@users.noreply.github.com> Date: Sun, 24 Sep 2023 18:44:56 +0100 Subject: [PATCH 158/190] change tests due to new class assignment workflow ref commit 808ff54 --- .../test/bim/feature/aggregate.feature | 3 +++ .../test/bim/feature/attribute.feature | 2 ++ src/blenderbim/test/bim/feature/cost.feature | 6 +++++ .../test/bim/feature/document.feature | 2 ++ .../test/bim/feature/drawing.feature | 6 +++++ .../test/bim/feature/geometry.feature | 22 +++++++++++++++++++ .../test/bim/feature/library.feature | 2 ++ .../test/bim/feature/material.feature | 19 ++++++++++++++++ src/blenderbim/test/bim/feature/misc.feature | 2 ++ src/blenderbim/test/bim/feature/owner.feature | 2 ++ .../test/bim/feature/project.feature | 3 +++ src/blenderbim/test/bim/feature/pset.feature | 15 +++++++++++++ .../test/bim/feature/resource.feature | 3 +++ src/blenderbim/test/bim/feature/root.feature | 6 +++++ .../test/bim/feature/search.feature | 1 + .../test/bim/feature/sequence.feature | 21 +++++++++++++----- .../test/bim/feature/spatial.feature | 6 +++++ src/blenderbim/test/bim/feature/type.feature | 10 +++++++++ src/blenderbim/test/bim/feature/void.feature | 9 ++++++++ 19 files changed, 135 insertions(+), 5 deletions(-) diff --git a/src/blenderbim/test/bim/feature/aggregate.feature b/src/blenderbim/test/bim/feature/aggregate.feature index 662d3b6d15..9d6c35d294 100644 --- a/src/blenderbim/test/bim/feature/aggregate.feature +++ b/src/blenderbim/test/bim/feature/aggregate.feature @@ -61,6 +61,7 @@ Scenario: Add aggregate Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected @@ -73,6 +74,7 @@ Scenario: Add aggregate - with the aggregate inheriting the existing spatial col Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is placed in the collection "IfcBuildingStorey/My Storey" @@ -86,6 +88,7 @@ Scenario: Add aggregate - add a nested aggregate Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is placed in the collection "IfcBuildingStorey/My Storey" diff --git a/src/blenderbim/test/bim/feature/attribute.feature b/src/blenderbim/test/bim/feature/attribute.feature index 2e0047e9c9..0810ec6068 100644 --- a/src/blenderbim/test/bim/feature/attribute.feature +++ b/src/blenderbim/test/bim/feature/attribute.feature @@ -38,10 +38,12 @@ Scenario: Copy attribute to selected Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected diff --git a/src/blenderbim/test/bim/feature/cost.feature b/src/blenderbim/test/bim/feature/cost.feature index aefe3e5ff9..e74e6b003d 100644 --- a/src/blenderbim/test/bim/feature/cost.feature +++ b/src/blenderbim/test/bim/feature/cost.feature @@ -357,6 +357,7 @@ Scenario: Assign cost item quantity - count based And the variable "cost_item" is "{ifc}.by_type('IfcCostItem')[0].id()" And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" When I press "bim.assign_cost_item_quantity(cost_item={cost_item}, related_object_type='PRODUCT', prop_name='')" @@ -371,6 +372,7 @@ Scenario: Assign cost item quantity - quantity based And the variable "cost_item" is "{ifc}.by_type('IfcCostItem')[0].id()" And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected @@ -388,6 +390,7 @@ Scenario: Unassign cost item quantity - selection based And the variable "cost_item" is "{ifc}.by_type('IfcCostItem')[0].id()" And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected @@ -404,6 +407,7 @@ Scenario: Unassign cost item quantity - explicit object And the variable "cost_item" is "{ifc}.by_type('IfcCostItem')[0].id()" And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected @@ -421,6 +425,7 @@ Scenario: Select cost item products And the variable "cost_item" is "{ifc}.by_type('IfcCostItem')[0].id()" And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected @@ -437,6 +442,7 @@ Scenario: Select Cost Schedule Products And the variable "cost_item" is "{ifc}.by_type('IfcCostItem')[0].id()" And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected diff --git a/src/blenderbim/test/bim/feature/document.feature b/src/blenderbim/test/bim/feature/document.feature index db14740fbe..2157a79b2f 100644 --- a/src/blenderbim/test/bim/feature/document.feature +++ b/src/blenderbim/test/bim/feature/document.feature @@ -89,6 +89,7 @@ Scenario: Assign document And I add a cube And the object "Cube" is selected And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" When I press "bim.assign_document(document={reference})" @@ -105,6 +106,7 @@ Scenario: Unassign document And I add a cube And the object "Cube" is selected And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I press "bim.assign_document(document={reference})" diff --git a/src/blenderbim/test/bim/feature/drawing.feature b/src/blenderbim/test/bim/feature/drawing.feature index e9d9ec5a81..28c2b5d80c 100644 --- a/src/blenderbim/test/bim/feature/drawing.feature +++ b/src/blenderbim/test/bim/feature/drawing.feature @@ -5,6 +5,7 @@ Scenario: Duplicate drawing Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the variable "wall1" is "IfcStore.get_file().by_type('IfcWall')[-1].id()" @@ -17,6 +18,7 @@ Scenario: Create drawing Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the variable "wall1" is "IfcStore.get_file().by_type('IfcWall')[-1].id()" @@ -31,6 +33,7 @@ Scenario: Create drawing after deleting a duplicated object Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the variable "wall1" is "IfcStore.get_file().by_type('IfcWall')[-1].id()" @@ -52,6 +55,7 @@ Scenario: Remove drawing Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the variable "wall1" is "IfcStore.get_file().by_type('IfcWall')[-1].id()" @@ -65,6 +69,7 @@ Scenario: Remove drawing - via object deletion Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the variable "wall1" is "IfcStore.get_file().by_type('IfcWall')[-1].id()" @@ -79,6 +84,7 @@ Scenario: Remove drawing - deleting active drawing Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the variable "wall1" is "IfcStore.get_file().by_type('IfcWall')[-1].id()" diff --git a/src/blenderbim/test/bim/feature/geometry.feature b/src/blenderbim/test/bim/feature/geometry.feature index fc9afdd286..206769fc64 100644 --- a/src/blenderbim/test/bim/feature/geometry.feature +++ b/src/blenderbim/test/bim/feature/geometry.feature @@ -5,6 +5,7 @@ Scenario: Edit object placement Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected @@ -15,6 +16,7 @@ Scenario: Add representation Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected @@ -48,6 +50,7 @@ Scenario: Add representation - add a representation with a scale factor applied And the object "Cube" is selected When the object "Cube" is scaled to "2" And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" Then the object "IfcWall/Cube" has no scale @@ -60,6 +63,7 @@ Scenario: Add representation - add a representation with a scale factor removed And the object "Cube" is selected When the object "Cube" is scaled to "2" And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" Then the object "IfcWall/Cube" has no scale @@ -69,6 +73,7 @@ Scenario: Switch representation Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" When the variable "representation" is "{ifc}.by_type('IfcShapeRepresentation')[0].id()" @@ -79,6 +84,7 @@ Scenario: Switch representation - current edited representation is updated prior Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the variable "context" is "[c for c in {ifc}.by_type('IfcGeometricRepresentationSubContext') if c.ContextType == 'Plan' and c.ContextIdentifier=='Annotation'][0].id()" @@ -96,6 +102,7 @@ Scenario: Switch representation - current edited representation is discarded if Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" When the object "IfcWall/Cube" is scaled to "2" @@ -111,6 +118,7 @@ Scenario: Switch representation - existing Blender modifiers must be purged And I add a cube And the object "Cube" is selected And I add an array modifier + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" When the variable "representation" is "{ifc}.by_type('IfcShapeRepresentation')[0].id()" @@ -122,6 +130,7 @@ Scenario: Remove representation - remove an active representation And I add a cube And the object "Cube" is selected And I add an array modifier + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" When the variable "representation" is "{ifc}.by_type('IfcShapeRepresentation')[0].id()" @@ -133,6 +142,7 @@ Scenario: Remove representation - remove an unloaded representation And I add a cube And the object "Cube" is selected And I add an array modifier + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" When the variable "representation" is "{ifc}.by_type('IfcShapeRepresentation')[1].id()" @@ -182,6 +192,7 @@ Scenario: Update representation - updating a tessellation And I add a cube And the object "Cube" is selected And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I press "bim.update_representation(obj='IfcWall/Cube')" @@ -192,6 +203,7 @@ Scenario: Update representation - updating a layered extrusion And I add a cube And the object "Cube" is selected And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I add an empty @@ -219,6 +231,7 @@ Scenario: Update representation - updating a profiled extrusion Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I add an empty @@ -242,6 +255,7 @@ Scenario: Get representation IFC parameters Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I press "bim.update_representation(ifc_representation_class='IfcExtrudedAreaSolid/IfcRectangleProfileDef')" @@ -252,6 +266,7 @@ Scenario: Copy representation Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I add a cube @@ -273,6 +288,7 @@ Scenario: Override delete - with active IFC data Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected @@ -297,6 +313,7 @@ Scenario: Override duplicate move - with active IFC data Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected @@ -315,6 +332,7 @@ Scenario: Override duplicate move - copying a coloured representation And I add a cube And the object "Cube" is selected And I add a material + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected @@ -347,6 +365,7 @@ Scenario: Override duplicate move - copying a layered extrusion Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I add an empty @@ -376,6 +395,7 @@ Scenario: Override duplicate move - copying a profiled extrusion Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I add an empty @@ -412,6 +432,7 @@ Scenario: Override duplicate move linked - with active IFC data Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected @@ -440,6 +461,7 @@ Scenario: Override paste buffer - with active IFC data Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected diff --git a/src/blenderbim/test/bim/feature/library.feature b/src/blenderbim/test/bim/feature/library.feature index 3cc340d993..1223eccbda 100644 --- a/src/blenderbim/test/bim/feature/library.feature +++ b/src/blenderbim/test/bim/feature/library.feature @@ -113,6 +113,7 @@ Scenario: Assign library reference And the variable "reference" is "{ifc}.by_type('IfcLibraryReference')[-1].id()" And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected @@ -128,6 +129,7 @@ Scenario: Unassign library reference And the variable "reference" is "{ifc}.by_type('IfcLibraryReference')[-1].id()" And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected diff --git a/src/blenderbim/test/bim/feature/material.feature b/src/blenderbim/test/bim/feature/material.feature index 18d9b8b0bc..290e8aaf0c 100644 --- a/src/blenderbim/test/bim/feature/material.feature +++ b/src/blenderbim/test/bim/feature/material.feature @@ -72,6 +72,7 @@ Scenario: Assign material - single material Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I press "bim.add_material(obj='')" @@ -83,6 +84,7 @@ Scenario: Unassign material - single material Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I press "bim.add_material(obj='')" @@ -95,6 +97,7 @@ Scenario: Enable editing assigned material - single material Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I press "bim.add_material(obj='')" @@ -107,6 +110,7 @@ Scenario: Disable editing assigned material - single material Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I press "bim.add_material(obj='')" @@ -120,6 +124,7 @@ Scenario: Edit assigned material - single material Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I press "bim.add_material(obj='')" @@ -160,6 +165,7 @@ Scenario: Unassign material - removing inherited material Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" @@ -186,6 +192,7 @@ Scenario: Enable editing assigned material - material layer set Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I add an empty @@ -203,6 +210,7 @@ Scenario: Disable editing assigned material - material layer set Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I add an empty @@ -221,6 +229,7 @@ Scenario: Edit assigned material - material layer set Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I add an empty @@ -266,6 +275,7 @@ Scenario: Enable editing assigned material - material profile set Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I add an empty @@ -283,6 +293,7 @@ Scenario: Disable editing assigned material - material profile set Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I add an empty @@ -301,6 +312,7 @@ Scenario: Edit assigned material - material profile set Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I add an empty @@ -320,6 +332,7 @@ Scenario: Assign material - material constituent set Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I press "bim.add_material(obj='')" @@ -331,6 +344,7 @@ Scenario: Unassign material - material constituent set Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I press "bim.add_material(obj='')" @@ -343,6 +357,7 @@ Scenario: Enable editing assigned material - material constituent set Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I press "bim.add_material(obj='')" @@ -355,6 +370,7 @@ Scenario: Disable editing assigned material - material constituent set Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I press "bim.add_material(obj='')" @@ -368,6 +384,7 @@ Scenario: Edit assigned material - material constituent set Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I press "bim.add_material(obj='')" @@ -419,6 +436,7 @@ Scenario: Add material set layer Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I add an empty @@ -438,6 +456,7 @@ Scenario: Remove material set layer Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" diff --git a/src/blenderbim/test/bim/feature/misc.feature b/src/blenderbim/test/bim/feature/misc.feature index 282ec0d16b..e20ef274a8 100644 --- a/src/blenderbim/test/bim/feature/misc.feature +++ b/src/blenderbim/test/bim/feature/misc.feature @@ -28,6 +28,7 @@ Scenario: Resize to storey Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected @@ -40,6 +41,7 @@ Scenario: Split along edge Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I add a plane of size "4" at "0,0,0" diff --git a/src/blenderbim/test/bim/feature/owner.feature b/src/blenderbim/test/bim/feature/owner.feature index 7852e2d70e..b1b96ae6e4 100644 --- a/src/blenderbim/test/bim/feature/owner.feature +++ b/src/blenderbim/test/bim/feature/owner.feature @@ -284,6 +284,7 @@ Scenario: Assign actor And I press "bim.add_actor" And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the variable "actor" is "{ifc}.by_type('IfcActor')[0].id()" @@ -297,6 +298,7 @@ Scenario: Unassign actor And I press "bim.add_actor" And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the variable "actor" is "{ifc}.by_type('IfcActor')[0].id()" diff --git a/src/blenderbim/test/bim/feature/project.feature b/src/blenderbim/test/bim/feature/project.feature index d61d895b43..c427e2e877 100644 --- a/src/blenderbim/test/bim/feature/project.feature +++ b/src/blenderbim/test/bim/feature/project.feature @@ -436,6 +436,7 @@ Scenario: Export IFC - with changed object scale synchronised Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected @@ -450,6 +451,7 @@ Scenario: Export IFC - with changed style colour synchronised And I add a cube And the object "Cube" is selected And I add a material + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected @@ -464,6 +466,7 @@ Scenario: Export IFC - with changed style element synchronised And I add a cube And the object "Cube" is selected And I add a material + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected diff --git a/src/blenderbim/test/bim/feature/pset.feature b/src/blenderbim/test/bim/feature/pset.feature index b4c87ffb1f..033c64627b 100644 --- a/src/blenderbim/test/bim/feature/pset.feature +++ b/src/blenderbim/test/bim/feature/pset.feature @@ -5,6 +5,7 @@ Scenario: Add pset - object Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected @@ -16,11 +17,13 @@ Scenario: Add pset - multiple objects Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected @@ -33,6 +36,7 @@ Scenario: Enable pset editing - object Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected @@ -48,6 +52,7 @@ Scenario: Enable pset editing - material Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I press "bim.add_material(obj='')" @@ -127,6 +132,7 @@ Scenario: Disable pset editing - object Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected @@ -139,6 +145,7 @@ Scenario: Disable pset editing - material Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I press "bim.add_material(obj='')" @@ -204,6 +211,7 @@ Scenario: Edit pset - object Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected @@ -216,6 +224,7 @@ Scenario: Edit qto - object Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected @@ -229,6 +238,7 @@ Scenario: Edit pset - material Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I press "bim.add_material(obj='')" @@ -294,10 +304,12 @@ Scenario: Copy property to selected - copy property Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected @@ -312,6 +324,7 @@ Scenario: Remove pset - object Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected @@ -327,11 +340,13 @@ Scenario: Remove pset - multiple objects Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected diff --git a/src/blenderbim/test/bim/feature/resource.feature b/src/blenderbim/test/bim/feature/resource.feature index 3e01cfa926..ac7522e774 100644 --- a/src/blenderbim/test/bim/feature/resource.feature +++ b/src/blenderbim/test/bim/feature/resource.feature @@ -274,6 +274,7 @@ Scenario: Calculate Resource Work And I press "bim.edit_task_time" And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected @@ -307,6 +308,7 @@ Scenario: Assign Resource And the variable "labor_resource" is "IfcStore.get_file().by_type('IfcLaborResource')[0].id()" And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected @@ -322,6 +324,7 @@ Scenario: UnAssign Resource And the variable "labor_resource" is "IfcStore.get_file().by_type('IfcLaborResource')[0].id()" And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected diff --git a/src/blenderbim/test/bim/feature/root.feature b/src/blenderbim/test/bim/feature/root.feature index 28b9e71726..ea2a032a27 100644 --- a/src/blenderbim/test/bim/feature/root.feature +++ b/src/blenderbim/test/bim/feature/root.feature @@ -5,6 +5,7 @@ Scenario: Reassign class Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class(ifc_class='IfcWall', predefined_type='SOLIDWALL')" And I press "object.duplicate_move" @@ -20,6 +21,7 @@ Scenario: Unlink object And I add a cube And the object "Cube" is selected And I add a material + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I press "bim.add_material" @@ -32,6 +34,7 @@ Scenario: Copy class Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" When I press "bim.copy_class(obj='IfcWall/Cube')" @@ -41,6 +44,7 @@ Scenario: Assign a class to a cube Given an empty IFC project And I add a cube When the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" Then the object "IfcWall/Cube" is an "IfcWall" @@ -87,6 +91,7 @@ Scenario: Assign a class to a cube in a collection And I add a cube When the object "Cube" is selected And the object "Cube" is placed in the collection "IfcBuildingStorey/My Storey" + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" Then the object "IfcWall/Cube" is contained in "My Storey" @@ -95,6 +100,7 @@ Scenario: Copy a wall Given an empty IFC project And I add a cube When the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I duplicate the selected objects diff --git a/src/blenderbim/test/bim/feature/search.feature b/src/blenderbim/test/bim/feature/search.feature index 7e1614b01a..80ec5a2c10 100644 --- a/src/blenderbim/test/bim/feature/search.feature +++ b/src/blenderbim/test/bim/feature/search.feature @@ -5,6 +5,7 @@ Scenario: Select all walls Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I add a new item to "scene.IfcSelectorProperties.groups" diff --git a/src/blenderbim/test/bim/feature/sequence.feature b/src/blenderbim/test/bim/feature/sequence.feature index 4cd0e551cb..ecbaa64063 100644 --- a/src/blenderbim/test/bim/feature/sequence.feature +++ b/src/blenderbim/test/bim/feature/sequence.feature @@ -349,6 +349,8 @@ Scenario: Animate the construction of a wall And I press "bim.edit_task_time" And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected @@ -385,6 +387,7 @@ Scenario: Animate the demolition of a wall And I press "bim.edit_task_time" And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected @@ -424,6 +427,7 @@ Scenario: Animate the operation of a wall And I press "bim.edit_task_time" And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected @@ -458,6 +462,7 @@ Scenario: Animate the movement of a wall And I add a cube And I rename the object "Cube" to "ToObject" And the object "ToObject" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/ToObject" is selected @@ -508,6 +513,7 @@ Scenario: Animate the consumption of a wall And I press "bim.edit_task_time" And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected @@ -549,6 +555,7 @@ Scenario: Clear Previous Animation And I press "bim.edit_task_time" And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected @@ -758,6 +765,7 @@ Scenario: Assign Product Output And the variable "task" is "IfcStore.get_file().by_type('IfcTask')[0].id()" And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class(ifc_class='IfcWall', predefined_type='SOLIDWALL')" And the object "IfcWall/Cube" is selected @@ -773,10 +781,11 @@ Scenario: Assign Product Input And the variable "task" is "IfcStore.get_file().by_type('IfcTask')[0].id()" And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class(ifc_class='IfcWall', predefined_type='SOLIDWALL')" And the object "IfcWall/Cube" is selected - And I press "bim.assign_process(task={task}, related_object_type='PRODUCT')" + And I press "bim.assign_process(task={task},related_object=0, related_object_type='PRODUCT')" Then nothing happens Scenario: Select Assigned Outputs @@ -788,9 +797,9 @@ Scenario: Select Assigned Outputs And the variable "task" is "IfcStore.get_file().by_type('IfcTask')[0].id()" And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" - And I press "bim.assign_class(ifc_class='IfcWall', predefined_type='SOLIDWALL')" - And I press "object.select_all(action='DESELECT')" + And I press "bim.assign_class()" And I press "bim.assign_product(task={task})" When I press "bim.select_task_related_products(task={task})" Then nothing happens @@ -804,9 +813,10 @@ Scenario: Select Assigned Inputs And the variable "task" is "IfcStore.get_file().by_type('IfcTask')[0].id()" And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" - And I press "bim.assign_class(ifc_class='IfcWall', predefined_type='SOLIDWALL')" - And I press "object.select_all(action='DESELECT')" + And I press "bim.assign_class()" + And the object "IfcWall/Cube" is selected And I press "bim.assign_process(task={task}, related_object_type='PRODUCT')" When I press "bim.select_task_related_products(task={task})" Then nothing happens @@ -849,6 +859,7 @@ Scenario: Add Animation Camera Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I press "bim.add_animation_camera" diff --git a/src/blenderbim/test/bim/feature/spatial.feature b/src/blenderbim/test/bim/feature/spatial.feature index acdf6ee292..8262e9d0cb 100644 --- a/src/blenderbim/test/bim/feature/spatial.feature +++ b/src/blenderbim/test/bim/feature/spatial.feature @@ -21,6 +21,7 @@ Scenario: Assign container Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected @@ -33,6 +34,7 @@ Scenario: Copy to container Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected @@ -45,6 +47,7 @@ Scenario: Reference structure Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected @@ -57,6 +60,7 @@ Scenario: Dereference structure Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected @@ -70,6 +74,7 @@ Scenario: Select container Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected @@ -83,6 +88,7 @@ Scenario: Select similar container Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And the object "IfcWall/Cube" is selected diff --git a/src/blenderbim/test/bim/feature/type.feature b/src/blenderbim/test/bim/feature/type.feature index 1e02167b6b..2e0fd06206 100644 --- a/src/blenderbim/test/bim/feature/type.feature +++ b/src/blenderbim/test/bim/feature/type.feature @@ -24,6 +24,7 @@ Scenario: Enable editing type Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" When I press "bim.enable_editing_type" @@ -33,6 +34,7 @@ Scenario: Disable editing type Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I press "bim.enable_editing_type" @@ -43,6 +45,7 @@ Scenario: Assign type - assign to an empty type Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I add an empty @@ -58,6 +61,7 @@ Scenario: Assign type - assign to a type with representation maps Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I add a cube @@ -73,6 +77,7 @@ Scenario: Assign type - assign to a type with a material layer set, which automa Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I add an empty @@ -94,6 +99,7 @@ Scenario: Assign type - assign to a different type with a material layer set Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I add an empty @@ -132,6 +138,7 @@ Scenario: Assign type - assign to a type with a material profile set Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I add an empty @@ -155,6 +162,7 @@ Scenario: Select type objects Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I add an empty @@ -172,10 +180,12 @@ Scenario: Select similar type Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I add an empty diff --git a/src/blenderbim/test/bim/feature/void.feature b/src/blenderbim/test/bim/feature/void.feature index 44691a35d1..53231ec61e 100644 --- a/src/blenderbim/test/bim/feature/void.feature +++ b/src/blenderbim/test/bim/feature/void.feature @@ -6,6 +6,7 @@ Scenario: Add an opening Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I add a cube @@ -19,6 +20,7 @@ Scenario: Add an opening using the BIM tool Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I press "bim.add_potential_opening" @@ -32,6 +34,7 @@ Scenario: Show openings Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I press "bim.add_potential_opening" @@ -47,6 +50,7 @@ Scenario: Hide openings Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I press "bim.add_potential_opening" @@ -62,6 +66,7 @@ Scenario: Edit openings Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I press "bim.add_potential_opening" @@ -79,6 +84,7 @@ Scenario: Add an opening to Element B with a void that already voids Element A Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" @@ -105,6 +111,7 @@ Scenario: Remove opening Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I press "bim.add_potential_opening" @@ -123,6 +130,7 @@ Scenario: Remove opening - using deletion Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I press "bim.add_potential_opening" @@ -140,6 +148,7 @@ Scenario: Remove opening - indirectly by deleting its building element Given an empty IFC project And I add a cube And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_product" to "IfcElement" And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" And I press "bim.assign_class" And I press "bim.add_potential_opening" From e40019f3cb14b6adb28576f82d9d2d1cd2e9a38c Mon Sep 17 00:00:00 2001 From: "Sigma Dimensions (Yass)" <79010126+myoualid@users.noreply.github.com> Date: Sun, 24 Sep 2023 19:02:35 +0100 Subject: [PATCH 159/190] resources code cleanup --- .../bim/module/resource/__init__.py | 1 - .../bim/module/resource/operator.py | 13 -------- .../blenderbim/bim/module/resource/prop.py | 3 +- .../bim/module/sequence/operator.py | 4 ++- .../blenderbim/bim/module/sequence/prop.py | 8 ++--- .../blenderbim/bim/module/sequence/ui.py | 7 +++- src/blenderbim/blenderbim/core/cost.py | 33 +++++++++++++++---- src/blenderbim/blenderbim/core/resource.py | 22 ++++++------- src/blenderbim/blenderbim/core/sequence.py | 20 +++++------ src/blenderbim/blenderbim/core/tool.py | 3 +- src/blenderbim/blenderbim/tool/cost.py | 1 - src/blenderbim/blenderbim/tool/resource.py | 3 +- src/blenderbim/blenderbim/tool/sequence.py | 22 ++++--------- 13 files changed, 69 insertions(+), 71 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/resource/__init__.py b/src/blenderbim/blenderbim/bim/module/resource/__init__.py index 8512a370dd..8ab1410e3f 100644 --- a/src/blenderbim/blenderbim/bim/module/resource/__init__.py +++ b/src/blenderbim/blenderbim/bim/module/resource/__init__.py @@ -49,7 +49,6 @@ classes = ( operator.ExpandResource, operator.GoToResource, operator.ImportResources, - operator.LoadResourceProperties, operator.LoadResources, operator.RemoveResource, operator.RemoveResourceQuantity, diff --git a/src/blenderbim/blenderbim/bim/module/resource/operator.py b/src/blenderbim/blenderbim/bim/module/resource/operator.py index 068212f68c..9d64cdb0dd 100644 --- a/src/blenderbim/blenderbim/bim/module/resource/operator.py +++ b/src/blenderbim/blenderbim/bim/module/resource/operator.py @@ -33,19 +33,6 @@ class LoadResources(bpy.types.Operator): return {"FINISHED"} -class LoadResourceProperties(bpy.types.Operator): - bl_idname = "bim.load_resource_properties" - bl_label = "Load Resource Properties" - bl_options = {"REGISTER", "UNDO"} - resource: bpy.props.IntProperty() - - def execute(self, context): - core.load_resource_properties( - tool.Resource, resource=tool.Ifc.get().by_id(self.resource) if self.resource else None - ) - return {"FINISHED"} - - class AddResource(bpy.types.Operator, tool.Ifc.Operator): bl_idname = "bim.add_resource" bl_label = "Add Resource" diff --git a/src/blenderbim/blenderbim/bim/module/resource/prop.py b/src/blenderbim/blenderbim/bim/module/resource/prop.py index 9ea20d446c..2f7e539eb9 100644 --- a/src/blenderbim/blenderbim/bim/module/resource/prop.py +++ b/src/blenderbim/blenderbim/bim/module/resource/prop.py @@ -81,8 +81,7 @@ def update_active_resource_index(self, context): def updateResourceUsage(self, context): - props = context.scene.BIMResourceProperties - if not props.is_resource_update_enabled: + if not context.scene.BIMResourceProperties.is_resource_update_enabled: return if not self.schedule_usage: return diff --git a/src/blenderbim/blenderbim/bim/module/sequence/operator.py b/src/blenderbim/blenderbim/bim/module/sequence/operator.py index e66d492f3a..a7e6e1ea97 100644 --- a/src/blenderbim/blenderbim/bim/module/sequence/operator.py +++ b/src/blenderbim/blenderbim/bim/module/sequence/operator.py @@ -372,6 +372,7 @@ class EditTaskTime(bpy.types.Operator, tool.Ifc.Operator): core.edit_task_time( tool.Ifc, tool.Sequence, + tool.Resource, task_time=tool.Ifc.get().by_id(context.scene.BIMWorkScheduleProperties.active_task_time_id), ) @@ -508,7 +509,7 @@ class AssignProcess(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): if self.related_object_type == "RESOURCE": - core.assign_resource(tool.Ifc, tool.Sequence, task=tool.Ifc.get().by_id(self.task)) + core.assign_resource(tool.Ifc, tool.Sequence, tool.Resource, task=tool.Ifc.get().by_id(self.task)) elif self.related_object_type == "PRODUCT": if self.related_object: core.assign_input_products( @@ -541,6 +542,7 @@ class UnassignProcess(bpy.types.Operator): core.unassign_resource( tool.Ifc, tool.Sequence, + tool.Resource, task=tool.Ifc.get().by_id(self.task), resource=tool.Ifc.get().by_id(self.resource), ) diff --git a/src/blenderbim/blenderbim/bim/module/sequence/prop.py b/src/blenderbim/blenderbim/bim/module/sequence/prop.py index 629dd214f3..30dc776cf7 100644 --- a/src/blenderbim/blenderbim/bim/module/sequence/prop.py +++ b/src/blenderbim/blenderbim/bim/module/sequence/prop.py @@ -329,17 +329,16 @@ def get_saved_color_schemes(self, context): def updateAssignedResourceName(self, context): pass + def updateAssignedResourceUsage(self, context): - if not bpy.context.scene.BIMResourceProperties.is_resource_update_enabled: + if not context.scene.BIMResourceProperties.is_resource_update_enabled: return if not self.schedule_usage: return resource = tool.Ifc.get().by_id(self.ifc_definition_id) if resource.Usage and resource.Usage.ScheduleUsage == self.schedule_usage: return - tool.Resource.run_edit_resource_time(resource, attributes={ - "ScheduleUsage": self.schedule_usage - }) + tool.Resource.run_edit_resource_time(resource, attributes={"ScheduleUsage": self.schedule_usage}) tool.Sequence.load_task_properties() tool.Resource.load_resource_properties() tool.Sequence.refresh_task_resources() @@ -347,6 +346,7 @@ def updateAssignedResourceUsage(self, context): refresh_sequence_data() blenderbim.bim.module.pset.data.refresh() + class Task(PropertyGroup): name: StringProperty(name="Name", update=updateTaskName) identification: StringProperty(name="Identification", update=updateTaskIdentification) diff --git a/src/blenderbim/blenderbim/bim/module/sequence/ui.py b/src/blenderbim/blenderbim/bim/module/sequence/ui.py index 50542ffb19..850f507785 100644 --- a/src/blenderbim/blenderbim/bim/module/sequence/ui.py +++ b/src/blenderbim/blenderbim/bim/module/sequence/ui.py @@ -727,7 +727,11 @@ class BIM_PT_task_icom(Panel): if total_task_outputs: op = row2.operator("bim.unassign_product", icon="REMOVE", text="") op.task = task.ifc_definition_id - if not context.selected_objects and self.props.active_task_output_index < total_task_outputs: + if ( + total_task_outputs + and not context.selected_objects + and self.props.active_task_output_index < total_task_outputs + ): output_id = self.props.task_outputs[self.props.active_task_output_index].ifc_definition_id op.relating_product = output_id @@ -770,6 +774,7 @@ class BIM_UL_task_resources(UIList): row.prop(item, "name", emboss=False, text="") row.prop(item, "schedule_usage", emboss=False, text="") + class BIM_UL_animation_colors(UIList): def draw_item(self, context, layout, data, item, icon, active_data, active_propname): if item: diff --git a/src/blenderbim/blenderbim/core/cost.py b/src/blenderbim/blenderbim/core/cost.py index d8f5d9166b..19bf23f02a 100644 --- a/src/blenderbim/blenderbim/core/cost.py +++ b/src/blenderbim/blenderbim/core/cost.py @@ -1,4 +1,4 @@ -def add_cost_schedule(ifc, name, predefined_type,object_type): +def add_cost_schedule(ifc, name, predefined_type, object_type): ifc.run("cost.add_cost_schedule", name=name, predefined_type=predefined_type, object_type=object_type) @@ -112,70 +112,89 @@ def assign_cost_item_quantity(ifc, cost, cost_item, related_object_type, prop_na ifc.run("cost.assign_cost_item_quantity", cost_item=cost_item, products=products, prop_name=prop_name) cost.load_cost_item_quantity_assignments(cost_item, related_object_type=related_object_type) + def load_cost_item_quantities(cost): cost.load_cost_item_quantities() + def load_cost_item_element_quantities(cost): cost_item = cost.get_highlighted_cost_item() cost.load_cost_item_quantity_assignments(cost_item, related_object_type="PRODUCT") + def load_cost_item_task_quantities(cost): cost_item = cost.get_highlighted_cost_item() cost.load_cost_item_quantity_assignments(cost_item, related_object_type="PROCESS") + def load_cost_item_resource_quantities(cost): cost_item = cost.get_highlighted_cost_item() cost.load_cost_item_quantity_assignments(cost_item, related_object_type="RESOURCE") + def assign_cost_value(ifc, cost_item, cost_rate): ifc.run("cost.assign_cost_value", cost_item=cost_item, cost_rate=cost_rate) + def load_schedule_of_rates(cost, schedule_of_rates): cost.load_schedule_of_rates_tree(schedule_of_rates) + def unassign_cost_item_quantity(ifc, cost, cost_item, products): ifc.run("cost.unassign_cost_item_quantity", cost_item=cost_item, products=products) cost.load_cost_item_quantities() + def enable_editing_cost_item_quantities(cost, cost_item): cost.enable_editing_cost_item_quantities(cost_item) + def enable_editing_cost_item_values(cost, cost_item): cost.enable_editing_cost_item_values(cost_item) + def add_cost_item_quantity(ifc, cost_item, ifc_class): ifc.run("cost.add_cost_item_quantity", cost_item=cost_item, ifc_class=ifc_class) + def remove_cost_item_quantity(ifc, cost_item, physical_quantity): ifc.run("cost.remove_cost_item_quantity", cost_item=cost_item, physical_quantity=physical_quantity) - + + def enable_editing_cost_item_quantity(cost, physical_quantity): cost.load_cost_item_quantity_attributes(physical_quantity) cost.enable_editing_cost_item_quantity(physical_quantity) + def disable_editing_cost_item_quantity(cost): cost.disable_editing_cost_item_quantity() + def edit_cost_item_quantity(ifc, cost, physical_quantity): attributes = cost.get_cost_item_quantity_attributes() ifc.run("cost.edit_cost_item_quantity", physical_quantity=physical_quantity, attributes=attributes) cost.disable_editing_cost_item_quantity() cost.load_cost_item_quantities() + def add_cost_value(ifc, cost, parent, cost_type, cost_category): value = ifc.run("cost.add_cost_value", parent=parent) ifc.run( "cost.edit_cost_value", cost_value=value, - attributes=cost.get_attributes_for_cost_value(cost_type, cost_category)) + attributes=cost.get_attributes_for_cost_value(cost_type, cost_category), + ) + def remove_cost_value(ifc, parent, cost_value): ifc.run("cost.remove_cost_value", parent=parent, cost_value=cost_value) + def enable_editing_cost_item_value(cost, cost_value): cost.load_cost_item_value_attributes(cost_value) cost.enable_editing_cost_item_value(cost_value) + def disable_editing_cost_item_value(cost): cost.disable_editing_cost_item_value() @@ -195,7 +214,7 @@ def edit_cost_value(ifc, cost, cost_value): attributes = cost.get_cost_value_attributes() ifc.run("cost.edit_cost_value", cost_value=cost_value, attributes=attributes) cost.disable_editing_cost_item_value() - #cost.load_cost_item_values(cost.get_highlighted_cost_item()) + # cost.load_cost_item_values(cost.get_highlighted_cost_item()) def copy_cost_item_values(ifc, cost, source, destination): @@ -275,11 +294,12 @@ def change_parent_cost_item(ifc, cost, new_parent): cost_item = cost.get_active_cost_item() if cost_item and cost.is_root_cost_item(cost_item): return "Cannot change root cost item" - if cost_item : + if cost_item: ifc.run("nest.change_nest", item=cost_item, new_parent=new_parent) cost.disable_editing_cost_item_parent() cost.load_cost_schedule_tree() + def copy_cost_item(ifc, cost): cost_item = cost.get_highlighted_cost_item() if cost_item: @@ -287,9 +307,10 @@ def copy_cost_item(ifc, cost): cost.disable_editing_cost_item_parent() cost.load_cost_schedule_tree() + def add_currency(ifc, cost): unit = ifc.run("unit.add_monetary_unit") attributes = cost.get_currency_attributes() ifc.run("unit.edit_monetary_unit", unit=unit, attributes=attributes) ifc.run("unit.assign_unit", units=[unit]) - return unit \ No newline at end of file + return unit diff --git a/src/blenderbim/blenderbim/core/resource.py b/src/blenderbim/blenderbim/core/resource.py index c026139446..dd3875a04f 100644 --- a/src/blenderbim/blenderbim/core/resource.py +++ b/src/blenderbim/blenderbim/core/resource.py @@ -21,15 +21,11 @@ def load_resources(resource): resource.load_resources() - resource.load_resource_properties() + def add_resource(tool_ifc, resource_tool, ifc_class, parent_resource=None): tool_ifc.run("resource.add_resource", ifc_class=ifc_class, parent_resource=parent_resource) - load_resources(resource_tool) - - -def load_resource_properties(resource_tool, resource=None): - resource_tool.load_resource_properties() + resource_tool.load_resources() def disable_editing_resource(resource_tool): @@ -54,7 +50,7 @@ def edit_resource(ifc, resource_tool, resource): def remove_resource(ifc, resource_tool, resource=None): ifc.run("resource.remove_resource", resource=resource) - load_resources(resource_tool) + resource_tool.load_resources() def enable_editing_resource_time(ifc_tool, resource_tool, resource): @@ -82,7 +78,7 @@ def calculate_resource_work(ifc, resource_tool, resource): nested_resources = resource_tool.get_nested_resources(resource) for nested_resource in nested_resources or []: ifc.run("resource.calculate_resource_work", resource=nested_resource) - load_resources(resource_tool) + resource_tool.load_resources() def enable_editing_resource_costs(resource_tool, resource): @@ -143,17 +139,17 @@ def edit_resource_quantity(resource_tool, ifc, physical_quantity=None): def import_resources(resource_tool, file_path): resource_tool.import_resources(file_path) - load_resources(resource_tool) + resource_tool.load_resources() def expand_resource(resource_tool, resource): resource_tool.expand_resource(resource) - load_resources(resource_tool) + resource_tool.load_resources() def contract_resource(resource_tool, resource): resource_tool.contract_resource(resource) - load_resources(resource_tool) + resource_tool.load_resources() def assign_resource(ifc, spatial, resource=None, products=None): @@ -213,9 +209,11 @@ def remove_usage_constraint(ifc, resource_tool, resource, reference_path): ifc.run("constraint.unassign_constraint", product=resource, constraint=constraint) ifc.run("constraint.remove_constraint", constraint=constraint) + def go_to_resource(resource_tool, resource): resource_tool.go_to_resource(resource) + def calculate_resource_usage(ifc, resource_tool, resource): ifc.run("resource.calculate_resource_usage", resource=resource) - load_resources(resource_tool) \ No newline at end of file + resource_tool.load_resources() diff --git a/src/blenderbim/blenderbim/core/sequence.py b/src/blenderbim/blenderbim/core/sequence.py index 2321796783..d4ecd2bb72 100644 --- a/src/blenderbim/blenderbim/core/sequence.py +++ b/src/blenderbim/blenderbim/core/sequence.py @@ -186,14 +186,14 @@ def enable_editing_task_time(ifc, sequence, task=None): sequence.enable_editing_task_time(task) -def edit_task_time(ifc, sequence, task_time=None): +def edit_task_time(ifc, sequence, resource, task_time=None): attributes = sequence.get_task_time_attributes() # TODO: nasty loop goes on when calendar props are messed up ifc.run("sequence.edit_task_time", task_time=task_time, attributes=attributes) task = sequence.get_active_task() sequence.load_task_properties(task=task) sequence.disable_editing_task_time() - sequence.load_resources() + resource.load_resource_properties() def assign_predecessor(ifc, sequence, task=None): @@ -248,8 +248,8 @@ def unassign_input_products(ifc, sequence, spatial, task=None, products=None): sequence.load_task_inputs(inputs) -def assign_resource(ifc, sequence, task=None): - resource = sequence.get_selected_resource() +def assign_resource(ifc, sequence, resource_tool, task=None): + resource = resource_tool.get_highlighted_resource() sub_resource = ifc.run( "resource.add_resource", parent_resource=resource, @@ -257,17 +257,15 @@ def assign_resource(ifc, sequence, task=None): name="{}/{}".format(resource.Name or "Unnamed", task.Name or ""), ) ifc.run("sequence.assign_process", relating_process=task, related_object=sub_resource) - resources = sequence.get_task_resources(task) - sequence.load_task_resources(resources) - sequence.load_resources() + sequence.load_task_resources(task) + resource_tool.load_resources() -def unassign_resource(ifc, sequence, task=None, resource=None): +def unassign_resource(ifc, sequence, resource_tool, task=None, resource=None): ifc.run("sequence.unassign_process", relating_process=task, related_object=resource) ifc.run("resource.remove_resource", resource=resource) - resources = sequence.get_task_resources(task) - sequence.load_task_resources(resources) - sequence.load_resources() + sequence.load_task_resources(task) + resource_tool.load_resources() def remove_work_calendar(ifc, work_calendar=None): diff --git a/src/blenderbim/blenderbim/core/tool.py b/src/blenderbim/blenderbim/core/tool.py index f0f168a7ba..7e769df9dc 100644 --- a/src/blenderbim/blenderbim/core/tool.py +++ b/src/blenderbim/blenderbim/core/tool.py @@ -726,7 +726,6 @@ class Sequence: def get_recurrence_pattern_attributes(cls, recurrence_pattern): pass def get_recurrence_pattern_times(cls): pass def get_rel_sequence_attributes(cls): pass - def get_selected_resource(cls): pass def get_start_date(cls): pass def get_task_attribute_value(cls, attribute_name): pass def get_task_attributes(cls): pass @@ -758,7 +757,7 @@ class Sequence: def load_task_inputs(cls, inputs): pass def load_task_outputs(cls, outputs): pass def load_task_properties(cls, task): pass - def load_task_resources(cls,resources): pass + def load_task_resources(cls, task): pass def load_task_time_attributes(cls, task_time): pass def load_task_tree(cls, work_schedule): pass def load_work_calendar_attributes(cls, work_calendar): pass diff --git a/src/blenderbim/blenderbim/tool/cost.py b/src/blenderbim/blenderbim/tool/cost.py index 698625bc33..f51d157862 100644 --- a/src/blenderbim/blenderbim/tool/cost.py +++ b/src/blenderbim/blenderbim/tool/cost.py @@ -234,7 +234,6 @@ class Cost(blenderbim.core.tool.Cost): @classmethod def get_products(cls, related_object_type=None): - props = bpy.context.scene.BIMCostProperties if related_object_type == "PRODUCT": products = tool.Spatial.get_selected_products() elif related_object_type == "PROCESS": diff --git a/src/blenderbim/blenderbim/tool/resource.py b/src/blenderbim/blenderbim/tool/resource.py index 3c5698505d..99293fcbc9 100644 --- a/src/blenderbim/blenderbim/tool/resource.py +++ b/src/blenderbim/blenderbim/tool/resource.py @@ -61,6 +61,7 @@ class Resource(blenderbim.core.tool.Resource): continue create_new_resource_li(resource, 0) cls.load_productivity_data() + cls.load_resource_properties() props.is_resource_update_enabled = True props.is_editing = True @@ -425,7 +426,7 @@ class Resource(blenderbim.core.tool.Resource): contracted_resources.remove(ancestor) bpy.context.scene.BIMResourceProperties.contracted_resources = json.dumps(contracted_resources) cls.load_resources() - cls.load_resource_properties() + resource_props = bpy.context.scene.BIMResourceTreeProperties expanded_resources = [item.ifc_definition_id for item in resource_props.resources] diff --git a/src/blenderbim/blenderbim/tool/sequence.py b/src/blenderbim/blenderbim/tool/sequence.py index 038a95473f..c197d7799b 100644 --- a/src/blenderbim/blenderbim/tool/sequence.py +++ b/src/blenderbim/blenderbim/tool/sequence.py @@ -254,14 +254,6 @@ class Sequence(blenderbim.core.tool.Sequence): return None return tool.Ifc.get().by_id(bpy.context.scene.BIMWorkScheduleProperties.active_work_schedule_id) - @classmethod - def get_selected_resource(cls): - if bpy.context.scene.BIMResourceTreeProperties.resources: - selected_resource_id = bpy.context.scene.BIMResourceTreeProperties.resources[ - bpy.context.scene.BIMResourceProperties.active_resource_index - ].ifc_definition_id - return tool.Ifc.get().by_id(selected_resource_id) - @classmethod def expand_task(cls, task): props = bpy.context.scene.BIMWorkScheduleProperties @@ -410,20 +402,16 @@ class Sequence(blenderbim.core.tool.Sequence): @classmethod def load_task_resources(cls, task): - resources = cls.get_task_resources(task) props = bpy.context.scene.BIMWorkScheduleProperties + rprops = bpy.context.scene.BIMResourceProperties props.task_resources.clear() - bpy.context.scene.BIMResourceProperties.is_resource_update_enabled = False - for resource in resources or []: + rprops.is_resource_update_enabled = False + for resource in cls.get_task_resources(task) or []: new = props.task_resources.add() new.ifc_definition_id = resource.id() new.name = resource.Name or "Unnamed" new.schedule_usage = resource.Usage.ScheduleUsage or 0 if resource.Usage else 0 - bpy.context.scene.BIMResourceProperties.is_resource_update_enabled = True - - @classmethod - def load_resources(cls): - blenderbim.core.resource.load_resources(tool.Resource) + rprops.is_resource_update_enabled = True @classmethod def get_task_inputs(cls, task): @@ -449,6 +437,8 @@ class Sequence(blenderbim.core.tool.Sequence): @classmethod def get_task_resources(cls, task): + if not task: + return is_deep = bpy.context.scene.BIMWorkScheduleProperties.show_nested_resources return ifcopenshell.util.sequence.get_task_resources(task, is_deep) From 9665adb61d5b493746e77faf6bbaa69eb81b1c47 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 25 Sep 2023 08:34:37 +1000 Subject: [PATCH 160/190] Fix #3793. Bug where flipping a wall with generic connection relationships would fail. --- src/blenderbim/blenderbim/bim/module/model/wall.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/model/wall.py b/src/blenderbim/blenderbim/bim/module/model/wall.py index 06a7af965e..54b81214a0 100644 --- a/src/blenderbim/blenderbim/bim/module/model/wall.py +++ b/src/blenderbim/blenderbim/bim/module/model/wall.py @@ -828,10 +828,10 @@ class DumbWallJoiner: return for rel in element1.ConnectedTo: - if rel.RelatingConnectionType in ["ATSTART", "ATEND"]: + if rel.is_a("IfcRelConnectsPathElements") and rel.RelatingConnectionType in ["ATSTART", "ATEND"]: rel.RelatingConnectionType = "ATSTART" if rel.RelatingConnectionType == "ATEND" else "ATEND" for rel in element1.ConnectedFrom: - if rel.RelatedConnectionType in ["ATSTART", "ATEND"]: + if rel.is_a("IfcRelConnectsPathElements") and rel.RelatedConnectionType in ["ATSTART", "ATEND"]: rel.RelatedConnectionType = "ATSTART" if rel.RelatedConnectionType == "ATEND" else "ATEND" layers1 = tool.Model.get_material_layer_parameters(element1) From eee799bfc499128bf7d9fd6e1178aa9115fd520a Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 25 Sep 2023 19:24:01 +1000 Subject: [PATCH 161/190] Purge IfcCOBie deps from build --- src/blenderbim/Makefile | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/src/blenderbim/Makefile b/src/blenderbim/Makefile index 94f9df787e..08f291afa1 100644 --- a/src/blenderbim/Makefile +++ b/src/blenderbim/Makefile @@ -201,8 +201,6 @@ endif cp -r dist/working/IfcOpenShell-0.7.0/src/ifctester/ifctester dist/blenderbim/libs/site/packages/ # Provides IFCFM functionality cp -r dist/working/IfcOpenShell-0.7.0/src/ifcfm/ifcfm dist/blenderbim/libs/site/packages/ - # Provides IFCCOBie functionality - cp -r dist/working/IfcOpenShell-0.7.0/src/ifccobie/* dist/blenderbim/libs/site/packages/ # Provides bSDD functionality cp -r dist/working/IfcOpenShell-0.7.0/src/bsdd/* dist/blenderbim/libs/site/packages/ # Provides IFCDiff functionality @@ -596,14 +594,6 @@ endif cd dist/working/parse_type-0.5.2/ && cp -r parse_type ../../blenderbim/libs/site/packages/ rm -rf dist/working - # Required by IFCCOBie for XLSX support - # TODO: see if we can replace this with openpyxl which does both read/write - mkdir dist/working - cd dist/working && wget https://files.pythonhosted.org/packages/0c/bc/82d6783f83f65f56d8b77d052773c4a2f952fa86385f0cd54e1e006658d7/XlsxWriter-1.2.9.tar.gz - cd dist/working && tar -xzvf XlsxWriter* - cd dist/working/XlsxWriter-1.2.9/ && cp -r xlsxwriter ../../blenderbim/libs/site/packages/ - rm -rf dist/working - # Required by augin mkdir dist/working cd dist/working && wget https://files.pythonhosted.org/packages/76/b4/b7baffbda025efd5dc8fcd8d2e953e3aa939c236a484084fa8f4c3588ee9/boto3-1.17.17.tar.gz From 9f7dffb29de8bad7d65481cb46b90b626db599c9 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 25 Sep 2023 11:55:56 +0500 Subject: [PATCH 162/190] replace Drawing.remove_object_data with more general method --- src/blenderbim/blenderbim/tool/drawing.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/blenderbim/blenderbim/tool/drawing.py b/src/blenderbim/blenderbim/tool/drawing.py index 93fb1218fe..63af83cbaa 100644 --- a/src/blenderbim/blenderbim/tool/drawing.py +++ b/src/blenderbim/blenderbim/tool/drawing.py @@ -249,17 +249,7 @@ class Drawing(blenderbim.core.tool.Drawing): obj_data = obj.data bpy.data.objects.remove(obj) if obj_data and obj_data.users == 0: # in case we have drawing element types - cls.remove_object_data(obj_data) - - @classmethod - def remove_object_data(cls, data): - """also removes all related objects""" - if isinstance(data, bpy.types.Camera): - bpy.data.cameras.remove(data) - elif isinstance(data, bpy.types.Mesh): - bpy.data.meshes.remove(data) - elif isinstance(data, bpy.types.Curve): - bpy.data.curves.remove(data) + tool.Blender.remove_data_block(obj_data) @classmethod def delete_object(cls, obj): From 81a1bd92068e4f5297e28a4910a96772f80722d3 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 25 Sep 2023 14:38:42 +0500 Subject: [PATCH 163/190] Moved common duplication operators code to DuplicateMove Now OverrideDuplicateMoveLinked allways will be in sync with OverrideDuplicateMove. Also fixed the issue when DuplicateMoveLinked for non ifc elements worked the same was DuplicateMove (it was copying mesh data when it's not suppose to). --- .../bim/module/geometry/operator.py | 76 +++++++++++-------- src/blenderbim/blenderbim/tool/geometry.py | 24 ------ 2 files changed, 44 insertions(+), 56 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/geometry/operator.py b/src/blenderbim/blenderbim/bim/module/geometry/operator.py index 0c3304e59e..292936c230 100644 --- a/src/blenderbim/blenderbim/bim/module/geometry/operator.py +++ b/src/blenderbim/blenderbim/bim/module/geometry/operator.py @@ -713,13 +713,41 @@ class OverrideDuplicateMove(bpy.types.Operator): return len(context.selected_objects) > 0 def execute(self, context): - return tool.Geometry.duplicate_move_operator_execute(self, context, linked=False) + return OverrideDuplicateMove.execute_duplicate_operator(self, context, linked=False) def _execute(self, context): + return OverrideDuplicateMove.execute_ifc_duplicate_operator(self, context) + + @staticmethod + def execute_duplicate_operator(self, context, linked=False): + # Deep magick from the dawn of time + if IfcStore.get_file(): + IfcStore.execute_ifc_operator(self, context) + if self.new_active_obj: + context.view_layer.objects.active = self.new_active_obj + return {"FINISHED"} + + new_active_obj = None + for obj in context.selected_objects: + new_obj = obj.copy() + if linked and obj.data: + new_obj.data = obj.data.copy() + if obj == context.active_object: + new_active_obj = new_obj + for collection in obj.users_collection: + collection.objects.link(new_obj) + obj.select_set(False) + new_obj.select_set(True) + if new_active_obj: + context.view_layer.objects.active = new_active_obj + return {"FINISHED"} + + @staticmethod + def execute_ifc_duplicate_operator(self, context, linked=False): objects_to_duplicate = set(context.selected_objects) # handle arrays - arrays_to_duplicate, array_children = self.process_arrays(context) + arrays_to_duplicate, array_children = OverrideDuplicateMove.process_arrays(self, context) objects_to_duplicate -= array_children for child in array_children: child.select_set(False) @@ -734,22 +762,30 @@ class OverrideDuplicateMove(bpy.types.Operator): if element and element.is_a("IfcAnnotation") and element.ObjectType == "DRAWING": continue # For now, don't copy drawings until we stabilise a bit more. It's tricky. + linked_non_ifc_object = linked and not element + # Prior to duplicating, sync the object placement to make decomposition recreation more stable. if tool.Ifc.is_moved(obj): blenderbim.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj) new_obj = obj.copy() temp_data = None - if obj.data: + + if obj.data and not linked_non_ifc_object: # assure root.copy_class won't replace the previous mesh globally temp_data = obj.data.copy() new_obj.data = temp_data + if obj == context.active_object: self.new_active_obj = new_obj for collection in obj.users_collection: collection.objects.link(new_obj) obj.select_set(False) new_obj.select_set(True) + + if linked_non_ifc_object: + continue + # clear object's collection so it will be able to have it's own new_obj.BIMObjectProperties.collection = None # copy the actual class @@ -776,6 +812,7 @@ class OverrideDuplicateMove(bpy.types.Operator): tool.Root.recreate_decompositions(relationships, old_to_new) blenderbim.bim.handler.refresh_ui_data() + @staticmethod def process_arrays(self, context): selected_objects = set(context.selected_objects) array_parents = set() @@ -811,6 +848,7 @@ class OverrideDuplicateMove(bpy.types.Operator): return arrays_to_create, array_children + class OverrideDuplicateMoveLinkedMacro(bpy.types.Macro): bl_idname = "bim.override_object_duplicate_move_linked_macro" bl_label = "IFC Duplicate Linked" @@ -827,36 +865,10 @@ class OverrideDuplicateMoveLinked(bpy.types.Operator): return len(context.selected_objects) > 0 def execute(self, context): - return tool.Geometry.duplicate_move_operator_execute(self, context, linked=True) + return OverrideDuplicateMove.execute_duplicate_operator(self, context, linked=True) def _execute(self, context): - self.new_active_obj = None - # Track decompositions so they can be recreated after the operation - relationships = tool.Root.get_decomposition_relationships(context.selected_objects) - old_to_new = {} - for obj in context.selected_objects: - # Prior to duplicating, sync the object placement to make decomposition recreation more stable. - if tool.Ifc.is_moved(obj): - blenderbim.core.geometry.edit_object_placement(tool.Ifc, tool.Geometry, tool.Surveyor, obj=obj) - - new_obj = obj.copy() - if obj.data: - new_obj.data = obj.data.copy() - if obj == context.active_object: - self.new_active_obj = new_obj - for collection in obj.users_collection: - collection.objects.link(new_obj) - obj.select_set(False) - new_obj.select_set(True) - # Copy the actual class - new = blenderbim.core.root.copy_class(tool.Ifc, tool.Collector, tool.Geometry, tool.Root, obj=new_obj) - if new: - tool.Model.handle_array_on_copied_element(new) - old_to_new[tool.Ifc.get_entity(obj)] = new - # Recreate decompositions - tool.Root.recreate_decompositions(relationships, old_to_new) - blenderbim.bim.handler.refresh_ui_data() - return {"FINISHED"} + return OverrideDuplicateMove.execute_ifc_duplicate_operator(self, context, linked=True) class OverrideDuplicateMoveAggregateMacro(bpy.types.Macro): @@ -876,7 +888,7 @@ class OverrideDuplicateMoveAggregate(bpy.types.Operator): return len(context.selected_objects) > 0 def execute(self, context): - return tool.Geometry.duplicate_move_operator_execute(self, context, linked=False) + return OverrideDuplicateMove.execute_duplicate_operator(self, context, linked=False) def _execute(self, context): self.new_active_obj = None diff --git a/src/blenderbim/blenderbim/tool/geometry.py b/src/blenderbim/blenderbim/tool/geometry.py index 5860f369f2..8e624d61f0 100644 --- a/src/blenderbim/blenderbim/tool/geometry.py +++ b/src/blenderbim/blenderbim/tool/geometry.py @@ -641,27 +641,3 @@ class Geometry(blenderbim.core.tool.Geometry): @classmethod def get_model_representations(cls): return tool.Ifc.get().by_type("IfcShapeRepresentation") - - @classmethod - def duplicate_move_operator_execute(cls, operator, context, linked=False): - # Deep magick from the dawn of time - if IfcStore.get_file(): - IfcStore.execute_ifc_operator(operator, context) - if operator.new_active_obj: - context.view_layer.objects.active = operator.new_active_obj - return {"FINISHED"} - - new_active_obj = None - for obj in context.selected_objects: - new_obj = obj.copy() - if linked and obj.data: - new_obj.data = obj.data.copy() - if obj == context.active_object: - new_active_obj = new_obj - for collection in obj.users_collection: - collection.objects.link(new_obj) - obj.select_set(False) - new_obj.select_set(True) - if new_active_obj: - context.view_layer.objects.active = new_active_obj - return {"FINISHED"} From 8f6be60c6ef73a409e809680b6f3d77a6722adef Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 25 Sep 2023 15:16:07 +0500 Subject: [PATCH 164/190] recalculate fills on applying changes to ifc windows / ifc doors previously if you'd move the window and then change it's parametric data and try to apply it, opening's position wouldn't be updated --- src/blenderbim/blenderbim/bim/module/model/window.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/blenderbim/blenderbim/bim/module/model/window.py b/src/blenderbim/blenderbim/bim/module/model/window.py index fe934693ac..e432be6797 100644 --- a/src/blenderbim/blenderbim/bim/module/model/window.py +++ b/src/blenderbim/blenderbim/bim/module/model/window.py @@ -81,6 +81,8 @@ def update_simple_openings(element, opening_width, opening_height): has_replaced_opening_representation = True tool.Model.reload_body_representation(voided_objs) + with bpy.context.temp_override(selected_objects=[tool.Ifc.get_object(f) for f in fillings]): + bpy.ops.bim.recalculate_fill() def update_window_modifier_representation(context, obj): From 9c1ecf3f2c55e19d721ebf2015ae3f4c7997f997 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 25 Sep 2023 15:24:11 +0500 Subject: [PATCH 165/190] draw calculate_all_quantities hotkey in bim tool ui hotkey was already there, it just wasn't added to UI https://i.imgur.com/nar5WYI.png --- src/blenderbim/blenderbim/bim/module/model/workspace.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/blenderbim/blenderbim/bim/module/model/workspace.py b/src/blenderbim/blenderbim/bim/module/model/workspace.py index fa2f33090d..6af2600dee 100644 --- a/src/blenderbim/blenderbim/bim/module/model/workspace.py +++ b/src/blenderbim/blenderbim/bim/module/model/workspace.py @@ -434,6 +434,11 @@ class BimToolUI: add_layout_hotkey_operator(cls.layout, "Void", "A_O", "Toggle openings") add_layout_hotkey_operator(cls.layout, "Decomposition", "A_D", "Select decomposition") + cls.layout.separator() + add_layout_hotkey_operator( + cls.layout, "Calculate All Quantities", "S_Q", bpy.ops.bim.calculate_all_quantities.__doc__ + ) + @classmethod def draw_header_interface(cls): cls.draw_type_selection_interface() From 82d03b98c6d2c8be0c16480811fda5a705db6e6b Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Mon, 25 Sep 2023 16:06:22 +0500 Subject: [PATCH 166/190] Descriptions for add_representation conversion methods --- .../blenderbim/bim/module/geometry/operator.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/geometry/operator.py b/src/blenderbim/blenderbim/bim/module/geometry/operator.py index 292936c230..dcf63827b9 100644 --- a/src/blenderbim/blenderbim/bim/module/geometry/operator.py +++ b/src/blenderbim/blenderbim/bim/module/geometry/operator.py @@ -121,8 +121,15 @@ class AddRepresentation(bpy.types.Operator, Operator): bl_options = {"REGISTER", "UNDO"} representation_conversion_method: bpy.props.EnumProperty( items=[ - ("OUTLINE", "Trace Outline", ""), - ("BOX", "Bounding Box", ""), + ("OUTLINE", "Trace Outline", "Traces outline by local XY axes, for Profile - by local XZ axes."), + ( + "BOX", + "Bounding Box", + "Creates a bounding box representation.\n" + "For Plan context - 2D bounding box by local XY axes,\n" + "for Profile - 2D bounding box by local XZ axes.\n" + "For other contexts - bounding box is 3d.", + ), ("PROJECT", "Full Representation", ""), ], name="Representation Conversion Method", From e4bfb05875925b9e2ac49a445357e9eb21484275 Mon Sep 17 00:00:00 2001 From: Ryan Schultz Date: Mon, 25 Sep 2023 10:32:06 -0500 Subject: [PATCH 167/190] proposed fix for #3792 --- src/blenderbim/blenderbim/bim/module/drawing/helper.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/drawing/helper.py b/src/blenderbim/blenderbim/bim/module/drawing/helper.py index 95bd35b77b..56a7887d27 100644 --- a/src/blenderbim/blenderbim/bim/module/drawing/helper.py +++ b/src/blenderbim/blenderbim/bim/module/drawing/helper.py @@ -180,10 +180,6 @@ def format_distance( frac = 0 inches += 1 - # Check values and compose string - if inches == 12: - feet += 1 - inches = 0 if not isArea: add_inches = bool(inches) or not suppress_zero_inches From 2661999f3ee4fac4516a761d5fdb655228e82939 Mon Sep 17 00:00:00 2001 From: Claudio Benghi Date: Tue, 26 Sep 2023 02:50:00 +0200 Subject: [PATCH 168/190] Fixed most issues with IDS auditing --- src/ifctester/ifctester/facet.py | 8 +- src/ifctester/test/ids_doc_generator.py | 4 +- src/ifctester/test/test_facet.py | 131 ++++++++++++------------ src/ifctester/test/test_ids.py | 2 +- 4 files changed, 75 insertions(+), 70 deletions(-) diff --git a/src/ifctester/ifctester/facet.py b/src/ifctester/ifctester/facet.py index 791223a11b..5e20f84524 100644 --- a/src/ifctester/ifctester/facet.py +++ b/src/ifctester/ifctester/facet.py @@ -286,7 +286,7 @@ class Attribute(Facet): class Classification(Facet): - def __init__(self, value=None, system=None, uri=None, minOccurs=None, maxOccurs=None, instructions=None): + def __init__(self, value=None, system=None, uri=None, minOccurs=None, maxOccurs="unbounded", instructions=None): self.parameters = ["value", "system", "@uri", "@minOccurs", "@maxOccurs", "@instructions"] self.applicability_templates = [ "Data having a {system} reference of {value}", @@ -347,7 +347,7 @@ class PartOf(Facet): predefinedType=None, relation=None, minOccurs=None, - maxOccurs=None, + maxOccurs="unbounded", instructions=None, ): self.parameters = ["entity", "predefinedType", "@relation", "@minOccurs", "@maxOccurs", "@instructions"] @@ -552,7 +552,7 @@ class Property(Facet): datatype=None, uri=None, minOccurs=None, - maxOccurs=None, + maxOccurs="unbounded", instructions=None, ): self.parameters = [ @@ -824,7 +824,7 @@ class Property(Facet): class Material(Facet): - def __init__(self, value=None, uri=None, minOccurs=None, maxOccurs=None, instructions=None): + def __init__(self, value=None, uri=None, minOccurs=None, maxOccurs="unbounded", instructions=None): self.parameters = ["value", "@uri", "@minOccurs", "@maxOccurs", "@instructions"] self.applicability_templates = [ "All data with a {value} material", diff --git a/src/ifctester/test/ids_doc_generator.py b/src/ifctester/test/ids_doc_generator.py index e6b4d73532..0b0f593576 100644 --- a/src/ifctester/test/ids_doc_generator.py +++ b/src/ifctester/test/ids_doc_generator.py @@ -81,7 +81,9 @@ class FacetDocGenerator: # Create an IDS with the applicability selecting exactly # the entity type passed to us in `inst`. specs = ids.Ids(title=name) - spec = ids.Specification(name=name, minOccurs=1) + + # todo: to resume IFC2X3 we need to ensure that entities and attributes are consistent with that schema in order to pass audit + spec = ids.Specification(name=name, minOccurs=1, ifcVersion=["IFC4"]) spec.applicability.append(ids.Entity(name=inst.is_a().upper())) spec.requirements.append(facet) specs.specifications.append(spec) diff --git a/src/ifctester/test/test_facet.py b/src/ifctester/test/test_facet.py index 5455a816b3..a495fa2554 100644 --- a/src/ifctester/test/test_facet.py +++ b/src/ifctester/test/test_facet.py @@ -667,9 +667,11 @@ class TestAttribute: class TestClassification: def test_creating_a_classification_facet(self): facet = Classification() - assert facet.asdict() == {} + assert facet.asdict() == { + "@maxOccurs": "unbounded" + } facet = Classification(value="value", system="system") - assert facet.asdict() == {"value": {"simpleValue": "value"}, "system": {"simpleValue": "system"}} + assert facet.asdict() == {"value": {"simpleValue": "value"}, "system": {"simpleValue": "system"}, "@maxOccurs": "unbounded" } facet = Classification( value="value", system="system", @@ -834,6 +836,7 @@ class TestProperty: assert facet.asdict() == { "propertySet": {"simpleValue": "Property_Set"}, "name": {"simpleValue": "PropertyName"}, + "@maxOccurs": "unbounded" } facet = Property( propertySet="propertySet", @@ -861,7 +864,7 @@ class TestProperty: ifc = self.setup_ifc() - facet = Property(propertySet="Foo_Bar", name="Foo", datatype="IfcLabel") + facet = Property(propertySet="Foo_Bar", name="Foo", datatype="IFCLABEL") element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") run("Elements with no properties always fail", facet=facet, inst=element, expected=False) pset = ifcopenshell.api.run("pset.add_pset", ifc, product=element, name="Foo_Bar") @@ -873,27 +876,27 @@ class TestProperty: run("A name check will match any property with any string value", facet=facet, inst=element, expected=True) ifc = self.setup_ifc() - facet = Property(propertySet="Foo_Bar", name="Foo", datatype="IfcLabel") + facet = Property(propertySet="Foo_Bar", name="Foo", datatype="IFCLABEL") element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") pset = ifcopenshell.api.run("pset.add_pset", ifc, product=element, name="Foo_Bar") ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": "Bar"}) run("A required facet checks all parameters as normal", facet=facet, inst=element, expected=True) - facet = Property(propertySet="Foo_Bar", name="Foo", datatype="IfcLabel", minOccurs=0, maxOccurs=0) + facet = Property(propertySet="Foo_Bar", name="Foo", datatype="IFCLABEL", minOccurs=0, maxOccurs=0) run("A prohibited facet returns the opposite of a required facet", facet=facet, inst=element, expected=False) - facet = Property(propertySet="Foo_Bar", name="Foo", datatype="IfcLabel", minOccurs=0) + facet = Property(propertySet="Foo_Bar", name="Foo", datatype="IFCLABEL", minOccurs=0) run("An optional facet always passes regardless of outcome 1/2", facet=facet, inst=element, expected=True) - facet = Property(propertySet="Foo_Bar", name="Bar", datatype="IfcLabel", minOccurs=0) + facet = Property(propertySet="Foo_Bar", name="Bar", datatype="IFCLABEL", minOccurs=0) run("An optional facet always passes regardless of outcome 2/2", facet=facet, inst=element, expected=True) ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": ""}) - facet = Property(propertySet="Foo_Bar", name="Foo", datatype="IfcLogical") + facet = Property(propertySet="Foo_Bar", name="Foo", datatype="IFCLOGICAL") run("An empty string is considered falsey and will not pass", facet=facet, inst=element, expected=False) ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": ifc.createIfcLogical("UNKNOWN")}) - facet = Property(propertySet="Foo_Bar", name="Foo", datatype="IfcDuration") + facet = Property(propertySet="Foo_Bar", name="Foo", datatype="IFCDURATION") run("A logical unknown is considered falsey and will not pass", facet=facet, inst=element, expected=False) ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": ifc.createIfcDuration("P0D")}) run("A zero duration will pass", facet=facet, inst=element, expected=True) - facet = Property(propertySet="Foo_Bar", name="Foo", datatype="IfcBoolean") + facet = Property(propertySet="Foo_Bar", name="Foo", datatype="IFCBOOLEAN") ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": ifc.createIfcBoolean(True)}) run("A property set to true will pass a name check", facet=facet, inst=element, expected=True) ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": False}) @@ -905,7 +908,7 @@ class TestProperty: ) ifc = self.setup_ifc() - facet = Property(propertySet="Foo_Bar", name="Foo", value="Bar", datatype="IfcLabel") + facet = Property(propertySet="Foo_Bar", name="Foo", value="Bar", datatype="IFCLABEL") element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") pset = ifcopenshell.api.run("pset.add_pset", ifc, product=element, name="Foo_Bar") ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": "Bar"}) @@ -915,55 +918,55 @@ class TestProperty: ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": "Baz"}) run("Specifying a value fails against different values", facet=facet, inst=element, expected=False) - facet = Property(propertySet="Foo_Bar", name="Foo", value="♫Don'tÄrgerhôtelЊет", datatype="IfcLabel") + facet = Property(propertySet="Foo_Bar", name="Foo", value="♫Don'tÄrgerhôtelЊет", datatype="IFCLABEL") ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": "♫Don'tÄrgerhôtelЊет"}) run("Non-ascii characters are treated without encoding", facet=facet, inst=element, expected=True) identifier = "123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345" facet = Property( - propertySet="Foo_Bar", name="Foo", value=identifier + "_extra_characters", datatype="IfcIdentifier" + propertySet="Foo_Bar", name="Foo", value=identifier + "_extra_characters", datatype="IFCIDENTIFIER" ) ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": ifc.createIfcIdentifier(identifier)}) run("IDS does not handle string truncation such as for identifiers", facet=facet, inst=element, expected=False) ifc = self.setup_ifc() - facet = Property(propertySet="Foo_Bar", name="Foo", value="1", datatype="IfcLabel") + facet = Property(propertySet="Foo_Bar", name="Foo", value="1", datatype="IFCLABEL") element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") pset = ifcopenshell.api.run("pset.add_pset", ifc, product=element, name="Foo_Bar") ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": "1"}) run("A number specified as a string is treated as a string", facet=facet, inst=element, expected=True) - facet = Property(propertySet="Foo_Bar", name="Foo", value="42", datatype="IfcInteger") + facet = Property(propertySet="Foo_Bar", name="Foo", value="42", datatype="IFCINTEGER") ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": ifc.createIfcInteger(42)}) run("Integer values are checked using type casting 1/4", facet=facet, inst=element, expected=True) - facet = Property(propertySet="Foo_Bar", name="Foo", value="42.", datatype="IfcInteger") + facet = Property(propertySet="Foo_Bar", name="Foo", value="42.", datatype="IFCINTEGER") run("Integer values are checked using type casting 2/4", facet=facet, inst=element, expected=True) - facet = Property(propertySet="Foo_Bar", name="Foo", value="42.0", datatype="IfcInteger") + facet = Property(propertySet="Foo_Bar", name="Foo", value="42.0", datatype="IFCINTEGER") run("Integer values are checked using type casting 3/4", facet=facet, inst=element, expected=True) - facet = Property(propertySet="Foo_Bar", name="Foo", value="42.3", datatype="IfcInteger") + facet = Property(propertySet="Foo_Bar", name="Foo", value="42.3", datatype="IFCINTEGER") run("Integer values are checked using type casting 4/4", facet=facet, inst=element, expected=False) - facet = Property(propertySet="Foo_Bar", name="Foo", value="42", datatype="IfcReal") + facet = Property(propertySet="Foo_Bar", name="Foo", value="42", datatype="IFCREAL") ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": ifc.createIfcReal(42.0)}) run("Real values are checked using type casting 1/3", facet=facet, inst=element, expected=True) - facet = Property(propertySet="Foo_Bar", name="Foo", value="42.0", datatype="IfcReal") + facet = Property(propertySet="Foo_Bar", name="Foo", value="42.0", datatype="IFCREAL") run("Real values are checked using type casting 2/3", facet=facet, inst=element, expected=True) - facet = Property(propertySet="Foo_Bar", name="Foo", value="42.3", datatype="IfcReal") + facet = Property(propertySet="Foo_Bar", name="Foo", value="42.3", datatype="IFCREAL") ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": ifc.createIfcReal(42.3)}) run("Real values are checked using type casting 3/3", facet=facet, inst=element, expected=True) - facet = Property(propertySet="Foo_Bar", name="Foo", value="42,3", datatype="IfcReal") + facet = Property(propertySet="Foo_Bar", name="Foo", value="42,3", datatype="IFCREAL") ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": ifc.createIfcReal(42.3)}) run("Only specifically formatted numbers are allowed 1/4", facet=facet, inst=element, expected=False) - facet = Property(propertySet="Foo_Bar", name="Foo", value="123,4.5", datatype="IfcReal") + facet = Property(propertySet="Foo_Bar", name="Foo", value="123,4.5", datatype="IFCREAL") ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": ifc.createIfcReal(1234.5)}) run("Only specifically formatted numbers are allowed 2/4", facet=facet, inst=element, expected=False) - facet = Property(propertySet="Foo_Bar", name="Foo", value="1.2345e3", datatype="IfcReal") + facet = Property(propertySet="Foo_Bar", name="Foo", value="1.2345e3", datatype="IFCREAL") run("Only specifically formatted numbers are allowed 3/4", facet=facet, inst=element, expected=True) - facet = Property(propertySet="Foo_Bar", name="Foo", value="1.2345E3", datatype="IfcReal") + facet = Property(propertySet="Foo_Bar", name="Foo", value="1.2345E3", datatype="IFCREAL") run("Only specifically formatted numbers are allowed 4/4", facet=facet, inst=element, expected=True) - facet = Property(propertySet="Foo_Bar", name="Foo", value="42.", datatype="IfcReal") + facet = Property(propertySet="Foo_Bar", name="Foo", value="42.", datatype="IFCREAL") ifcopenshell.api.run( "pset.edit_pset", ifc, pset=pset, properties={"Foo": ifc.createIfcReal(42.0 * (1.0 + 1e-6))} ) @@ -981,15 +984,15 @@ class TestProperty: ) run("Floating point numbers are compared with a 1e-6 tolerance 4/4", facet=facet, inst=element, expected=False) - facet = Property(propertySet="Foo_Bar", name="Foo", value="TRUE", datatype="IfcBoolean") + facet = Property(propertySet="Foo_Bar", name="Foo", value="TRUE", datatype="IFCBOOLEAN") ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": ifc.createIfcBoolean(False)}) run("Booleans must be specified as uppercase strings 1/3", facet=facet, inst=element, expected=False) - facet = Property(propertySet="Foo_Bar", name="Foo", value="FALSE", datatype="IfcBoolean") + facet = Property(propertySet="Foo_Bar", name="Foo", value="FALSE", datatype="IFCBOOLEAN") run("Booleans must be specified as uppercase strings 2/3", facet=facet, inst=element, expected=True) - facet = Property(propertySet="Foo_Bar", name="Foo", value="False", datatype="IfcBoolean") + facet = Property(propertySet="Foo_Bar", name="Foo", value="False", datatype="IFCBOOLEAN") run("Booleans must be specified as uppercase strings 3/3", facet=facet, inst=element, expected=False) - facet = Property(propertySet="Foo_Bar", name="Foo", value="2022-01-01", datatype="IfcDate") + facet = Property(propertySet="Foo_Bar", name="Foo", value="2022-01-01", datatype="IFCDATE") ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": ifc.createIfcDate("2022-01-01")}) run("Dates are treated as strings 1/2", facet=facet, inst=element, expected=True) ifcopenshell.api.run( @@ -997,7 +1000,7 @@ class TestProperty: ) run("Dates are treated as strings 2/2", facet=facet, inst=element, expected=False) - facet = Property(propertySet="Foo_Bar", name="Foo", value="PT16H", datatype="IfcDuration") + facet = Property(propertySet="Foo_Bar", name="Foo", value="PT16H", datatype="IFCDURATION") ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": ifc.createIfcDuration("PT16H")}) run("Durations are treated as strings 1/2", facet=facet, inst=element, expected=True) ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": ifc.createIfcDuration("P2D")}) @@ -1014,11 +1017,11 @@ class TestProperty: properties={"Status": ["EXISTING", "DEMOLISH"]}, pset_template=pset_template, ) - facet = Property(propertySet="Pset_WallCommon", name="Status", value="EXISTING", datatype="IfcLabel") + facet = Property(propertySet="Pset_WallCommon", name="Status", value="EXISTING", datatype="IFCLABEL") run("Any matching value in an enumerated property will pass 1/3", facet=facet, inst=element, expected=True) - facet = Property(propertySet="Pset_WallCommon", name="Status", value="DEMOLISH", datatype="IfcLabel") + facet = Property(propertySet="Pset_WallCommon", name="Status", value="DEMOLISH", datatype="IFCLABEL") run("Any matching value in an enumerated property will pass 2/3", facet=facet, inst=element, expected=True) - facet = Property(propertySet="Pset_WallCommon", name="Status", value="NEW", datatype="IfcLabel") + facet = Property(propertySet="Pset_WallCommon", name="Status", value="NEW", datatype="IFCLABEL") run("Any matching value in an enumerated property will pass 3/3", facet=facet, inst=element, expected=False) ifc = self.setup_ifc() @@ -1028,11 +1031,11 @@ class TestProperty: Name="Foo", ListValues=[ifc.createIfcLabel("X"), ifc.createIfcLabel("Y")] ) pset.HasProperties = [list_property] - facet = Property(propertySet="Foo_Bar", name="Foo", value="X", datatype="IfcLabel") + facet = Property(propertySet="Foo_Bar", name="Foo", value="X", datatype="IFCLABEL") run("Any matching value in a list property will pass 1/3", facet=facet, inst=element, expected=True) - facet = Property(propertySet="Foo_Bar", name="Foo", value="Y", datatype="IfcLabel") + facet = Property(propertySet="Foo_Bar", name="Foo", value="Y", datatype="IFCLABEL") run("Any matching value in a list property will pass 2/3", facet=facet, inst=element, expected=True) - facet = Property(propertySet="Foo_Bar", name="Foo", value="Z", datatype="IfcLabel") + facet = Property(propertySet="Foo_Bar", name="Foo", value="Z", datatype="IFCLABEL") run("Any matching value in a list property will pass 3/3", facet=facet, inst=element, expected=False) ifc = self.setup_ifc() @@ -1045,13 +1048,13 @@ class TestProperty: SetPointValue=ifc.createIfcLengthMeasure(3000), ) pset.HasProperties = [bounded_property] - facet = Property(propertySet="Foo_Bar", name="Foo", value="1", datatype="IfcLengthMeasure") + facet = Property(propertySet="Foo_Bar", name="Foo", value="1", datatype="IFCLENGTHMEASURE") run("Any matching value in a bounded property will pass 1/4", facet=facet, inst=element, expected=True) - facet = Property(propertySet="Foo_Bar", name="Foo", value="5", datatype="IfcLengthMeasure") + facet = Property(propertySet="Foo_Bar", name="Foo", value="5", datatype="IFCLENGTHMEASURE") run("Any matching value in a bounded property will pass 2/4", facet=facet, inst=element, expected=True) - facet = Property(propertySet="Foo_Bar", name="Foo", value="3", datatype="IfcLengthMeasure") + facet = Property(propertySet="Foo_Bar", name="Foo", value="3", datatype="IFCLENGTHMEASURE") run("Any matching value in a bounded property will pass 3/4", facet=facet, inst=element, expected=True) - facet = Property(propertySet="Foo_Bar", name="Foo", value="2", datatype="IfcLengthMeasure") + facet = Property(propertySet="Foo_Bar", name="Foo", value="2", datatype="IFCLENGTHMEASURE") run("Any matching value in a bounded property will pass 4/4", facet=facet, inst=element, expected=False) ifc = self.setup_ifc() @@ -1061,18 +1064,18 @@ class TestProperty: Name="Foo", DefiningValues=[ifc.createIfcLabel("X")], DefinedValues=[ifc.createIfcLengthMeasure(1000)] ) pset.HasProperties = [table_property] - facet = Property(propertySet="Foo_Bar", name="Foo", value="X", datatype="IfcLabel") + facet = Property(propertySet="Foo_Bar", name="Foo", value="X", datatype="IFCLABEL") run("Any matching value in a table property will pass 1/3", facet=facet, inst=element, expected=True) - facet = Property(propertySet="Foo_Bar", name="Foo", value="1", datatype="IfcLengthMeasure") + facet = Property(propertySet="Foo_Bar", name="Foo", value="1", datatype="IFCLENGTHMEASURE") run("Any matching value in a table property will pass 2/3", facet=facet, inst=element, expected=True) - facet = Property(propertySet="Foo_Bar", name="Foo", value="Y", datatype="IfcLabel") + facet = Property(propertySet="Foo_Bar", name="Foo", value="Y", datatype="IFCLABEL") run("Any matching value in a table property will pass 3/3", facet=facet, inst=element, expected=False) ifc = self.setup_ifc() element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") pset = ifcopenshell.api.run("pset.add_pset", ifc, product=element, name="Foo_Bar") pset.HasProperties = [ifc.createIfcPropertyReferenceValue(Name="Foo")] - facet = Property(propertySet="Foo_Bar", name="Foo", datatype="IfcLabel") + facet = Property(propertySet="Foo_Bar", name="Foo", datatype="IFCLABEL") run("Reference properties are treated as objects and not supported", facet=facet, inst=element, expected=False) ifc = self.setup_ifc() @@ -1091,21 +1094,21 @@ class TestProperty: RelatingPropertyDefinition=pset, ) facet = Property( - propertySet="Foo_Bar", name="PanelOperation", value="SWINGING", datatype="IfcDoorPanelOperationEnum" + propertySet="Foo_Bar", name="PanelOperation", value="SWINGING", datatype="IFCDOORPANELOPERATIONENUM" ) run("Predefined properties are supported but discouraged 1/2", facet=facet, inst=element, expected=True) facet = Property( - propertySet="Foo_Bar", name="PanelOperation", value="SWONGING", datatype="IfcDoorPanelOperationEnum" + propertySet="Foo_Bar", name="PanelOperation", value="SWONGING", datatype="IFCDOORPANELOPERATIONENUM" ) run("Predefined properties are supported but discouraged 2/2", facet=facet, inst=element, expected=False) ifc = self.setup_ifc() - facet = Property(propertySet="Foo_Bar", name="Foo", datatype="IfcLengthMeasure") + facet = Property(propertySet="Foo_Bar", name="Foo", datatype="IFCLENGTHMEASURE") element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") qto = ifcopenshell.api.run("pset.add_qto", ifc, product=element, name="Foo_Bar") ifcopenshell.api.run("pset.edit_qto", ifc, qto=qto, properties={"Foo": ifc.createIfcLengthMeasure(42)}) run("A name check will match any quantity with any value", facet=facet, inst=element, expected=True) - facet = Property(propertySet="Foo_Bar", name="Foo", datatype="IfcAreaMeasure") + facet = Property(propertySet="Foo_Bar", name="Foo", datatype="IFCAREAMEASURE") run("Quantities must also match the appropriate measure", facet=facet, inst=element, expected=False) ifc = self.setup_ifc() @@ -1114,9 +1117,9 @@ class TestProperty: complex_property = ifc.createIfcComplexProperty(Name="Foo", UsageName="RabbitAgilityTraining") ifcopenshell.api.run("pset.edit_pset", ifc, pset=complex_property, properties={"Rabbits": "Awesome"}) pset.HasProperties = [complex_property] - facet = Property(propertySet="Foo_Bar", name="Foo", datatype="IfcLabel") + facet = Property(propertySet="Foo_Bar", name="Foo", datatype="IFCLABEL") run("Complex properties are not supported 1/2", facet=facet, inst=element, expected=False) - facet = Property(propertySet="Foo", name="Rabbits", datatype="IfcLabel") + facet = Property(propertySet="Foo", name="Rabbits", datatype="IFCLABEL") run("Complex properties are not supported 2/2", facet=facet, inst=element, expected=False) ifc = self.setup_ifc() @@ -1127,14 +1130,14 @@ class TestProperty: "pset.edit_qto", ifc, qto=complex_quantity, properties={"MyLength": ifc.createIfcLengthMeasure(42)} ) qto.Quantities = [complex_quantity] - facet = Property(propertySet="Foo_Bar", name="Foo", datatype="IfcLengthMeasure") + facet = Property(propertySet="Foo_Bar", name="Foo", datatype="IFCLENGTHMEASURE") run("Complex properties are not supported 1/2", facet=facet, inst=element, expected=False) - facet = Property(propertySet="Foo", name="MyLength", datatype="IfcLengthMeasure") + facet = Property(propertySet="Foo", name="MyLength", datatype="IFCLENGTHMEASURE") run("Complex properties are not supported 2/2", facet=facet, inst=element, expected=False) ifc = self.setup_ifc() restriction = Restriction(options={"pattern": "Foo_.*"}) - facet = Property(propertySet=restriction, name="Foo", datatype="IfcLabel") + facet = Property(propertySet=restriction, name="Foo", datatype="IFCLABEL") element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") pset = ifcopenshell.api.run("pset.add_pset", ifc, product=element, name="Foo_Bar") ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": "Bar"}) @@ -1147,7 +1150,7 @@ class TestProperty: ifc = self.setup_ifc() restriction = Restriction(options={"pattern": "Foo.*"}) - facet = Property(propertySet="Foo_Bar", name=restriction, value="x", datatype="IfcLabel") + facet = Property(propertySet="Foo_Bar", name=restriction, value="x", datatype="IFCLABEL") element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") pset = ifcopenshell.api.run("pset.add_pset", ifc, product=element, name="Foo_Bar") ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foobar": "x"}) @@ -1160,7 +1163,7 @@ class TestProperty: ifc = self.setup_ifc() restriction1 = Restriction(options={"pattern": "Foo.*"}) restriction2 = Restriction(options={"enumeration": ["x", "y"]}) - facet = Property(propertySet="Foo_Bar", name=restriction1, value=restriction2, datatype="IfcLabel") + facet = Property(propertySet="Foo_Bar", name=restriction1, value=restriction2, datatype="IFCLABEL") element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") pset = ifcopenshell.api.run("pset.add_pset", ifc, product=element, name="Foo_Bar") ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foobar": "x", "Foobaz": "y"}) @@ -1179,7 +1182,7 @@ class TestProperty: ) ifc = self.setup_ifc() - facet = Property(propertySet="Foo_Bar", name="Foo", value="2", datatype="IfcTimeMeasure") + facet = Property(propertySet="Foo_Bar", name="Foo", value="2", datatype="IFCTIMEMEASURE") element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") pset = ifcopenshell.api.run("pset.add_pset", ifc, product=element, name="Foo_Bar") ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": ifc.createIfcMassMeasure(2)}) @@ -1188,7 +1191,7 @@ class TestProperty: run("Measures are used to specify an IFC data type 2/2", facet=facet, inst=element, expected=True) ifc = self.setup_ifc() - facet = Property(propertySet="Foo_Bar", name="Foo", value="2", datatype="IfcLengthMeasure") + facet = Property(propertySet="Foo_Bar", name="Foo", value="2", datatype="IFCLENGTHMEASURE") element = ifcopenshell.api.run("root.create_entity", ifc, ifc_class="IfcWall") pset = ifcopenshell.api.run("pset.add_pset", ifc, product=element, name="Foo_Bar") ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": ifc.createIfcLengthMeasure(2)}) @@ -1212,7 +1215,7 @@ class TestProperty: ifcopenshell.api.run("type.assign_type", ifc, related_object=wall, relating_type=wall_type) pset = ifcopenshell.api.run("pset.add_pset", ifc, product=wall_type, name="Foo_Bar") ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": "Bar"}) - facet = Property(propertySet="Foo_Bar", name="Foo", datatype="IfcLabel") + facet = Property(propertySet="Foo_Bar", name="Foo", datatype="IFCLABEL") run("Properties can be inherited from the type 1/2", facet=facet, inst=wall, expected=True) run("Properties can be inherited from the type 2/2", facet=facet, inst=wall_type, expected=True) @@ -1224,7 +1227,7 @@ class TestProperty: ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": "Baz"}) pset = ifcopenshell.api.run("pset.add_pset", ifc, product=wall, name="Foo_Bar") ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": "Bar"}) - facet = Property(propertySet="Foo_Bar", name="Foo", value="Bar", datatype="IfcLabel") + facet = Property(propertySet="Foo_Bar", name="Foo", value="Bar", datatype="IFCLABEL") run("Properties can be overriden by an occurrence 1/2", facet=facet, inst=wall, expected=True) run("Properties can be overriden by an occurrence 2/2", facet=facet, inst=wall_type, expected=False) @@ -1243,7 +1246,7 @@ class TestProperty: class TestMaterial: def test_creating_a_material_facet(self): facet = Material() - assert facet.asdict() == {} + assert facet.asdict() == {"@maxOccurs": "unbounded"} facet = Material( value="value", uri="https://test.com", minOccurs="0", maxOccurs="unbounded", instructions="instructions" ) @@ -1401,9 +1404,9 @@ class TestMaterial: class TestPartOf: def test_creating_a_partof_facet(self): facet = PartOf() - assert facet.asdict() == {"entity": {"name": {"simpleValue": "IFCWALL"}}} + assert facet.asdict() == {"entity": {"name": {"simpleValue": "IFCWALL"}}, "@maxOccurs": "unbounded" } facet = PartOf( - entity="IfcGroup", + entity="IFCGROUP", predefinedType="predefinedType", relation="IFCRELASSIGNSTOGROUP", minOccurs="0", @@ -1412,7 +1415,7 @@ class TestPartOf: ) assert facet.asdict() == { "entity": { - "name": {"simpleValue": "IfcGroup"}, + "name": {"simpleValue": "IFCGROUP"}, "predefinedType": {"simpleValue": "predefinedType"}, }, "@relation": "IFCRELASSIGNSTOGROUP", diff --git a/src/ifctester/test/test_ids.py b/src/ifctester/test/test_ids.py index 10f2cb2beb..ea2b63dd55 100644 --- a/src/ifctester/test/test_ids.py +++ b/src/ifctester/test/test_ids.py @@ -159,7 +159,7 @@ class TestIds: run("Prohibited specifications fail if at least one entity passes all requirements 1/3", specs, model, True) model = ifcopenshell.file() wall = model.createIfcWall(Name="Wally") - run("Prohibited specifications fail if at least one entity passes all requirements 2/3", specs, model, True, [wall], [wall]) + run("Prohibited specifications fail if at least one entity passes all requirements 2/3", specs, model, False, [wall], [wall]) model = ifcopenshell.file() wall = model.createIfcWall(Name="Waldo") run("Prohibited specifications fail if at least one entity passes all requirements 3/3", specs, model, False, [wall]) From 8e59282fe062d18ca1c9ded6c83abc0f6f68204b Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Tue, 26 Sep 2023 16:02:59 +1000 Subject: [PATCH 169/190] Possible optimisation by caching in IfcTester property checks --- src/ifctester/ifctester/facet.py | 15 +++++++++++++-- src/ifctester/ifctester/ids.py | 4 +++- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/ifctester/ifctester/facet.py b/src/ifctester/ifctester/facet.py index 5e20f84524..d10664fee2 100644 --- a/src/ifctester/ifctester/facet.py +++ b/src/ifctester/ifctester/facet.py @@ -21,6 +21,7 @@ import builtins import ifcopenshell.util.unit import ifcopenshell.util.element import ifcopenshell.util.classification +from functools import lru_cache from xmlschema.validators import identities @@ -41,6 +42,16 @@ def cast_to_value(from_value, to_value): pass +@lru_cache +def get_pset(element, pset): + return ifcopenshell.util.element.get_pset(element, pset) + + +@lru_cache +def get_psets(element, pset): + return ifcopenshell.util.element.get_psets(element) + + class Facet: def __init__(self, *parameters): self.status = None @@ -591,10 +602,10 @@ class Property(Facet): return PropertyResult(True) if isinstance(self.propertySet, str): - pset = ifcopenshell.util.element.get_pset(inst, self.propertySet) + pset = get_pset(inst, self.propertySet) psets = {self.propertySet: pset} if pset else {} else: - all_psets = ifcopenshell.util.element.get_psets(inst) + all_psets = get_psets(inst) psets = {k: v for k, v in all_psets.items() if k == self.propertySet} is_pass = bool(psets) diff --git a/src/ifctester/ifctester/ids.py b/src/ifctester/ifctester/ids.py index c4766259bb..e57eca7048 100644 --- a/src/ifctester/ifctester/ids.py +++ b/src/ifctester/ifctester/ids.py @@ -21,7 +21,7 @@ import datetime from xmlschema import XMLSchema from xmlschema import etree_tostring from xml.etree import ElementTree as ET -from .facet import Entity, Attribute, Classification, Property, PartOf, Material, Restriction +from .facet import Entity, Attribute, Classification, Property, PartOf, Material, Restriction, get_pset, get_psets cwd = os.path.dirname(os.path.realpath(__file__)) @@ -113,6 +113,8 @@ class Ids: return get_schema().is_valid(filepath) def validate(self, ifc_file, filter_version=False): + get_pset.cache_clear() + get_psets.cache_clear() for specification in self.specifications: specification.reset_status() specification.validate(ifc_file, filter_version=filter_version) From 779fc352282bd737a308bf870eb08b0364c6ae7a Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Thu, 21 Sep 2023 16:21:43 +0500 Subject: [PATCH 170/190] settings flow direction without jumping to ports https://imgur.com/a/F00tG7g --- .../blenderbim/bim/module/system/data.py | 20 +++--- .../blenderbim/bim/module/system/operator.py | 63 ++++++++++++++++--- .../blenderbim/bim/module/system/ui.py | 28 ++++++--- 3 files changed, 88 insertions(+), 23 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/system/data.py b/src/blenderbim/blenderbim/bim/module/system/data.py index 9dd348fe13..3ba63ae1f7 100644 --- a/src/blenderbim/blenderbim/bim/module/system/data.py +++ b/src/blenderbim/blenderbim/bim/module/system/data.py @@ -102,12 +102,13 @@ class PortData: "port_connected_object": cls.port_connected_object() if is_port else None, "port_relating_object": cls.port_relating_object() if is_port else None, } + # AFTER located_ports_data + cls.data["selected_objects_flow_direction"] = cls.selected_objects_flow_direction() if not is_port else None cls.is_loaded = True @classmethod def total_ports(cls): - element = tool.Ifc.get_entity(bpy.context.active_object) - return len(ifcopenshell.util.system.get_ports(element)) + return len(ifcopenshell.util.system.get_ports(cls.element)) @classmethod def is_port(cls): @@ -127,21 +128,26 @@ class PortData: @classmethod def located_ports_data(cls): - element = tool.Ifc.get_entity(bpy.context.active_object) - ports = ifcopenshell.util.system.get_ports(element) + ports = ifcopenshell.util.system.get_ports(cls.element) data = [] for port in ports: port_obj = tool.Ifc.get_object(port) connected_port = tool.System.get_connected_port(port) if connected_port: - connected_element = tool.Ifc.get_object(tool.System.get_port_relating_element(connected_port)) + connected_obj = tool.Ifc.get_object(tool.System.get_port_relating_element(connected_port)) else: - connected_element = None + connected_obj = None - data.append((port, port_obj, connected_element)) + data.append((port, port_obj, connected_obj)) return data + @classmethod + def selected_objects_flow_direction(cls): + for port, port_obj, connected_obj in cls.data["located_ports_data"]: + if connected_obj in bpy.context.selected_objects: + return port.FlowDirection + class SystemDecorationData: data = {} diff --git a/src/blenderbim/blenderbim/bim/module/system/operator.py b/src/blenderbim/blenderbim/bim/module/system/operator.py index 0b703495b3..4d40d9c36d 100644 --- a/src/blenderbim/blenderbim/bim/module/system/operator.py +++ b/src/blenderbim/blenderbim/bim/module/system/operator.py @@ -270,12 +270,59 @@ class SetFlowDirection(bpy.types.Operator, Operator): bl_options = {"REGISTER", "UNDO"} direction: bpy.props.StringProperty() + @classmethod + def description(cls, context, operator): + if not PortData.is_loaded: + PortData.load() + + port = PortData.data["is_port"] + if port: + return f"Set port flow direction to {operator.direction}" + else: + return f"Set flow direction to {operator.direction} for active element relatively to the selected" + + @classmethod + def poll(cls, context): + if not PortData.is_loaded: + PortData.load() + + port = PortData.data["is_port"] + if not port and not len(context.selected_objects) == 2: + cls.poll_message_set("Need to select port or 2 connected objects.") + return False + return True + def _execute(self, context): - port = tool.Ifc.get_entity(context.active_object) - second_port = tool.System.get_connected_port(port) - if not second_port: - self.report({"ERROR"}, "To set flow direction port has to be connected to another one.") - return - core.set_flow_direction( - tool.Ifc, tool.System, port=tool.Ifc.get_entity(context.active_object), direction=self.direction - ) + element = tool.Ifc.get_entity(context.active_object) + + if element.is_a("IfcDistributionPort"): + second_port = tool.System.get_connected_port(element) + if not second_port: + self.report({"ERROR"}, "To set flow direction port has to be connected to another one.") + return + core.set_flow_direction(tool.Ifc, tool.System, port=element, direction=self.direction) + return {"FINISHED"} + + selected_elements = [ + entity + for entity in (tool.Ifc.get_entity(o) for o in context.selected_objects) + if entity and tool.System.is_mep_element(element) + ] + + if len(selected_elements) != 2: + self.report({"ERROR"}, "To set flow direction selected two connected MEP elements or just 1 port.") + return {"CANCELLED"} + + other_element = selected_elements[selected_elements[0] == element] + active_element_ports = tool.System.get_ports(element) + other_element_ports = tool.System.get_ports(other_element) + + for port in active_element_ports: + connected_port = tool.System.get_connected_port(port) + if connected_port in other_element_ports: + core.set_flow_direction(tool.Ifc, tool.System, port=port, direction=self.direction) + tool.Blender.update_viewport() + return {"FINISHED"} + + self.report({"ERROR"}, "Selected elements are not connected to set the flow direction") + return {"CANCELLED"} diff --git a/src/blenderbim/blenderbim/bim/module/system/ui.py b/src/blenderbim/blenderbim/bim/module/system/ui.py index d8e6b764c6..6e20514bac 100644 --- a/src/blenderbim/blenderbim/bim/module/system/ui.py +++ b/src/blenderbim/blenderbim/bim/module/system/ui.py @@ -24,10 +24,10 @@ from blenderbim.bim.module.system.data import SystemData, ObjectSystemData, Port FLOW_DIRECTION_TO_ICON = { - "SOURCE": "FORWARD", - "SINK": "BACK", + "SOURCE": "REMOVE", + "SINK": "ADD", "SOURCEANDSINK": "ARROW_LEFTRIGHT", - "NOTDEFINED": "RESTRICT_INSTANCED_ON", + "NOTDEFINED": "CHECKBOX_DEHLT", } @@ -179,6 +179,19 @@ class BIM_PT_ports(Panel): if total_ports == 0: return + row = self.layout.row(align=True) + row.label(text="Change Flow Direction:") + + current_flow_direction = PortData.data["selected_objects_flow_direction"] + for flow_direction in FLOW_DIRECTION_TO_ICON.keys(): + row.operator( + "bim.set_flow_direction", + icon=FLOW_DIRECTION_TO_ICON[flow_direction], + depress=flow_direction == current_flow_direction, + text="", + ).direction = flow_direction + row.enabled = len(context.selected_objects) == 2 + row = self.layout.row(align=True) row.label(text="Ports located on object and connected objects:") row = self.layout.row(align=True) @@ -270,16 +283,15 @@ class BIM_PT_port(Panel): else: row.label(text="Port is not connected to any element") - # TODO: replace with enum property? row = layout.row(align=True) row.label(text="Change Flow Direction:") for flow_direction in FLOW_DIRECTION_TO_ICON.keys(): - row = layout.row() row.operator( - "bim.set_flow_direction", icon=FLOW_DIRECTION_TO_ICON[flow_direction], text=flow_direction + "bim.set_flow_direction", + icon=FLOW_DIRECTION_TO_ICON[flow_direction], + depress=flow_direction == current_flow_direction, + text="", ).direction = flow_direction - if flow_direction == current_flow_direction: - row.enabled = False class BIM_UL_systems(UIList): From e975eac4a74758f8aea618c80c284f3ca4029a04 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Tue, 26 Sep 2023 11:44:46 +0500 Subject: [PATCH 171/190] system decorations to show flow direction --- .../blenderbim/bim/module/system/decorator.py | 4 +- src/blenderbim/blenderbim/tool/system.py | 75 ++++++++++++++++++- 2 files changed, 76 insertions(+), 3 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/system/decorator.py b/src/blenderbim/blenderbim/bim/module/system/decorator.py index 244c8d9a5f..8cebbae4f3 100644 --- a/src/blenderbim/blenderbim/bim/module/system/decorator.py +++ b/src/blenderbim/blenderbim/bim/module/system/decorator.py @@ -111,10 +111,12 @@ class SystemDecorator: roof_angle_edges = [] preview_edges = [] + # NOTE: using .data here doesn't allow having live viewport orientation for the calculations + # but not using .data seems to be performance heavy if not SystemDecorationData.is_loaded: SystemDecorationData.load() - decoration_data = SystemDecorationData.data["decoration_data"] + all_vertices = decoration_data["all_vertices"] preview_edges = decoration_data["preview_edges"] special_vertices = decoration_data["special_vertices"] diff --git a/src/blenderbim/blenderbim/tool/system.py b/src/blenderbim/blenderbim/tool/system.py index 87bdcc1a65..adec7a3782 100644 --- a/src/blenderbim/blenderbim/tool/system.py +++ b/src/blenderbim/blenderbim/tool/system.py @@ -22,6 +22,7 @@ import blenderbim.core.tool import blenderbim.tool as tool from blenderbim.bim import import_ifc import re +from math import pi, cos, sin from mathutils import Matrix, Vector @@ -191,6 +192,15 @@ class System(blenderbim.core.tool.System): selected_edges = [] selected_vertices = [] + view3d_space = tool.Blender.get_viewport_context()["space_data"].region_3d + viewport_matrix = view3d_space.view_matrix.inverted() + viewport_y_axis = viewport_matrix.col[1].to_3d().normalized() + camera_pos = viewport_matrix.translation + dir_to_camera = lambda x: (camera_pos - x).normalized() + + def most_aligned_vector(a, vectors): + return max(vectors, key=lambda v: abs(a.dot(v))) + si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) start_vert_i = 0 @@ -216,15 +226,76 @@ class System(blenderbim.core.tool.System): if not cls.is_mep_element(element): continue + selected_element = element in selected_elements ports = tool.System.get_ports(element) + verts_pos = [] for port in ports: position = tool.Model.get_element_matrix(port).translation * si_conversion - all_vertices.append(position) + verts_pos.append(position) verts = range(start_vert_i, start_vert_i + len(ports)) edges = [(i, i + 1) for i in range(start_vert_i, start_vert_i + len(ports) - 1)] - if element in selected_elements: + + def get_flow_direction(ports): + # diagram - https://i.imgur.com/ioYL7bZ.png + flow_dirs = [p.FlowDirection for p in ports] + unique = set(flow_dirs) + if len(unique) == 1: + return 0 + elif flow_dirs[0] == "SOURCE": + return -1 + elif flow_dirs[0] == "SINK": + return 1 + elif flow_dirs[1] == "SOURCE": + return 1 + elif flow_dirs[1] == "SINK": + return -1 + return 0 + + if len(ports) == 2 and selected_element and (flow_direction := get_flow_direction(ports)): + edge_verts = verts_pos.copy() + edge_verts = edge_verts[::flow_direction] + + # create direction lines + direction_lines_offset = 0.4 + direction_lines_width = 0.05 + base_vert = edge_verts[0] + edge = edge_verts[1] - edge_verts[0] + edge_length = edge.length + edge_dir = edge.normalized() + # edge_ortho = most_aligned_vector( + # viewport_y_axis, ( + # obj.matrix_world.col[0].to_3d().normalized(), + # obj.matrix_world.col[1].to_3d().normalized(), + # )) + + # for now it's hardcoded to local Y axis to avoid using viewport data + # for performance reasons + edge_ortho = obj.matrix_world.col[1].to_3d().normalized() + second_ortho = edge_dir.cross(edge_ortho) + edge_ortho = second_ortho.cross(edge_dir) + + # direction lines should be around the edge center + n_direction_lines, start_offset = divmod(edge_length, direction_lines_offset) + n_direction_lines = int(n_direction_lines) + 1 + start_offset /= 2 + start_offset = edge_dir * start_offset + base_vert + cur_vert_index = start_vert_i + len(ports) + + for i in range(n_direction_lines): + cur_offset = start_offset + edge_dir * i * direction_lines_offset + arrow_base = cur_offset - edge_dir * direction_lines_width + verts_pos.append(arrow_base + edge_ortho * direction_lines_width) + verts_pos.append(cur_offset) + verts_pos.append(arrow_base - edge_ortho * direction_lines_width) + edges.append((cur_vert_index, cur_vert_index + 1)) + edges.append((cur_vert_index + 1, cur_vert_index + 2)) + cur_vert_index += 3 + + all_vertices.extend(verts_pos) + + if selected_element: selected_vertices.extend(verts) selected_edges.extend(edges) else: From cc1ee191150e84b31e7248dfe0d998139b3e5b00 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Tue, 26 Sep 2023 14:45:45 +0500 Subject: [PATCH 172/190] Add ports to MEP bends/transitions types instead of occurences --- src/blenderbim/blenderbim/bim/module/model/mep.py | 8 ++++++-- src/blenderbim/blenderbim/tool/system.py | 5 ++++- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/model/mep.py b/src/blenderbim/blenderbim/bim/module/model/mep.py index 9d03590f6a..6306e30544 100644 --- a/src/blenderbim/blenderbim/bim/module/model/mep.py +++ b/src/blenderbim/blenderbim/bim/module/model/mep.py @@ -586,6 +586,8 @@ class MEPGenerator: 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, @@ -855,6 +857,7 @@ class MEPAddTransition(bpy.types.Operator, tool.Ifc.Operator): pset=pset, properties={"Data": json.dumps(transition_data, default=list)}, ) + tool.System.add_ports(obj, offset_end_port=profile_offset_ws) # NOTE: at this point we loose current blender objects selection # create transition element @@ -878,7 +881,7 @@ class MEPAddTransition(bpy.types.Operator, tool.Ifc.Operator): transition_obj.location = start_segment_extend_point if start_port_match else end_segment_extend_point # add ports and connect them - ports = tool.System.add_ports(transition_obj, offset_end_port=profile_offset_ws) + ports = tool.System.get_ports(tool.Ifc.get_entity(transition_obj)) if not start_port_match: start_port, end_port = end_port, start_port tool.Ifc.run("system.connect_port", port1=ports[0], port2=start_port, direction="NOTDEFINED") @@ -1197,6 +1200,7 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator): pset=pset, properties={"Data": json.dumps(bend_data, default=list)}, ) + tool.System.add_ports(obj, offset_end_port=start_object_rotation @ (radial_offset * V(1, 1, 0))) # NOTE: at this point we loose current blender objects selection # create transition element @@ -1240,7 +1244,7 @@ class MEPAddBend(bpy.types.Operator, tool.Ifc.Operator): fitting_obj.matrix_world = get_fitting_matrix() # add ports and connect them - ports = tool.System.add_ports(fitting_obj, offset_end_port=start_object_rotation @ (radial_offset * V(1, 1, 0))) + ports = tool.System.get_ports(tool.Ifc.get_entity(fitting_obj)) if not start_port_match: start_port, end_port = end_port, start_port tool.Ifc.run("system.connect_port", port1=ports[0], port2=start_port, direction="NOTDEFINED") diff --git a/src/blenderbim/blenderbim/tool/system.py b/src/blenderbim/blenderbim/tool/system.py index adec7a3782..9688875b89 100644 --- a/src/blenderbim/blenderbim/tool/system.py +++ b/src/blenderbim/blenderbim/tool/system.py @@ -106,7 +106,10 @@ class System(blenderbim.core.tool.System): @classmethod def get_port_predefined_type(cls, mep_element): split_camel_case = lambda x: re.findall("[A-Z][^A-Z]*", x) - class_name = "".join(split_camel_case(mep_element.is_a())[1:-1]).upper() + mep_class = mep_element.is_a() + if mep_class.endswith("Type"): + mep_class = mep_class[:-4] + class_name = "".join(split_camel_case(mep_class)[1:-1]).upper() if class_name == "CONVEYOR": return "NOTDEFINED" return class_name From 1193e7025abfb2da968fb2212b5741ad12d68625 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Tue, 26 Sep 2023 14:53:54 +0500 Subject: [PATCH 173/190] system decorations - live time update Decoration data is now updating in live time, caching some parts in Data for performance. Real time update is needed because Data doesn't allow update on objects positions change and later will need real time update for viewport orientation data. --- .../bim/module/drawing/decoration.py | 11 +++-- .../blenderbim/bim/module/system/data.py | 43 +++++++++++++++---- .../blenderbim/bim/module/system/decorator.py | 8 ++-- src/blenderbim/blenderbim/tool/model.py | 9 +++- src/blenderbim/blenderbim/tool/system.py | 33 +++++++------- 5 files changed, 71 insertions(+), 33 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/drawing/decoration.py b/src/blenderbim/blenderbim/bim/module/drawing/decoration.py index ebff8b188d..2c0bf10b1f 100644 --- a/src/blenderbim/blenderbim/bim/module/drawing/decoration.py +++ b/src/blenderbim/blenderbim/bim/module/drawing/decoration.py @@ -52,15 +52,20 @@ class profile_consequential: cls.test_name = test_name @classmethod - def log(cls): + def log(cls, args=[]): if cls.start_time is not None: - cls.lines.append(f"{cls.test_name}\t{timer() - cls.start_time:.10f}") + args = "" if not args else "\t" + "\t".join(args) + cls.lines.append(f"{cls.test_name}\t{timer() - cls.start_time:.10f}{args}") @classmethod def stop(cls): cls.log() cls.start_time = None - print("\n".join(cls.lines)) + lines = "\n".join(cls.lines) + print(lines) + import pyperclip + + pyperclip.copy(lines) cls.lines = [] diff --git a/src/blenderbim/blenderbim/bim/module/system/data.py b/src/blenderbim/blenderbim/bim/module/system/data.py index 3ba63ae1f7..6955fcdbbf 100644 --- a/src/blenderbim/blenderbim/bim/module/system/data.py +++ b/src/blenderbim/blenderbim/bim/module/system/data.py @@ -68,16 +68,18 @@ class ObjectSystemData: cls.data = { "systems": cls.systems(), "total_systems": cls.total_systems(), + # AFTER SYSTEMS + "connected_elements": cls.connected_elements(), } cls.is_loaded = True @classmethod def systems(cls): results = [] - element = tool.Ifc.get_entity(bpy.context.active_object) - if not element: + cls.element = tool.Ifc.get_entity(bpy.context.active_object) + if not cls.element: return results - for system in ifcopenshell.util.system.get_element_systems(element): + for system in ifcopenshell.util.system.get_element_systems(cls.element): results.append({"id": system.id(), "name": system.Name or "Unnamed", "ifc_class": system.is_a()}) return results @@ -85,6 +87,12 @@ class ObjectSystemData: def total_systems(cls): return len(tool.Ifc.get().by_type("IfcSystem")) + @classmethod + def connected_elements(cls): + if not cls.element: + return set() + return tool.System.get_connected_elements(cls.element) + class PortData: data = {} @@ -151,15 +159,34 @@ class PortData: class SystemDecorationData: data = {} + elements_ports_positions = {} is_loaded = False @classmethod def load(cls): - cls.data = { - "decoration_data": cls.decoration_data(), - } + cls.data = {} cls.is_loaded = True + cls.elements_ports_positions = {} @classmethod - def decoration_data(cls): - return tool.System.get_decoration_data() + def get_element_ports_data(cls, element): + """returns element's port data, caches the data until UI update + + Port data includes: + - local port position in SI units + - port flow direction + + """ + if element not in cls.elements_ports_positions: + ports = tool.System.get_ports(element) + si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + ports_data = [] + for port in ports: + position = tool.Model.get_element_matrix(port, keep_local=True).translation * si_conversion + port_data = { + "position": position, + "flow_direction": port.FlowDirection, + } + ports_data.append(port_data) + cls.elements_ports_positions[element] = ports_data + return cls.elements_ports_positions[element] diff --git a/src/blenderbim/blenderbim/bim/module/system/decorator.py b/src/blenderbim/blenderbim/bim/module/system/decorator.py index 8cebbae4f3..e3e8518f79 100644 --- a/src/blenderbim/blenderbim/bim/module/system/decorator.py +++ b/src/blenderbim/blenderbim/bim/module/system/decorator.py @@ -111,11 +111,9 @@ class SystemDecorator: roof_angle_edges = [] preview_edges = [] - # NOTE: using .data here doesn't allow having live viewport orientation for the calculations - # but not using .data seems to be performance heavy - if not SystemDecorationData.is_loaded: - SystemDecorationData.load() - decoration_data = SystemDecorationData.data["decoration_data"] + # NOTE: using live update because Data wouldn't allow + # live time update of objects positions + decoration_data = tool.System.get_decoration_data() all_vertices = decoration_data["all_vertices"] preview_edges = decoration_data["preview_edges"] diff --git a/src/blenderbim/blenderbim/tool/model.py b/src/blenderbim/blenderbim/tool/model.py index e2a2c29773..47ae276c3a 100644 --- a/src/blenderbim/blenderbim/tool/model.py +++ b/src/blenderbim/blenderbim/tool/model.py @@ -31,6 +31,7 @@ from blenderbim.bim.module.geometry.helper import Helper from blenderbim.bim.module.model.data import AuthoringData, RailingData, RoofData, WindowData, DoorData import collections import json +import numpy as np class Model(blenderbim.core.tool.Model): @@ -815,8 +816,12 @@ class Model(blenderbim.core.tool.Model): tool.Ifc.run("geometry.edit_object_placement", product=element, matrix=matrix, is_si=True) @classmethod - def get_element_matrix(cls, element): - placement = ifcopenshell.util.placement.get_local_placement(element.ObjectPlacement) + def get_element_matrix(cls, element, keep_local=False): + placement = element.ObjectPlacement + if keep_local: + placement = ifcopenshell.util.placement.get_axis2placement(placement.RelativePlacement) + else: + placement = ifcopenshell.util.placement.get_local_placement(placement) return Matrix(placement) @classmethod diff --git a/src/blenderbim/blenderbim/tool/system.py b/src/blenderbim/blenderbim/tool/system.py index 9688875b89..6a0cd8b080 100644 --- a/src/blenderbim/blenderbim/tool/system.py +++ b/src/blenderbim/blenderbim/tool/system.py @@ -24,6 +24,8 @@ from blenderbim.bim import import_ifc import re from math import pi, cos, sin from mathutils import Matrix, Vector +from blenderbim.bim.module.system.data import ObjectSystemData, SystemDecorationData +from blenderbim.bim.module.drawing.decoration import profile_consequential class System(blenderbim.core.tool.System): @@ -204,13 +206,16 @@ class System(blenderbim.core.tool.System): def most_aligned_vector(a, vectors): return max(vectors, key=lambda v: abs(a.dot(v))) - si_conversion = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) start_vert_i = 0 - if bpy.context.active_object and (active_element := tool.Ifc.get_entity(bpy.context.active_object)): - selected_elements = cls.get_connected_elements(active_element) - else: - selected_elements = set() + if not ObjectSystemData.is_loaded: + ObjectSystemData.load() + + if not SystemDecorationData.is_loaded: + SystemDecorationData.load() + + object_system_data = ObjectSystemData.data + selected_elements = object_system_data["connected_elements"] # TODO: get only objects visible in viewport objects = set(bpy.data.objects) - set(bpy.data.collections["Types"].objects) @@ -230,19 +235,17 @@ class System(blenderbim.core.tool.System): continue selected_element = element in selected_elements - ports = tool.System.get_ports(element) verts_pos = [] - for port in ports: - position = tool.Model.get_element_matrix(port).translation * si_conversion - verts_pos.append(position) + port_data = SystemDecorationData.get_element_ports_data(element) + verts_pos.extend([obj.matrix_world @ data["position"] for data in port_data]) - verts = range(start_vert_i, start_vert_i + len(ports)) - edges = [(i, i + 1) for i in range(start_vert_i, start_vert_i + len(ports) - 1)] + verts = range(start_vert_i, start_vert_i + len(port_data)) + edges = [(i, i + 1) for i in range(start_vert_i, start_vert_i + len(port_data) - 1)] - def get_flow_direction(ports): + def get_flow_direction(port_data): # diagram - https://i.imgur.com/ioYL7bZ.png - flow_dirs = [p.FlowDirection for p in ports] + flow_dirs = [p["flow_direction"] for p in port_data] unique = set(flow_dirs) if len(unique) == 1: return 0 @@ -256,7 +259,7 @@ class System(blenderbim.core.tool.System): return -1 return 0 - if len(ports) == 2 and selected_element and (flow_direction := get_flow_direction(ports)): + if len(port_data) == 2 and selected_element and (flow_direction := get_flow_direction(port_data)): edge_verts = verts_pos.copy() edge_verts = edge_verts[::flow_direction] @@ -284,7 +287,7 @@ class System(blenderbim.core.tool.System): n_direction_lines = int(n_direction_lines) + 1 start_offset /= 2 start_offset = edge_dir * start_offset + base_vert - cur_vert_index = start_vert_i + len(ports) + cur_vert_index = start_vert_i + len(port_data) for i in range(n_direction_lines): cur_offset = start_offset + edge_dir * i * direction_lines_offset From 7ed4301c6ccce6b932162db2d4c37ac5fa19ec67 Mon Sep 17 00:00:00 2001 From: "Sigma Dimensions (Yass)" <79010126+myoualid@users.noreply.github.com> Date: Tue, 26 Sep 2023 16:28:51 +0100 Subject: [PATCH 174/190] fix tasks not displaying derived calendar --- src/blenderbim/blenderbim/tool/sequence.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/blenderbim/blenderbim/tool/sequence.py b/src/blenderbim/blenderbim/tool/sequence.py index c197d7799b..43aac94fb7 100644 --- a/src/blenderbim/blenderbim/tool/sequence.py +++ b/src/blenderbim/blenderbim/tool/sequence.py @@ -205,10 +205,8 @@ class Sequence(blenderbim.core.tool.Sequence): rel.RelatingProcess.id() for rel in task.IsSuccessorFrom ] calendar = ifcopenshell.util.sequence.derive_calendar(task) - if task.HasAssignments: - for rel in task.HasAssignments: - if rel.is_a("IfcRelAssignsToControl") and rel.RelatingControl.is_a("IfcWorkCalendar"): - item.calendar = calendar.Name or "Unnamed" if calendar else "" + if ifcopenshell.util.sequence.get_calendar(task): + item.calendar = calendar.Name or "Unnamed" if calendar else "" else: item.calendar = "" item.derived_calendar = calendar.Name or "Unnamed" if calendar else "" From c85e4e7cebc1327d4f93ab2c0399187158b708da Mon Sep 17 00:00:00 2001 From: "Sigma Dimensions (Yass)" <79010126+myoualid@users.noreply.github.com> Date: Tue, 26 Sep 2023 16:35:00 +0100 Subject: [PATCH 175/190] fix missing task types for animations color scheme --- src/blenderbim/blenderbim/tool/sequence.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/blenderbim/blenderbim/tool/sequence.py b/src/blenderbim/blenderbim/tool/sequence.py index 43aac94fb7..d67b90c66e 100644 --- a/src/blenderbim/blenderbim/tool/sequence.py +++ b/src/blenderbim/blenderbim/tool/sequence.py @@ -1070,9 +1070,9 @@ class Sequence(blenderbim.core.tool.Sequence): for predefined_type in data["PredefinedType"]: if group in ["CREATION", "OPERATION", "MOVEMENT_TO"]: predefined_type_item = props.task_output_colors.add() - elif group in ["MOVEMENT_FROM", "DESTRUCTION"]: + elif group in ["MOVEMENT_FROM"]: predefined_type_item = props.task_input_colors.add() - elif group == "USERDEFINED": + elif group in ["USERDEFINED", "DESTRUCTION"]: predefined_type_item = props.task_input_colors.add() predefined_type_item2 = props.task_output_colors.add() predefined_type_item2.name = predefined_type @@ -1299,19 +1299,17 @@ class Sequence(blenderbim.core.tool.Sequence): cls.display_object(obj) @classmethod - def animate_objects(cls, settings, frames, clear_previous=True, animation_type=""): + def animate_objects(cls, settings, frames, animation_type=""): for obj in bpy.data.objects: if not obj.BIMObjectProperties.ifc_definition_id: continue - if clear_previous: - cls.clear_object_animation(obj) cls.earliest_frame = None product_frames = frames.get(obj.BIMObjectProperties.ifc_definition_id, []) for product_frame in product_frames: if product_frame["relationship"] == "input": cls.animate_input(obj, settings["start_frame"], product_frame, animation_type) elif product_frame["relationship"] == "output": - cls.animate_output(obj, settings["start_frame"], product_frame) + cls.animate_output(obj, settings["start_frame"], product_frame, animation_type) area = next(area for area in bpy.context.screen.areas if area.type == "VIEW_3D") area.spaces[0].shading.color_type = "OBJECT" bpy.context.scene.frame_start = settings["start_frame"] @@ -1321,14 +1319,19 @@ class Sequence(blenderbim.core.tool.Sequence): def animate_input(cls, obj, start_frame, product_frame, animation_type): props = bpy.context.scene.BIMAnimationProperties color = props.task_input_colors[product_frame["type"]].color - cls.animate_destruction(obj, start_frame, product_frame, color, animation_type) + if product_frame["type"] in ["LOGISTIC", "MOVE", "DISPOSAL"]: + cls.animate_destruction(obj, start_frame, product_frame, color, animation_type) + else: + cls.animate_consumption(obj, start_frame, product_frame, color, animation_type) @classmethod - def animate_output(cls, obj, start_frame, product_frame): + def animate_output(cls, obj, start_frame, product_frame, animation_type): props = bpy.context.scene.BIMAnimationProperties color = props.task_output_colors[product_frame["type"]].color if product_frame["type"] in ["CONSTRUCTION", "INSTALLATION", "NOTDEFINED"]: cls.animate_creation(obj, start_frame, product_frame, color) + elif product_frame["type"] in ["DEMOLITION", "DISMANTLE", "DISPOSAL", "REMOVAL"]: + cls.animate_destruction(obj, start_frame, product_frame, color, animation_type) elif product_frame["type"] in ["ATTENDANCE", "MAINTENANCE", "OPERATION", "RENOVATION"]: cls.animate_operation(obj, start_frame, product_frame, color) elif product_frame["type"] in ["LOGISTIC", "MOVE"]: From 635dff73b4a12446f8a1fc0228e9fd2c8400404c Mon Sep 17 00:00:00 2001 From: "Sigma Dimensions (Yass)" <79010126+myoualid@users.noreply.github.com> Date: Tue, 26 Sep 2023 16:35:31 +0100 Subject: [PATCH 176/190] fix typo --- .../ifcopenshell/api/cost/remove_cost_schedule.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ifcopenshell-python/ifcopenshell/api/cost/remove_cost_schedule.py b/src/ifcopenshell-python/ifcopenshell/api/cost/remove_cost_schedule.py index 081f30a43d..b1ebfbd738 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/cost/remove_cost_schedule.py +++ b/src/ifcopenshell-python/ifcopenshell/api/cost/remove_cost_schedule.py @@ -48,7 +48,7 @@ class Usecase: if inverse.is_a("IfcRelAssignsToControl"): [ ifcopenshell.api.run( - "sequence.remove_cost_item", self.file, task=related_object + "cost.remove_cost_item", self.file, cost_item=related_object ) for related_object in inverse.RelatedObjects if related_object.is_a("IfcCostItem") From 5c2523e7524b039ac1d467a5064139385ddd4a09 Mon Sep 17 00:00:00 2001 From: "Sigma Dimensions (Yass)" <79010126+myoualid@users.noreply.github.com> Date: Tue, 26 Sep 2023 16:36:48 +0100 Subject: [PATCH 177/190] fix warning statement when there are no saved animation color schemes --- src/blenderbim/blenderbim/bim/module/sequence/ui.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blenderbim/blenderbim/bim/module/sequence/ui.py b/src/blenderbim/blenderbim/bim/module/sequence/ui.py index 850f507785..d0e8e15e03 100644 --- a/src/blenderbim/blenderbim/bim/module/sequence/ui.py +++ b/src/blenderbim/blenderbim/bim/module/sequence/ui.py @@ -556,7 +556,7 @@ class BIM_PT_animation_tools(Panel): row = self.layout.row(align=True) row.alignment = "RIGHT" - if self.animation_props.saved_color_schemes: + if AnimationColorSchemeData.data["saved_color_schemes"]: row.prop(self.animation_props, "saved_color_schemes", text="Color Scheme", icon="SEQUENCE_COLOR_04") else: row.label(text="No Color Scheme Saved", icon="INFO") From 6962e505f9950e348ac1999dccb2f6791e27abb9 Mon Sep 17 00:00:00 2001 From: "Sigma Dimensions (Yass)" <79010126+myoualid@users.noreply.github.com> Date: Tue, 26 Sep 2023 16:38:33 +0100 Subject: [PATCH 178/190] hide spaces when creating an animation --- src/blenderbim/blenderbim/tool/sequence.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/blenderbim/blenderbim/tool/sequence.py b/src/blenderbim/blenderbim/tool/sequence.py index d67b90c66e..d6767954d8 100644 --- a/src/blenderbim/blenderbim/tool/sequence.py +++ b/src/blenderbim/blenderbim/tool/sequence.py @@ -1303,6 +1303,9 @@ class Sequence(blenderbim.core.tool.Sequence): for obj in bpy.data.objects: if not obj.BIMObjectProperties.ifc_definition_id: continue + if tool.Ifc.get().by_id(obj.BIMObjectProperties.ifc_definition_id).is_a("IfcSpace"): + cls.hide_object(obj) + continue cls.earliest_frame = None product_frames = frames.get(obj.BIMObjectProperties.ifc_definition_id, []) for product_frame in product_frames: From 5dfbbb8d96c1542dda0fa17d021fcc3320b119e3 Mon Sep 17 00:00:00 2001 From: "Sigma Dimensions (Yass)" <79010126+myoualid@users.noreply.github.com> Date: Tue, 26 Sep 2023 16:42:35 +0100 Subject: [PATCH 179/190] maintain tasks bar visual property state when tasks properties are reloaded --- .../blenderbim/bim/module/sequence/prop.py | 11 ++++++- src/blenderbim/blenderbim/tool/sequence.py | 32 ++++++++++++++++--- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/sequence/prop.py b/src/blenderbim/blenderbim/bim/module/sequence/prop.py index 30dc776cf7..add76f452e 100644 --- a/src/blenderbim/blenderbim/bim/module/sequence/prop.py +++ b/src/blenderbim/blenderbim/bim/module/sequence/prop.py @@ -347,6 +347,14 @@ def updateAssignedResourceUsage(self, context): blenderbim.bim.module.pset.data.refresh() +def update_task_bar_list(self, context): + if not context.scene.BIMWorkScheduleProperties.is_task_update_enabled: + return + if self.has_bar_visual: + tool.Sequence.add_task_bar(self.ifc_definition_id) + else: + tool.Sequence.remove_task_bar(self.ifc_definition_id) + class Task(PropertyGroup): name: StringProperty(name="Name", update=updateTaskName) identification: StringProperty(name="Identification", update=updateTaskIdentification) @@ -354,7 +362,7 @@ class Task(PropertyGroup): has_children: BoolProperty(name="Has Children") is_selected: BoolProperty(name="Is Selected") is_expanded: BoolProperty(name="Is Expanded") - has_bar_visual: BoolProperty(name="Show Task Bar Animation", default=False) + has_bar_visual: BoolProperty(name="Show Task Bar Animation", default=False, update=update_task_bar_list) level_index: IntProperty(name="Level Index") duration: StringProperty(name="Duration", update=updateTaskDuration) start: StringProperty(name="Start", update=updateTaskTimeStart) @@ -456,6 +464,7 @@ class BIMWorkScheduleProperties(PropertyGroup): active_task_time_id: IntProperty(name="Active Task Time Id") task_time_attributes: CollectionProperty(name="Task Time Attributes", type=Attribute) contracted_tasks: StringProperty(name="Contracted Task Items", default="[]") + task_bars: StringProperty(name="Checked Task Items", default="[]") is_task_update_enabled: BoolProperty(name="Is Task Update Enabled", default=True) editing_sequence_type: StringProperty(name="Editing Sequence Type") active_sequence_id: IntProperty(name="Active Sequence Id") diff --git a/src/blenderbim/blenderbim/tool/sequence.py b/src/blenderbim/blenderbim/tool/sequence.py index d6767954d8..ff28484fb1 100644 --- a/src/blenderbim/blenderbim/tool/sequence.py +++ b/src/blenderbim/blenderbim/tool/sequence.py @@ -191,12 +191,14 @@ class Sequence(blenderbim.core.tool.Sequence): props = bpy.context.scene.BIMWorkScheduleProperties task_props = bpy.context.scene.BIMTaskTreeProperties + tasks_with_visual_bar = cls.get_task_bar_list() props.is_task_update_enabled = False for item in task_props.tasks: task = tool.Ifc.get().by_id(item.ifc_definition_id) item.name = task.Name or "Unnamed" item.identification = task.Identification or "XXX" + item.has_bar_visual = item.ifc_definition_id in tasks_with_visual_bar if props.highlighted_task_id: item.is_predecessor = props.highlighted_task_id in [ rel.RelatedProcess.id() for rel in task.IsPredecessorTo @@ -796,10 +798,9 @@ class Sequence(blenderbim.core.tool.Sequence): @classmethod def get_animation_bar_tasks(cls): return [ - tool.Ifc.get().by_id(item.ifc_definition_id) - for item in bpy.context.scene.BIMTaskTreeProperties.tasks - if item.has_bar_visual - ] or [] + tool.Ifc.get().by_id(task_id) + for task_id in cls.get_task_bar_list() + ] @classmethod def create_bars(cls, tasks): @@ -1289,6 +1290,12 @@ class Sequence(blenderbim.core.tool.Sequence): obj.hide_viewport = False obj.hide_render = False + @classmethod + def hide_object(cls, obj): + if obj.visible_get(): + obj.hide_viewport = True + obj.hide_render = True + @classmethod def clear_objects_animation(cls, include_blender_objects=True): for obj in bpy.data.objects: @@ -1676,3 +1683,20 @@ class Sequence(blenderbim.core.tool.Sequence): if task.TaskTime and task.TaskTime.ScheduleDuration: return True return False + + @classmethod + def get_task_bar_list(cls): + return json.loads(bpy.context.scene.BIMWorkScheduleProperties.task_bars) + + @classmethod + def add_task_bar(cls, task_id): + task_bars = cls.get_task_bar_list() + task_bars.append(task_id) + bpy.context.scene.BIMWorkScheduleProperties.task_bars = json.dumps(task_bars) + + @classmethod + def remove_task_bar(cls, task_id): + task_bars = cls.get_task_bar_list() + if task_id in task_bars: + task_bars.remove(task_id) + bpy.context.scene.BIMWorkScheduleProperties.task_bars = json.dumps(task_bars) \ No newline at end of file From 126f530af1ae5e9093be4c885837c9fe121d700c Mon Sep 17 00:00:00 2001 From: "Sigma Dimensions (Yass)" <79010126+myoualid@users.noreply.github.com> Date: Tue, 26 Sep 2023 16:44:35 +0100 Subject: [PATCH 180/190] run black and forgotten calender utils --- .../ifcopenshell/util/sequence.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/ifcopenshell-python/ifcopenshell/util/sequence.py b/src/ifcopenshell-python/ifcopenshell/util/sequence.py index a97e9a58a1..b8ad222e8a 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/sequence.py +++ b/src/ifcopenshell-python/ifcopenshell/util/sequence.py @@ -48,6 +48,14 @@ def derive_date(task, attribute_name, date=None, is_earliest=False, is_latest=Fa def derive_calendar(task): + calendar = get_calendar(task) + if calendar: + return calendar + for rel in task.Nests or []: + return derive_calendar(rel.RelatingObject) + + +def get_calendar(task): calendar = [ rel.RelatingControl for rel in task.HasAssignments or [] @@ -56,8 +64,6 @@ def derive_calendar(task): ] if calendar: return calendar[0] - for rel in task.Nests or []: - return derive_calendar(rel.RelatingObject) def count_working_days(start, finish, calendar): @@ -250,7 +256,7 @@ def get_task_work_schedule(task): def get_nested_tasks(task): - return [object for rel in task.IsNestedBy or [] for object in rel.RelatedObjects] + return [object for rel in task.IsNestedBy or [] for object in rel.RelatedObjects] def get_parent_task(task): From f1a5805b1cf34c2ab88f8252c18776183cac4830 Mon Sep 17 00:00:00 2001 From: "Sigma Dimensions (Yass)" <79010126+myoualid@users.noreply.github.com> Date: Tue, 26 Sep 2023 18:33:52 +0100 Subject: [PATCH 181/190] fix loading animation color scheme --- src/blenderbim/blenderbim/core/sequence.py | 1 + src/blenderbim/blenderbim/tool/sequence.py | 7 ++++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/blenderbim/blenderbim/core/sequence.py b/src/blenderbim/blenderbim/core/sequence.py index d4ecd2bb72..2fa2debf5d 100644 --- a/src/blenderbim/blenderbim/core/sequence.py +++ b/src/blenderbim/blenderbim/core/sequence.py @@ -513,6 +513,7 @@ def visualise_work_schedule_date_range(sequence, work_schedule=None): product_frames = sequence.get_animation_product_frames(work_schedule, settings) if not sequence.has_animation_colors(): sequence.load_default_animation_color_scheme() + load_animation_color_scheme(sequence, scheme=sequence.get_animation_color_scheme()) sequence.animate_objects(settings, product_frames, "date_range") sequence.add_text_animation_handler(settings) add_task_bars(sequence) diff --git a/src/blenderbim/blenderbim/tool/sequence.py b/src/blenderbim/blenderbim/tool/sequence.py index ff28484fb1..a62322723a 100644 --- a/src/blenderbim/blenderbim/tool/sequence.py +++ b/src/blenderbim/blenderbim/tool/sequence.py @@ -1699,4 +1699,9 @@ class Sequence(blenderbim.core.tool.Sequence): task_bars = cls.get_task_bar_list() if task_id in task_bars: task_bars.remove(task_id) - bpy.context.scene.BIMWorkScheduleProperties.task_bars = json.dumps(task_bars) \ No newline at end of file + bpy.context.scene.BIMWorkScheduleProperties.task_bars = json.dumps(task_bars) + + @classmethod + def get_animation_color_scheme(cls): + if len(bpy.context.scene.BIMAnimationProperties.saved_color_schemes) > 0: + return tool.Ifc.get().by_id(int(bpy.context.scene.BIMAnimationProperties.saved_color_schemes)) From e93a69717900d30ba6acbe4f72fc277e5b214fa0 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 27 Sep 2023 11:50:43 +1000 Subject: [PATCH 182/190] Fix #3788. Fix crash when opening drawing SVGs using webbrowser module which is not recommended and the file is on a Dropbox synced external drive. Now replaced with generic os.startfile / xdg-open / open commands. --- src/blenderbim/blenderbim/bim/module/csv/operator.py | 1 - src/blenderbim/blenderbim/bim/module/drawing/operator.py | 1 - src/blenderbim/blenderbim/bim/module/fm/operator.py | 1 - .../blenderbim/bim/module/sequence/operator.py | 1 - src/blenderbim/blenderbim/tool/drawing.py | 9 +++++++-- 5 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/csv/operator.py b/src/blenderbim/blenderbim/bim/module/csv/operator.py index 0ff49af50c..78e980e99e 100644 --- a/src/blenderbim/blenderbim/bim/module/csv/operator.py +++ b/src/blenderbim/blenderbim/bim/module/csv/operator.py @@ -22,7 +22,6 @@ import json import ifccsv import logging import tempfile -import webbrowser import ifcopenshell import blenderbim.tool as tool from blenderbim.bim.ifc import IfcStore diff --git a/src/blenderbim/blenderbim/bim/module/drawing/operator.py b/src/blenderbim/blenderbim/bim/module/drawing/operator.py index 1285187b40..caa9ed66ed 100644 --- a/src/blenderbim/blenderbim/bim/module/drawing/operator.py +++ b/src/blenderbim/blenderbim/bim/module/drawing/operator.py @@ -26,7 +26,6 @@ import shutil import hashlib import shapely import subprocess -import webbrowser import numpy as np import multiprocessing import ifcopenshell diff --git a/src/blenderbim/blenderbim/bim/module/fm/operator.py b/src/blenderbim/blenderbim/bim/module/fm/operator.py index a53c92dc0c..d48502790a 100644 --- a/src/blenderbim/blenderbim/bim/module/fm/operator.py +++ b/src/blenderbim/blenderbim/bim/module/fm/operator.py @@ -21,7 +21,6 @@ import bpy import json import logging import tempfile -import webbrowser import ifcopenshell import blenderbim.tool as tool diff --git a/src/blenderbim/blenderbim/bim/module/sequence/operator.py b/src/blenderbim/blenderbim/bim/module/sequence/operator.py index a7e6e1ea97..d285a49d33 100644 --- a/src/blenderbim/blenderbim/bim/module/sequence/operator.py +++ b/src/blenderbim/blenderbim/bim/module/sequence/operator.py @@ -23,7 +23,6 @@ import time import calendar import isodate import pystache -import webbrowser import blenderbim.core.sequence as core import blenderbim.tool as tool import blenderbim.bim.module.sequence.helper as helper diff --git a/src/blenderbim/blenderbim/tool/drawing.py b/src/blenderbim/blenderbim/tool/drawing.py index 63af83cbaa..6d461cefc5 100644 --- a/src/blenderbim/blenderbim/tool/drawing.py +++ b/src/blenderbim/blenderbim/tool/drawing.py @@ -26,9 +26,9 @@ import bmesh import shutil import logging import shapely +import platform from shapely.ops import unary_union import mathutils -import webbrowser import subprocess import numpy as np import blenderbim.core.tool @@ -834,7 +834,12 @@ class Drawing(blenderbim.core.tool.Drawing): command[0] = shutil.which(command[0]) or command[0] subprocess.Popen([replacements.get(c, c) for c in command]) else: - webbrowser.open("file://" + path) + if platform.system() == "Darwin": + subprocess.call(("open", path)) + elif platform.system() == "Windows": + os.startfile(path) + else: + subprocess.call(("xdg-open", path)) @classmethod def open_spreadsheet(cls, uri): From 50f6f44d26f1a5958a1a1cd72abe4274ecc6e0e4 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 27 Sep 2023 14:16:43 +1000 Subject: [PATCH 183/190] You can now specify empty formatting for IfcCSV via CLI --- src/ifccsv/ifccsv.py | 28 ++++++++++++++++++------- src/ifcopenshell-python/docs/ifccsv.rst | 8 ++++--- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/src/ifccsv/ifccsv.py b/src/ifccsv/ifccsv.py index de505d676a..04d18727e2 100755 --- a/src/ifccsv/ifccsv.py +++ b/src/ifccsv/ifccsv.py @@ -247,10 +247,11 @@ class IfcCsv: if not self.results: return if sort: + def natural_sort(value): if isinstance(value, str): convert = lambda text: int(text) if text.isdigit() else text.lower() - return [convert(c) for c in re.split('([0-9]+)', value)] + return [convert(c) for c in re.split("([0-9]+)", value)] return value # Sort least important keys first, then more important keys. @@ -370,7 +371,9 @@ class IfcCsv: results.update([p.Name for p in element.Quantities]) return ["{}.{}".format(pset_qto_name, n) for n in results] - def Import(self, ifc_file, table, attributes=None, delimiter=",", null="-", empty="", bool_true="YES", bool_false="NO"): + def Import( + self, ifc_file, table, attributes=None, delimiter=",", null="-", empty="", bool_true="YES", bool_false="NO" + ): ext = table.split(".")[-1].lower() if ext == "csv": @@ -380,7 +383,9 @@ class IfcCsv: elif ext == "xlsx": self.import_xlsx(ifc_file, table, attributes, null, empty, bool_true, bool_false) - def import_csv(self, ifc_file, table, attributes=None, delimiter=",", null="-", empty="", bool_true="YES", bool_false="NO"): + def import_csv( + self, ifc_file, table, attributes=None, delimiter=",", null="-", empty="", bool_true="YES", bool_false="NO" + ): with open(table, newline="", encoding="utf-8") as f: reader = csv.reader(f, delimiter=delimiter) headers = [] @@ -441,7 +446,10 @@ if __name__ == "__main__": parser.add_argument("-f", "--format", type=str, default="csv", help="The format, chosen from csv, ods, or xlsx") parser.add_argument("-d", "--delimiter", type=str, default=",", help="The delimiter in CSV. Defaults to a comma.") parser.add_argument( - "-n", "--null", type=str, default="-", help="How to represent null values. Defaults to a hyphen." + "-n", "--null", type=str, default="N/A", help="How to represent null values. Defaults to N/A." + ) + parser.add_argument( + "-e", "--empty", type=str, default="-", help="How to represent empty strings. Defaults to a hyphen." ) parser.add_argument("--bool_true", type=str, default="YES", help="How to represent true values. Defaults to YES.") parser.add_argument("--bool_false", type=str, default="NO", help="How to represent false values. Defaults to NO.") @@ -452,9 +460,7 @@ if __name__ == "__main__": nargs="+", help="Specify attributes that are part of the extract, using the IfcQuery syntax such as 'class', 'Name' or 'Pset_Foo.Bar'", ) - parser.add_argument( - "--headers", nargs="+", help="Specify human readable headers that correlate to each attribute." - ) + parser.add_argument("--headers", nargs="+", help="Specify human readable headers that correlate to each attribute.") parser.add_argument("--sort", nargs="+", help="Specify one or more attributes to sort by.") parser.add_argument("--order", nargs="+", help="Choose the sort order from ASC or DESC for each sorted attribute.") parser.add_argument("--export", action="store_true", help="Export from IFC to the desired format.") @@ -477,6 +483,7 @@ if __name__ == "__main__": format=args.format, delimiter=args.delimiter, null=args.null, + empty=args.empty, bool_true=args.bool_true, bool_false=args.bool_false, sort=sort, @@ -485,6 +492,11 @@ if __name__ == "__main__": ifc_csv = IfcCsv() ifc_file = ifcopenshell.open(args.ifc) ifc_csv.Import( - ifc_file, args.spreadsheet, attributes=args.attributes or [], delimiter=args.delimiter, null=args.null + ifc_file, + args.spreadsheet, + attributes=args.attributes or [], + delimiter=args.delimiter, + null=args.null, + empty=args.empty, ) ifc_file.write(args.ifc) diff --git a/src/ifcopenshell-python/docs/ifccsv.rst b/src/ifcopenshell-python/docs/ifccsv.rst index 54ef0a4b6a..154a0fcae5 100644 --- a/src/ifcopenshell-python/docs/ifccsv.rst +++ b/src/ifcopenshell-python/docs/ifccsv.rst @@ -66,8 +66,8 @@ utility: :: $ python -m ifccsv -h - usage: ifccsv.py [-h] -i IFC [-s SPREADSHEET] [-f FORMAT] [-d DELIMITER] [-n NULL] [--bool_true BOOL_TRUE] [--bool_false BOOL_FALSE] [-q QUERY] [-a ATTRIBUTES [ATTRIBUTES ...]] [--headers HEADERS [HEADERS ...]] [--sort SORT [SORT ...]] - [--order ORDER [ORDER ...]] [--export] [--import] + usage: ifccsv.py [-h] -i IFC [-s SPREADSHEET] [-f FORMAT] [-d DELIMITER] [-n NULL] [-e EMPTY] [--bool_true BOOL_TRUE] [--bool_false BOOL_FALSE] [-q QUERY] [-a ATTRIBUTES [ATTRIBUTES ...]] [--headers HEADERS [HEADERS ...]] + [--sort SORT [SORT ...]] [--order ORDER [ORDER ...]] [--export] [--import] Exports IFC data to and from CSV @@ -80,7 +80,9 @@ utility: The format, chosen from csv, ods, or xlsx -d DELIMITER, --delimiter DELIMITER The delimiter in CSV. Defaults to a comma. - -n NULL, --null NULL How to represent null values. Defaults to a hyphen. + -n NULL, --null NULL How to represent null values. Defaults to N/A. + -e EMPTY, --empty EMPTY + How to represent empty strings. Defaults to a hyphen. --bool_true BOOL_TRUE How to represent true values. Defaults to YES. --bool_false BOOL_FALSE From 22edbddd84c43b70bd63f9c54fdbcb319a1932e1 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 27 Sep 2023 14:17:46 +1000 Subject: [PATCH 184/190] You can now interact with IfcFM via CLI --- .../blenderbim/bim/module/fm/prop.py | 2 +- src/ifcfm/ifcfm/__main__.py | 37 +++++++++++++++++++ src/ifcfm/ifcfm/{cobie.py => cobie24.py} | 0 .../ifcopenshell/util/fm.py | 1 + 4 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 src/ifcfm/ifcfm/__main__.py rename src/ifcfm/ifcfm/{cobie.py => cobie24.py} (100%) diff --git a/src/blenderbim/blenderbim/bim/module/fm/prop.py b/src/blenderbim/blenderbim/bim/module/fm/prop.py index 120de1a747..e05312038c 100644 --- a/src/blenderbim/blenderbim/bim/module/fm/prop.py +++ b/src/blenderbim/blenderbim/bim/module/fm/prop.py @@ -37,7 +37,7 @@ class BIMFMProperties(PropertyGroup): items=[ ("aohbsem", "AOH-BSEM", ""), ("basic", "Basic", ""), - ("cobie", "COBie 2.4", ""), + ("cobie24", "COBie 2.4", ""), ("cobie3", "COBie 3", ""), ], name="Engine", diff --git a/src/ifcfm/ifcfm/__main__.py b/src/ifcfm/ifcfm/__main__.py new file mode 100644 index 0000000000..6b0fac9cb7 --- /dev/null +++ b/src/ifcfm/ifcfm/__main__.py @@ -0,0 +1,37 @@ +import ifcfm +import argparse +import ifcopenshell + +parser = argparse.ArgumentParser(description="Extracts FM data from IFC to spreadsheets") +parser.add_argument( + "-p", + "--preset", + type=str, + default="basic", + help="The FM standard to extract. Built-in preset standards include cobie24, cobie3, aohbsem, and basic.", +) +parser.add_argument("-i", "--ifc", type=str, required=True, help="The IFC file") +parser.add_argument("-s", "--spreadsheet", type=str, default="output.ods", help="The spreadsheet file, or directory if the format is csv. Defaults to output.ods") +parser.add_argument( + "-f", "--format", type=str, default="ods", help="The format, chosen from csv, ods, or xlsx. Defaults to ods." +) +parser.add_argument("-d", "--delimiter", type=str, default=",", help="The delimiter in CSV. Defaults to a comma.") +parser.add_argument("-n", "--null", type=str, default="N/A", help="How to represent null values. Defaults to N/A.") +parser.add_argument( + "-e", "--empty", type=str, default="-", help="How to represent empty strings. Defaults to a hyphen." +) +parser.add_argument("--bool_true", type=str, default="YES", help="How to represent true values. Defaults to YES.") +parser.add_argument("--bool_false", type=str, default="NO", help="How to represent false values. Defaults to NO.") +args = parser.parse_args() + +ifc_file = ifcopenshell.open(args.ifc) +parser = ifcfm.Parser(preset=args.preset) +parser.parse(ifc_file) +writer = ifcfm.Writer(parser) +writer.write(null=args.null, empty=args.empty, bool_true=args.bool_true, bool_false=args.bool_false) +if args.format == "csv": + writer.write_csv(args.spreadsheet, delimiter=args.delimiter) +elif args.format == "ods": + writer.write_ods(args.spreadsheet) +elif args.format == "xlsx": + writer.write_xlsx(args.spreadsheet) diff --git a/src/ifcfm/ifcfm/cobie.py b/src/ifcfm/ifcfm/cobie24.py similarity index 100% rename from src/ifcfm/ifcfm/cobie.py rename to src/ifcfm/ifcfm/cobie24.py diff --git a/src/ifcopenshell-python/ifcopenshell/util/fm.py b/src/ifcopenshell-python/ifcopenshell/util/fm.py index f1c41f65e9..b58d688c99 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/fm.py +++ b/src/ifcopenshell-python/ifcopenshell/util/fm.py @@ -19,6 +19,7 @@ import ifcopenshell import ifcopenshell.util.attribute +# COBie actually uses an exclusion list, but this inclusion list is equivalent. cobie_type_classes = [ "IfcDoorStyle", "IfcBuildingElementProxyType", From 182b6c7b0702b8b4d9dacb3d7837ea776e65d2f1 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 27 Sep 2023 14:18:48 +1000 Subject: [PATCH 185/190] IfcFM now supports writing to Pandas Dataframes --- src/ifcfm/ifcfm/__init__.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/ifcfm/ifcfm/__init__.py b/src/ifcfm/ifcfm/__init__.py index 5b81f02367..c5414a46ac 100644 --- a/src/ifcfm/ifcfm/__init__.py +++ b/src/ifcfm/ifcfm/__init__.py @@ -36,6 +36,12 @@ except: pass # No ODF support +try: + import pandas as pd +except: + pass # No Pandas support + + class Parser: def __init__(self, preset="basic"): self.file = None @@ -239,3 +245,9 @@ class Writer: r += 1 workbook.save(output) + + def write_pd(self): + results = {} + for category, data in self.categories.items(): + results[category] = pd.DataFrame(data["rows"], columns=data["headers"]) + return results From c1bedec3541af43f70f370bb2c8849778d21f919 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 27 Sep 2023 12:09:44 +0500 Subject: [PATCH 186/190] Minor fix after 22edbdd Error was: TypeError: EnumProperty(..., default='cobie'): not found in enum members Exception in module register(): \3.6\scripts\addons\blenderbim\__init__.py Traceback (most recent call last): File "\Blender\3.6\scripts\modules\addon_utils.py", line 369, in enable mod.register() File "\3.6\scripts\addons\blenderbim\__init__.py", line 42, in register blenderbim.bim.register() File "\3.6\scripts\addons\blenderbim\bim\__init__.py", line 190, in register bpy.utils.register_class(cls) ValueError: bpy_struct "BIMFMProperties" registration error: 'engine' EnumProperty could not register (see previous error) --- src/blenderbim/blenderbim/bim/module/fm/prop.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/blenderbim/blenderbim/bim/module/fm/prop.py b/src/blenderbim/blenderbim/bim/module/fm/prop.py index e05312038c..f6a3074da3 100644 --- a/src/blenderbim/blenderbim/bim/module/fm/prop.py +++ b/src/blenderbim/blenderbim/bim/module/fm/prop.py @@ -41,7 +41,7 @@ class BIMFMProperties(PropertyGroup): ("cobie3", "COBie 3", ""), ], name="Engine", - default="cobie", + default="cobie24", ) format: EnumProperty( items=[ From 986ffe66f815b558bf6ba9fe1497eb5095a0a577 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 27 Sep 2023 18:21:20 +1000 Subject: [PATCH 187/190] New introduction section in IOS docs --- src/ifcopenshell-python/docs/conf.py | 2 +- .../docs/ifcopenshell-python.rst | 3 +- .../docs/ifcopenshell-python/hello_world.rst | 222 ------------------ src/ifcopenshell-python/docs/ifcopenshell.rst | 26 +- .../docs/ifcopenshell/images/intro.png | Bin 0 -> 135621 bytes src/ifcopenshell-python/docs/index.rst | 20 +- src/ifcopenshell-python/docs/indices.rst | 6 + src/ifcopenshell-python/docs/introduction.rst | 89 +++++++ .../docs/introduction/images/ifc-concepts.png | Bin 0 -> 21973 bytes .../images/ifc-concepts.svg | 0 .../docs/introduction/images/ifc-graph.png | Bin 0 -> 29060 bytes .../images/ifc-graph.svg | 0 .../docs/introduction/images/ifc-tree.png | Bin 0 -> 51462 bytes .../images/ifc-tree.svg | 0 .../docs/introduction/introduction_to_bim.rst | 37 +++ .../docs/introduction/introduction_to_ifc.rst | 220 +++++++++++++++++ 16 files changed, 373 insertions(+), 252 deletions(-) create mode 100644 src/ifcopenshell-python/docs/ifcopenshell/images/intro.png create mode 100644 src/ifcopenshell-python/docs/indices.rst create mode 100644 src/ifcopenshell-python/docs/introduction.rst create mode 100644 src/ifcopenshell-python/docs/introduction/images/ifc-concepts.png rename src/ifcopenshell-python/docs/{ifcopenshell-python => introduction}/images/ifc-concepts.svg (100%) create mode 100644 src/ifcopenshell-python/docs/introduction/images/ifc-graph.png rename src/ifcopenshell-python/docs/{ifcopenshell-python => introduction}/images/ifc-graph.svg (100%) create mode 100644 src/ifcopenshell-python/docs/introduction/images/ifc-tree.png rename src/ifcopenshell-python/docs/{ifcopenshell-python => introduction}/images/ifc-tree.svg (100%) create mode 100644 src/ifcopenshell-python/docs/introduction/introduction_to_bim.rst create mode 100644 src/ifcopenshell-python/docs/introduction/introduction_to_ifc.rst diff --git a/src/ifcopenshell-python/docs/conf.py b/src/ifcopenshell-python/docs/conf.py index 0d981bfc71..befbaae79b 100644 --- a/src/ifcopenshell-python/docs/conf.py +++ b/src/ifcopenshell-python/docs/conf.py @@ -36,7 +36,7 @@ sys.path.insert(0, os.path.abspath('..')) # -- Project information ----------------------------------------------------- project = "IfcOpenShell" -copyright = "2020-2022, IfcOpenShell Contributors" +copyright = "2011-2023, IfcOpenShell Contributors" author = "IfcOpenShell Contributors" # The full version, including alpha/beta/rc tags diff --git a/src/ifcopenshell-python/docs/ifcopenshell-python.rst b/src/ifcopenshell-python/docs/ifcopenshell-python.rst index 0861a2b374..9eebe129d4 100644 --- a/src/ifcopenshell-python/docs/ifcopenshell-python.rst +++ b/src/ifcopenshell-python/docs/ifcopenshell-python.rst @@ -2,7 +2,8 @@ IfcOpenShell-Python =================== IfcOpenShell-Python provides Python bindings to the core IfcOpenShell C++ -system, as well as high level analysis and authoring functions. +system, as well as high level analysis and authoring functions. All the +capabilities of the C++ core are available in Python. .. toctree:: :hidden: diff --git a/src/ifcopenshell-python/docs/ifcopenshell-python/hello_world.rst b/src/ifcopenshell-python/docs/ifcopenshell-python/hello_world.rst index 05289bbbd0..61931b8794 100644 --- a/src/ifcopenshell-python/docs/ifcopenshell-python/hello_world.rst +++ b/src/ifcopenshell-python/docs/ifcopenshell-python/hello_world.rst @@ -1,228 +1,6 @@ Hello, world! ============= -What's inside an IFC? ---------------------- - -An IFC model is a collection of elements (e.g. doors, windows, construction -tasks, materials, etc) with relationships to other elements in a graph-like -database. Together, these elements and their relationships describe the digital -built environment. - -.. image:: images/ifc-graph.svg - -Each element has a type known as an **IFC Class**. These classes define the -attributes that the element may store. For example, the **IfcWall Class** is -allowed to store a **Name** and **Description** attribute. - -This IFC database can be stored in many formats. The most common is the ``.ifc`` -format, which stores data in plain text. If you open a ``.ifc`` file in a text -editor, you'll see something like this: - -:: - - #1=IFCPROJECT('3Cbhu4euf1hfgM_SHZbeqM',$,'My Project',$,$,$,$,$,#4); - #2=IFCSIUNIT(*,.LENGTHUNIT.,.MILLI.,.METRE.); - #3=IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.); - #4=IFCUNITASSIGNMENT((#2,#3)); - #5=IFCCARTESIANPOINT((0.,0.,0.)); - -In this example there are 5 elements in the graph. The element with the ID of -**#1** has an **IFC Class** of **IfcProject**. This element has 9 -comma-separated attributes. IFC defines how many attributes each **IFC Class** -is allowed to have, attribute names, the order of attributes, data type, -optional or mandatory status (i.e. cardinality), and more. - -:: - - IFC Class Quoted string value Null value ID reference - ↓ ↓ ↓ ↓ - #1=IFCPROJECT('3Cbhu4euf1hfgM_SHZbeqM',$,'My Project',$,$,$,$,$,#4); - ↑ ↑ - Element ID Comma-separated list of attributes - -By selecting elements by their **IFC Class**, and reading their attributes, you -can navigate from one element to another. The relationships between elements are -called **IFC Concepts** and create meaning in our industry. For example, if a -**IfcWall** element has an attribute that references an **IfcBuildingStorey** -element in a particular way, it will mean that the wall is located in the -ground floor of the building. - -.. image:: images/ifc-concepts.svg - -The official IFC documentation describes hundreds of **IFC Classes**, ranging -from walls, door, to tasks, cost items, parametric materials, and structural -analysis constraints. There are also hundreds of **IFC Concepts**, which may -describe how a wall is in a storey, a construction task might occur one after -another, or how an surface bounds a space for energy analysis. - -It takes time to learn the many **IFC Classes** and **IFC Concepts** available. -Once you do, you will be able to richly describe our built environment -digitally. IfcOpenShell can help you navigate these IFC elements, read their -attributes, and explore relationships. Your journey begins here. - -.. seealso:: - - If you are already familiar with IFC and just want to learn how to use - IfcOpenShell, you can jump to the `Core functionality crash course`_. - -Begin learning IFC ------------------- - -IFC has three versions published by ISO: **IFC2X3** from 2007, **IFC4** from -2017, and **IFC4X3** in draft form. Each version improves on the previous -version, and will have different **IFC Classes** with different attributes and -different **IFC Concepts**. - -You can access the official documentation here: - -- `Official IFC2X3 documentation homepage `__ -- `Official IFC4 documentation homepage `__ -- `Official IFC4X3 documentation homepage `__ -- `List of all IFC2X3 classes `__ -- `List of all IFC4 classes `__ -- `List of all IFC4X3 classes `__ - -.. tip:: - - It is recommended to use IFC4. However, the IFC4X3 documentation is a lot - more friendly to newcomers. - -The official ISO documentation is written for a technical audience and may be -overwhelming. This guide will take you slowly through the core concepts, and -leave you with the knowledge you need to discover more. - -Before digging into theory, let's explore an existing IFC model. You can -download this sample IFC for this guide. - -.. container:: blockbutton - - `Download sample IFC `__ - -If you open up the model with a text editor, you will see text similar to this: - -:: - - ISO-10303-21; - HEADER;FILE_DESCRIPTION(('ViewDefinition [, QuantityTakeOffAddOnView, SpaceBoundary2ndLevelAddOnView]'),'2;1'); - FILE_NAME('AC20-FZK-Haus.ifc','2016-12-21T17:54:06',('Architect'),(''),'','',''); - FILE_SCHEMA(('IFC4')); - ENDSEC; - - DATA; - #3= IFCORGANIZATION($,'Nicht definiert',$,$,$); - #12= IFCOWNERHISTORY(#7,#11,$,.ADDED.,$,$,$,1482339244); - #13= IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.); - #14= IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.); - - ... - - #62= IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.00000000000E-5,#59,#60); - #66= IFCPROJECT('0lY6P5Ur90TAQnnnI6wtnb',#12,'Projekt-FZK-Haus','Projekt FZK-House create by KHH Forschuungszentrum Karlsruhe',$,$,$,(#62,#374),#49); - #77= IFCPROPERTYSINGLEVALUE('GS_TimeStamp',$,IFCTIMESTAMP(9685146),$); - #85= IFCPROPERTYSET('1mnk_H9cG6eU2r9ped0WRu',#12,'GSPset_TimeStamp',$,(#77)); - - ... - - #15033= IFCSHAPEREPRESENTATION(#15026,'Axis','Curve2D',(#15031)); - #15037= IFCPRODUCTDEFINITIONSHAPE($,$,(#15016,#15024,#15033)); - #15042= IFCWALLSTANDARDCASE('2XPyKWY018sA1ygZKgQPtU',#12,'Wand-Int-ERDG-4',$,$,#14983,#15037,'BC6F0F70-6195-495E-A2-FC-239713029DB1',$); - #15046= IFCMATERIAL('Leichtbeton 102890359',$,$); - - ... - - #15231= IFCRELDEFINESBYPROPERTIES('3Q0nMR5elnJFWzAhgkZqe1',#12,$,$,(#15042),#15229); - #15234= IFCWALLTYPE('2AEMyYvIjlsz7LRzqYHy64',#12,'Leichtbeton 102890359 240',$,$,$,(#15244,#15248,#15250,#17288,#17290,#17292,#18637,#18639,#18641,#19015,#19017,#19019,#20770,#20772,#20774),'8A396F22-E52B-6FDB-D1D5-6FDD2247C184',$,.NOTDEFINED.); - #15237= IFCDIRECTION((1.,0.,0.)); - #15239= IFCDIRECTION((0.,0.,1.)); - - ... etc - -The first thing you should notice is the line that defines that this is an -**IFC4** version. This determines what **IFC Classes** and **IFC Concepts** are -available. - -:: - - FILE_SCHEMA(('IFC4')); - -You'll notice certain **IFC Class** keywords jump out at you: things like -**IFCSIUNIT** which defines the length unit of metres, or **IFCPROJECT** which -defines the project, or **IFCPROPERTYSINGLEVALUE** which defines a property of -something, or **IFCWALLSTANDARDCASE** which defines a wall, or **IFCMATERIAL** -which defines a material, and so on. - -Let's see how to fetch this data with code. Let's start with loading the model. -Import the IfcOpenShell module, then use the ``open`` function to load the -model into a variable called ``model``. The first piece of information we want -to check is what IFC schema version we are using. We assume the model you are -learning with is IFC4. We'll then fetch all entities that use the **IfcSlab** -class. - -.. code-block:: python - - import ifcopenshell - model = ifcopenshell.open('/path/to/your/model.ifc') - print(model.schema) # May return IFC2X3, IFC4, or IFC4X3. - print(model.by_type("IfcSlab")) # Will return a list of IFCSLAB entities, like below: - # [ - # #34509=IfcSlab('1pPHnf7cXCpPsNEnQf8_6B',#12,'Bodenplatte',$,$,#34464,#34505,'E4D9CD4B-CA43-4735-94-BD-1FD4376BD455',.BASESLAB.), - # #59290=IfcSlab('2RGlQk4xH47RHK93zcTzUL',#12,'Slab-033',$,$,#59253,#59286,'DA0A17AC-B773-47AC-99-C5-D390C73AD5CC',.FLOOR.), - # #59553=IfcSlab('07Enbsqm9C7AQC9iyBwfSD',#12,'Dach-1',$,$,#59508,#59549,'E142B455-80E4-4B96-83-EC-E1589CA998DB',.ROOF.), - # #59753=IfcSlab('2IxUUNUVPB6Ob$eicCfP2N',#12,'Dach-2',$,$,#59716,#59749,'BD6D9414-37DF-40A8-88-40-301A32A9A5B5',.ROOF.) - # ] - -.. tip:: - - Try changing ``model.by_type("IfcSlab")`` to fetch different types of - entities based on their **IFC Class**. - -An overview of all IFC classes ------------------------------- - -There are hundreds of **IFC Classes**. You don't need to know them all, but -we'll help describe the general breakdown so you know where to find the -appropriate class for what you're after. - -**IFC Classes** are defined using an **Object Oriented** tree hierarchy. Child -**IFC Classes** inherit the attributes defined by the parent **IFC Class**. -This means that **IFC Classes** with common attributes are grouped together in -the tree. - -For example, because all **IfcObject** classes can have a **GlobalId** -attribute, that means that because **IfcWall** is a subtype of **IfcObject**, -it can also have a **GlobalId** attribute. - -.. image:: images/ifc-tree.svg - -Important IFC concepts ----------------------- - -There are hundreds of **IFC Concepts** that allow you to describe relationships -between **IFC Classes**. In this guide, we'll focus on the five most common -**IFC Concepts** to get you started. - -Concept 1: the project context ------------------------------- - -Concept 2: spatial decomposition --------------------------------- - -Concept 3: object typing ------------------------- - -Concept 4: attributes and property sets ---------------------------------------- - -Concept 5: material assignment ------------------------------- - -Self-learning IFC: how to learn more ------------------------------------- - -Core functionality crash course -------------------------------- - If you're reading this, we assume you already know IFC and just want to quickly get started with IfcOpenShell. diff --git a/src/ifcopenshell-python/docs/ifcopenshell.rst b/src/ifcopenshell-python/docs/ifcopenshell.rst index 38c930ee91..e5bdd50069 100644 --- a/src/ifcopenshell-python/docs/ifcopenshell.rst +++ b/src/ifcopenshell-python/docs/ifcopenshell.rst @@ -1,12 +1,19 @@ IfcOpenShell ============ -IfcOpenShell is an open source (LGPL-3.0-or-later) software library for working -with the Industry Foundation Classes (IFC) file format. Extensive geometric -support is implemented for the IFC releases IFC2x3 TC1 and IFC4 Add2 TC1. -Support for parsing is provided for IFC4x1, IFC4x2, and the IFC4x3 release -candidates. Extending with support for arbitrary IFC schemas is possible at -compile-time when using C++ and at run-time when using Python. +IfcOpenShell is an open source (LGPL-3.0-or-later) C++ software library for +working with the Industry Foundation Classes (IFC) file format. + +Extensive geometric support is implemented for the IFC releases IFC2x3 TC1 and +IFC4 Add2 TC1. Support for parsing is provided for IFC4x1, IFC4x2, and the +IFC4x3 release candidates. Extending with support for arbitrary IFC schemas is +possible at compile-time when using C++ and at run-time when using Python. + +.. seealso:: + + It is not necessary to be a C++ developer to use IfcOpenShell. Please see + other sections such as :doc:`ifcopenshell-python`, :doc:`ifcconvert`, and + more. .. toctree:: :hidden: @@ -17,10 +24,3 @@ compile-time when using C++ and at run-time when using Python. ifcopenshell/geometry_iterator ifcopenshell/geometry_settings ifcopenshell/boolean_process - -Indices and tables ------------------- - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` diff --git a/src/ifcopenshell-python/docs/ifcopenshell/images/intro.png b/src/ifcopenshell-python/docs/ifcopenshell/images/intro.png new file mode 100644 index 0000000000000000000000000000000000000000..04acfcaee465cab226ad2e4dd54d527d2df1bef7 GIT binary patch literal 135621 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

Ty+WKY)p$Z6$RBFYxlrWBC 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 literal 0 HcmV?d00001 diff --git a/src/ifcopenshell-python/docs/index.rst b/src/ifcopenshell-python/docs/index.rst index 4aca839ad3..eb9f2d700f 100644 --- a/src/ifcopenshell-python/docs/index.rst +++ b/src/ifcopenshell-python/docs/index.rst @@ -1,17 +1,14 @@ -Let's learn IfcOpenShell -======================== +Let's learn IfcOpenShell! +========================= -IfcOpenShell is a suite of developer libraries and utilities to manipulate OpenBIM data. - -.. note:: - - This documentation is incomplete. Would you like to help write more? `Get in touch! `__ +.. image:: ifcopenshell/images/intro.png .. toctree:: :hidden: :maxdepth: 1 :caption: Main: + introduction ifcopenshell ifcopenshell-python ifcconvert @@ -47,11 +44,4 @@ IfcOpenShell is a suite of developer libraries and utilities to manipulate OpenB C++ API Reference Python API Reference - - -Indices and tables -================== - -* :ref:`genindex` -* :ref:`modindex` -* :ref:`search` + indices diff --git a/src/ifcopenshell-python/docs/indices.rst b/src/ifcopenshell-python/docs/indices.rst new file mode 100644 index 0000000000..d46b839f66 --- /dev/null +++ b/src/ifcopenshell-python/docs/indices.rst @@ -0,0 +1,6 @@ +Indices and tables +================== + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/src/ifcopenshell-python/docs/introduction.rst b/src/ifcopenshell-python/docs/introduction.rst new file mode 100644 index 0000000000..77c00a4a94 --- /dev/null +++ b/src/ifcopenshell-python/docs/introduction.rst @@ -0,0 +1,89 @@ +Introduction +============ + +**IfcOpenShell** is an open source software library for software developers and BIM powerusers working with Industry Foundation Classes (`IFC `_). + +In addition to a C++ and Python API, **IfcOpenShell** comes with an ecosystem of tools, notably including **IfcConvert** (an application to convert IFC models to other formats), the **BlenderBIM Add-on** (an add-on to Blender providing a graphical IFC authoring platform), and many other libraries, CLI apps, and more. Support is also provided for auxiliary standards such as BCF, bSDD, and IDS. + +Things you can do +----------------- + +**IfcOpenShell** is designed to be a complete BIM authoring platform. Its +capabilities have a similar scope to other BIM libraries, modeling platforms, +costing programs, scheduling software, CAD packages, and simulation software. +It is too numerous to list in full, but an example of what is possible include: + +- Viewing models, including spaces, properties, and relationships +- Edit and extract attributes and properties +- Moving objects, and changing their geometry +- Create new objects using library elements +- Manage classification systems, document and library references +- Generating 2D drawings, schedules, and creating sheets +- Investigating and editing structural analysis models +- Connecting and managing distribution systems and ports +- Creating construction schedules, critical path analysis, and generating sequence animations +- Creating cost schedules, using formulas, and deriving quantities from model elements +- Clash detection and managing issues for model coordination + +... and much, much more. + +What makes IfcOpenShell special? +-------------------------------- + +IfcOpenShell has a huge amount of unique features and capabilities not found in any other technology. + +- IfcOpenShell is the oldest and most mature open source IFC library available. It's developed since 2011 by a community of hundreds of developers and trusted to deliver many AEC technologies that power our industry. IfcOpenShell is also taught in numerous universities and cited in hundreds of academic publications. +- Lots of platforms and package management options are available: Windows, Mac, Mac ARM (M1, M2), Linux, Web Assembly (WASM), Docker, AWS Lambda, Google Colab, and more. +- Develop in C++, Python, or JavaScript via Pyodide. +- All tools can be used either as a developer library, through a command line interface, or using a rich graphical interface. Whether you're deploy headless server tools for your own pipeline, writing your own apps, or an end-user, there's something for you. +- Supports IFC2X3, IFC4, and IFC4.3. Custom schemas (such as experimental or draft schemas) may be loaded at run-time instead of having to recompile. +- Built-in IFC validation is possible from basic syntax validation to more detailed "Where Rule" checks. This is the same validation that powers the official buildingSMART validation engine. +- Read and write IFC-SPF, IFCJSON, IFCXML, IFCHDF5, MySQL, and SQLite. +- High level API for hundreds of tasks. Perform complex authoring like copying objects, cost calculation, or 4D simulation with one line of code. Imagine a complete native IFC authoring and editing platform where every function is available to you as a library. +- Convert parametric geometry into explicit geometry for any CAD system from booleans to complex sweeps. Geometry has been battle-tested over many years to accommodate complex geometric edge cases with an extensive test suite. +- Geometry may be converted into voxels and analysed through voxels to resolve complex non-manifold geometry and precision issues. This analysis may be used from things like head height calculations, formwork analysis, to egress distances. +- Generate and annotate 2D drawings from 3D geometry with ease. Preserve drawing semantics and link model data to and from drawing symbols. Drawings may be richly annotated with text, line styles, hatches, symbols, and more and are used to deliver commercial drawings for projects. +- Clash detection, model comparison, and conversion to over 10 other formats (DAE, GLB, OBJ, SVG, and more). Integrate with technologies like IDS, BCF, and bSDD, and more. +- Extensive documentation, user guides, academic courses, and a vibrant user community to help you begin your journey. + +IfcOpenShell utilities +---------------------- + +IfcOpenShell is a modular ecosystem of tools that work together, where each tool focuses on a particular task. + +.. csv-table:: + :header: "Name", "Description" + + "**IfcOpenShell**", "The core library for C++ developers. The library includes the ability to parse schemas, tessellate and process implicit geometry." + "**IfcOpenShell-Python**", "Python bindings to the core IfcOpenShell C++ system, as well as high level analysis and authoring functions." + "**IfcConvert**", "A command-line application for converting IFC geometry into file formats such as OBJ, DAE, GLB, STP, IGS, XML, SVG, H5, and IFC itself." + "**BlenderBIM Add-on**", "A graphical add-on that lets you analyse, author, and modify IFC with Blender." + "**BCF**", "BIM Collaboration Format (BCF) is a standard to manage and exchange coordination topics between disciplines collaborating on a project by changing XML files or querying an API." + "**BIMServer-Plugin**", "A plugin to the open source BIMServer CDE to allow you to use IfcOpenShell to parse, view, and audit models." + "**BIMTester**", "A utility that allows you to write Gherkin-based tests for models." + "**bSDD**", "A Python library to query the buildingSMART Data Dictionary API to search for standardised classifications and properties." + "**Ifc2CA**", "Converts IFC models to FEM structural analytical models to be used in Code_Aster." + "**Ifc4D**", "A series of utilities for converting to and from various 4D software like MS Project, PowerProject, and Oracle P6." + "**Ifc5D**", "A collection of utilities of manipulating cost-related data to and from formats, reports, and optimisation engines." + "**IfcCityJSON**", "A converter for CityJSON files and IFC. It currently only supports one-way conversion from CityJSON to IFC." + "**IfcClash**", "A CLI utility and library that lets you perform clash detection on one or more IFC models. Clashes are defined in terms of clash sets with filters using the IFC query syntax." + "**IfcCSV**", "View and edit IFC data using spreadsheets or tabular datasets, such as CSV, ODS, XLSX, Pandas DataFrames, and regular Python lists." + "**IfcDiff**", "A CLI utility and library that lets you compare the changes between two IFC models." + "**IfcFM**", "A highly standards-compliant tool (e.g. COBie 2.4, COBie 3.0, AOH-BSEM) to convert FM data in IFC databases to spreadsheets and other machine readable formats, such as ODS, XLSX, CSV, Pandas, XML, and JSON." + "**IfcMax**", "A 3ds Max importer plugin able to import the IFC file format." + "**IfcPatch**", "A CLI utility and library that lets you run and distribute predetermined modifications on an IFC file, known as a patch recipe. Useful in deploying a data pipeline or batch-fixing external models." + "**IfcSverchok**", "A node based visual programming add-on for Blender to interact with IFC and Sverchok." + "**IfcTester**", "Author and read Information Delivery Specification (IDS) files. You can validate IFC models against IDS and generate reports in multiple formats. It works from the command line, as a web app, or as a library." + "**VoxelisationToolkit**", "Converts .ifc geometry into voxels, and lets you perform voxel based geometric analysis." + +.. note:: + + **IfcOpenShell** and all of its libraries are licensed under LGPL-3.0-or-later. Two exceptions to this are the **BlenderBIM Add-on** and **IfcSverchok**, which are both licensed under GPL-3.0-or-later. + +.. toctree:: + :hidden: + :maxdepth: 1 + :caption: Contents: + + introduction/introduction_to_bim + introduction/introduction_to_ifc diff --git a/src/ifcopenshell-python/docs/introduction/images/ifc-concepts.png b/src/ifcopenshell-python/docs/introduction/images/ifc-concepts.png new file mode 100644 index 0000000000000000000000000000000000000000..454327678278a2b3ff758ec75166fa44497d30d7 GIT binary patch literal 21973 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 literal 0 HcmV?d00001 diff --git a/src/ifcopenshell-python/docs/ifcopenshell-python/images/ifc-concepts.svg b/src/ifcopenshell-python/docs/introduction/images/ifc-concepts.svg similarity index 100% rename from src/ifcopenshell-python/docs/ifcopenshell-python/images/ifc-concepts.svg rename to src/ifcopenshell-python/docs/introduction/images/ifc-concepts.svg diff --git a/src/ifcopenshell-python/docs/introduction/images/ifc-graph.png b/src/ifcopenshell-python/docs/introduction/images/ifc-graph.png new file mode 100644 index 0000000000000000000000000000000000000000..06e7f88268bb491e1f298f4b0650c5e6d865ed27 GIT binary patch literal 29060 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 LD@j#Bje`FNVZP&m literal 0 HcmV?d00001 diff --git a/src/ifcopenshell-python/docs/ifcopenshell-python/images/ifc-graph.svg b/src/ifcopenshell-python/docs/introduction/images/ifc-graph.svg similarity index 100% rename from src/ifcopenshell-python/docs/ifcopenshell-python/images/ifc-graph.svg rename to src/ifcopenshell-python/docs/introduction/images/ifc-graph.svg diff --git a/src/ifcopenshell-python/docs/introduction/images/ifc-tree.png b/src/ifcopenshell-python/docs/introduction/images/ifc-tree.png new file mode 100644 index 0000000000000000000000000000000000000000..5b0a1ec32da38b9f4de8a72c1ac35102e79b1a18 GIT binary patch literal 51462 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%0DVoGRl-Bs zj$^@7_R^A(nhW)ci_>S-ZYOsh>|lJNrb3^T5keUcEmUfNOG-ev25<7mzz7Iuko%8{ z>{da*LpYX8_$bMD_i&fhIkxg=Rh3_(%ccPwFOn$W z6c1cObg=*@-;{S&J%y^S24-I!za;{5#BAylz^V&Os%LBbh^U%if1F>OW8c*)CPOkvOW#Hl!T_?z0*w}mk=~&`(==pr`X?%bKj~qg<%l3}<$nE&J7iw2!?ko` z*XYIRarDaIt%)iy9bPB$G|kdTV225yFAuzM5G9#{k{JwhUqFHmp)~pQ*Ng*=l7Tev z%VS$eivxcFxG!#IzcHAei#d6~Ug}#EsbVWo<0scJ-1tC_870+*wjbD;_Cf2&er{Mo zT|u9r{NmlCq?I97jbNM_DLFaj#>-3ogi0$K8k$H*FRieQfp2ZK(GS=x4Q?bYD13ZC z1crGiw*PCtE;Qg9B}Web(XogK8I;R6j~E-(9p!KA4u0r0Vg(xemwEANN1@IFzwfAjV_CGGa2 z9ZI>4gM*gpqjk!q@(cr;4QIiW5dd07DD|Vx(T8##UwT{$x_-X&I{oE-q6E_(5CM0VdWQAdT3cI3e&G1_f9Bj$x>*UQBqW3h#dU*Dv<-m> z+-$ToY9D&?n3Zf4>S2vWcVZAcE%vxf!GyJY-Q zx^yn#38RhHp$%)Y@ykyd)}ThQE9=d?>q4CF;GppCGDe?(f~2n#>7vS@2oI{6GCUFXCk03^{xrKyzgQTM z2&N2Gzyu7CC42I{tt&b*KH6l%7T&=x-qHT6fSoU>$XP*P453&mdnUB~u4GOoke9Ky zM#4LkvmI9c+#6_Rz`l;2{Hi9t50&9gp5;xR^*QlwusY&SaMtVB0L+=^xbpdPq zBVyv&Nz`TekSa~-FAw=gX!rO#czD+^0s<^^@@;;n%L`Pua?<$SxV_ZlQ|GWumYw3> z=P6`&aEO{;9KQ60Q5#QEuN(jN=d2}@5z8Z-Go(4^e{;*iJ`tinUh2u+Wv<@2?&t5{ zb9VA6q-FNEMN#9&Nq_%eqn7*Lm%W9|2Pk}*5tuD~i;oc49**o-cq1#vf4UylA z>bfBskvJ}s&g;JQjqC|M$0t|GVTH-ob}4H4ZI~#_mat3Hd0!nu45158Ebpt|n69X8 zABop~@0to(NkGcQm&l_U*N;gcca&@ zNx#L%`$|x|+luhhV^nR-1^wO^XgIqJyjkC?l$%@d>b)kfbi8a+{yS>v_6*I$RLB^UH*_l5w%bgU9YBjXR#|TI)w7v&dNPhX^dJz>v za?UqzY;2TTUT!r|B#j+Mp+@j~ug*qKZfXDIx7|ct;xi4q2Usb?wL?e^0<1yrF5{&C z7{Y(A^K*=ceS6grQQ$CEpG{Ej<;8Q!pq>4^(q-Dath{o5fl!_RwfSTsXes)BqW9Vp z!p=t2CDt1`DN%2|t&HWfne?!G7Hk8)lWuS$^t?A&juHIZY3Zee|9!dPR?W+^e=um< zY=`*xj+CRaY^JNIvB`fqiGTiN!kU}PW9_|;$6^#=@{}bP1(oN5qTNyU>&GJIQnbq`kAj8A^%jI&*?n4bR>cE0U| z-k>%%4V?M=7pTA_*ZFOmxqX&~*d0wnWx}be9U8_cDKk#;KSpeIXH$)~M<*no*E6vn zb)|?DA=>dIJR6MU7bHn<0tkeO!V~-8J(l+z&p2 z|N8m_VU=IV>e!8DV(pL+i28=W3_B&+bFPKX!@je(^9I6>k@mEEbJxF#rS|U_t)aw4HPqa-I|y^Ry%3!d9hi^v+6g$+tRE1ORIpL zDqZDivB}}nAu$r68$o%9U>SD8}a%YAn?CW@2 zCY?g=_cyXsA>=zJB>Dz}0}}JcDVA+9`d)&o{Zza!E{_ttMh(M+a9JA8*}6^kMfA_k zlK5xsYIn!ieK4bxz7#c_IJXj zzFOK~VHS~duHgC9Ex_{`+$Y|yN6Ye@{-JJ}bu<7`V`OpyQ(b~h6_qc>`9`9{?qMan z3;p1UC4ywcw$&X7A9Jw~YW8xEL#M4;Y;5ZBxFFk z>F)vUim95iBxP%6sZOb;eF5|Jwf_DQ?B6K^_jdwCJG;Pd7lj<2en?Au!$Q3%0GuXI zoh)`&?~;#cKjllh3zT!V(CM0+jSai>6Lu&r4JLMcM9A{4>kg88+5k2^#)sz#p>6oJ z&a!+1BI!6E)zUvoW%^2GirUz)Jk8I;KLua58(0aGq1X1`5>b%nS>49N(~Xc?SYB*4 zC{8QRB}?&cz$9B;9jSXyC^TM-Xjss0?&U=b>_xAhJa;k1*r+9IFm6lp|45fD7^6gq|9+05I!Qe5N$6tMd-K8OU}taaZ0*ketY{eqi=v&o>?0F~TKmn%%B z`pe+v)aCfPMx{GmVg2Q;J6cSoLPvv?{Heo%^F5W*O_1^fhV^_fZ`FIZThVfrZ@!1voO|+kT9C*+m z^0R}ezc8KvuVK2H@YSnV*f=DCg z0=b4cbI!T%z1LoA?R_{%u*52FDZsxp;hUuAW_mzGQf@ek7^uAbYxeJSuDY8i^;8Q2 z5jq{Lu3(VF-?+I^WnyLSu&iD#_q!IInGro$ha<0EdU&gu_<0+8HdG=g9pU94a0J(6AX=nFRytg!45C@t~OF1R$U_X{3HGt0%_>hLxUy)livw&cz83!ZJ) zx`BZ3pc=HCUd=ga)YaDK<>e8pBoR^A*u*I%d)K)4P}k2zsr7OW5%bUw zq@{jzkkI&LVh5Ko&#n3Mn9A$q)rEI&*HR&N^kMAI(4~;JD$Zo7QE*0SDZI%H`9>qg zD$9fA?sm)Sr6Gw-GIPv2#NYV0e1o|YuL7f|r&mhTlQqbzYeJ``s2*#v%U(sAFH^(H zkWKjmF%vyk3e=~ov~FV+G=$Wf6*Tm)!Fu@hBP4oQTs8v+ z^4;NZbHDVoaN``;fds8kOpq!I`p9+rmGS3C2bbRDJm<0;N%T59vG{Bl$QkPB5Gj^~ zNXH8Wj6jI=63XxfW$UE#O)sX29;sbDwnSE&`lq3{&O!(7Vh5h$Cgh}8!N_7GL8hsg zvaNx1*?!shIB+R4QZC;;2N8(0zYa&R9G{~p@UDMRyN-S)*y;<5TN)di4!@?ogV#z= z2sh4iJxF``9kp2rI|h^hIb>g?i{ zTiHm%QGkUPigM@}&ZE#9)?4dV)A_!Z?m4`zeQM`4x26T-(Pc^DkJ)x9$-!hKCf0B7 zbeGRZ-^+kRB}RjP^hsBX^^yOE2RJUu$T7gL;4FmoJT^B!q6|18A?PrW&qxr`N?~YX zBY6e26i<)dbiS+2&UdDL@#L8S7uks?%4cHj?p3rwlp_1mj_p8~l~x~luL{eFE1R8v z&LXeg8VAqqlKeH+!e~^LxRLD%-z^ohmU(gP2Zo`4>Nyrel{R5Yj}iX&1C+6s#;-jg z+m!F)6XTG7N-&t*_l*xj5!64z_?hW?HjQ^1t@hR3f6`ze<0tTvI#yyXdC-6dI1n5Z z{GFJ`EF8d;Cg@rVyWw1@#z5w7)ksiY?;A11gly7o${NbzRz3ezD0)*dIS#^u**sV8}O-8*BL3Cy6?+G!WFb4ow+h(cvWLZs*SQd<p~QFC8vOxRcR$>a-)f#L02 zB%Z2K$C}+f(fq@W@!(+ias_<#uwB4hUQeF)j1&ZVybUMreQL+J|v$Le&4(z>bZ$Hzw|zEzZDM5Ct{bWH3e5wu0b8Ye)duN5L$su zct;0PWhvZLQNvzvC~TrO#-{Bas{bpsv4dcFZsXA+b{^ z0UNItp`jSEYe;iR>x?>-A)1K6S39^NN-tha(m!QH&yXWJkcIcg)@8vGq)IU5)eK4A zMX}r`9^DzGMpZgA8Qkf`8R3iB3F^;&iA#!)l#Zkj`AVY)ujVx5BKyS=K}boAjuhx_ zmuzZvYSyuODCO@T6C+^VJBZ8-Lk-RL+<5#ULiVj_P{c333wLfEI5ADllV?xTcePIkH$X%Mmd=Me`Uw`i&BHT_ zAG6wC#ToJP90~G*UO&IkQoSf$CI@DDG4SRGp0wkZgaapf5Is>P@5vqtq|?Ro!dpUT z>P_eBslNqOiAk&IHGPr&cX>ArRtTMh03l(B5?G#QBMV<4{#`KhzI7}NWC+0pS?-0{@&P*9ZQDSOH@SN z_j^{IvA{TgWN&9c+8+~B9x$UqdaQ|~#=c=%Xn+14_xW+^8wMRpfvuXQLuw2-kCOUP zd6r(|yLXvIhkv#SiYD%0?v2xz&yyt$|FZD*K)c9MX7zJ4O;5z;;enHe~DQm4w1m z>0&lKprt`b@fEhNlpt4v&HM600(pT{u?{^4->Dt;k#}J}yE}@Y zRw3%|l2h}NV>2Nii`(xD0oP_j2d_DdS06?v$FN2m@;$=kR051Jg`-d>L<|2VE4<(r zjF`oZc0me|rF`;XY)<5=aeS-2HCNmLA3cc#6{)-6+C%`qebq59ubNt=01NzeqF_cb zum(}NsI2&L{j8|IsasS?&wJYsAYZz)^Lm}{hjfdu+3D&e<&@0QD(4nAf>`|v`m%31 z_!e%aQTikc-G;r^r6%I*5L9eGXx^^3UCOWBH(V}aTmPQy-OCTeEk@-RpwmH7$NU8d zJcOhI&0?}^OMqbm$yN1E&lumpPFISu-<(fpO6Llt8AK_*e^AWcaD-K=g|~uCUrvC} z4x*mB{71>#ZO<^G6{1&DnxdwD_Qu>eR~W#s3gyget8p7IPuZ|B?+Pu; zRA*xQY*MMu^TIq}YKMfF)QKvzwnPxXv7GWYT`>iYBrSjkQ}q*v+4+SHRb_hlcBAwh zYQJcL@;r6u&fMSUR(up@y+rQY$d%G>T8Ssq8T}^9D3v<{sx20!;HTq`HVA&ff=?I+ z1X4jk1}=dy$)L~cU2fqZMS6NRn?}VD#>NlWbYjt?hQ&m5PD@H2M|&VLzI}-ptm1^a z+7Y8@Cyb9xAV!>LCg#Hv*PN|G{K7+F)}sK7j5j!AuL^&~tEsDpKoYT-PnJW3Czc)H zJgLs??Ey8$G6qC5GdJh0CCw<9hsrBDzRmEM?JvLlhi&+1%5^_VVzNp*Cpyj?!Q$36 z7~+>E7Z)0y799yQ_goVXBi-dR@s~^nSO|CZbVX7VFKNs_OH#?vWa}QRV`RXMUlPL8 zD#ihDa4EFyBm2x*=W~T_DS;c*E=Q_2Wl)p?tw%u;$n@J|XWHzqm7+9|A^C+Ck1{}B zL0fnD-2Z3InHT z;xf`mj==`ugD0FIN#IO}jE+LFo9AOvQ=wgHE1|~{jUCEDa^$%LwCRu# zQ_Z)=(coe@ySii-HzJupYB^BZm5VhwUjJ?O6BNrF3(g*Ur9d9d;_O<-D8WU@l|pPm z!hGN^9yhSoH~cF|l|$C-6G|>)9=I_`5O8cIWtF#cludmiBC}(2zlA*U6UMW3*m$ie z5d4CJx7cVC0K^mN-$Nv8PDdd98ft8s_wggvR-oi%eNzCXdkNG#NNW|Xp zrKXV{GT7|Ns{jOG*Q!Lv3=0(L&|^>=c}@MEffe;_`nT7>;)02FA{~Cnl9u+p#cVVP zSZK9NeEm3&&X51&89ftg0YfLxOn`oAkb}@Jw9PWF;TeYi)Q-HK2d`7r5i)Wtz_q2u z&4hr!bUB@{2JzV7Bl6X?SHPO{-(6WHW%VFw+5mt*m@Td=T~Ee<)V5xU2-O z3AD6-=im!o?3-@+mKyq?f|4yaFG`6#1`Fw8Ht%OO$<7;l2%P7xZ#7Wz$Q?k7e3n8+ zPFdCk?#PM2(+`4y8{+OmJ*JqV9%CY5*$Vg|IMywTl` zR7AvD$hpR`WZ!XvR7e=mzI=@t-+G)!1rA02Bw-tFSC4M)f$Mra;oIIPuA||DPl1}> zp;`QAMLR*2B}ko2!bWkpiW8trWGBz)Laky#)3C0k7ZwW8AEe;W5Xk!^k)``YNOpCJ zSp*{{J+?G`3IJ+Nfu1s`0~G`zerokK0y()=IyY6ISj>47nq6dWFZItLbHfTpYi44c zf}q4+tbSHJgKB??;cMZye2p*e`wktBoZfJ1Od&t6NXA);pu!XEQ;G`8EEgZHK#fqON;)+<*%Lo5I{;O_fdi zwPB1%_+zSGdd6qioC8CRnP$O7ve{bSS(z5z+PPU~ zbi8dbSX?9Kgtb`YZ{dl_sX_MXC5-#H%(e_w7JQ6t5gA%LsUiWK>z7zLTHkLrzM7k> z6zG`Ff5El>_#M1of~y$C)-=y1atkmB5O-xr9icoh_wz|s$?V&>1g7o9GxEzUTCH1Gq1w5AX@p%B3CR4t^DS`%XJfQ zq()fPM_TYng*KtqwQkPtyhheJkWPvKwSp5nTn<^*9Tk8#z;@?U7W=w4SiTF5Bglfy z2TRhuA5?7$>Tu!&S2bUYB7=)HpU9?tgD;ILK7{QL{~xN8+GG`PgO!Z76OYW$iT43o z#aQHZl*SE_G>0b5$Vr*kV3@du*6!1LYh)BJTCVFT;5YILd=Ni|s;W6?sdZOTKSLu>584dtX%E>$$ zADNJ&6|-*v=8u@D__1k@NIp|RI079)VKZG+#L@k$g5QK(;Um7~7q{1DvFLPlUJvI# z5RnZonV1mWFq=6!+OEU3JSe*h_&?4Z6U+Qlw9+M8xaytVP=cu5D!9k5o3D5DUbD1J zf#n$3|CrV8rBJwEs?{lB1)d%~1~LG6-FQp_EO;ztoGlXE4a>{T@it5TlA)tY5=9e_ zb|4pR`QM(rUlY35T*D~P3g2C3|FtY4j&-DK)@La%W5AHFb?=~Uq#ocd5wlux7{-Xd zWwQo(pdK0;P$3xZfl)ki^qiaqbELEOLuk-Y&I1o0B~>NTtVDos$xjGDCR_@cb^dq_ zM$SH7D@TK@ZIn^@9z|rjT5kMZ4H$FPE%^ie=|s&rDc#lG6WPxNN-N}<52wFQL%pvF ziBy?VjJ%i6PX7FP`)~c@V84ua3122ftz*V%7nD7)I(!V#@W^TufKG9QT$a| zWE)f^caEeZO&xd~jSJtiLK60@$Sr@%!4jySYhSCqv8*~uuzkry1lz$2IAdHRV7Oej zNJ$zphalDBkTriAal?3@o(LyX8ktiFMY}Ow1&wT=a32t_oJQ}WAZ{R?W;VkrFgySz%OPI$f5b@hR7f*_V@~s7NHQ zEe_q${xydXs_NXYTZP zZ^+J?e6d#PKt(-CI9Ne+ol0xF?G!F=*zBk(E`rHHr zB!F%3K?9V#k^cbVWMfI|gXWu8$mLUv3NA_-Lm4TW+JLYu)M7uJdFki-)LMi!#569Y z8ks8vJ88n=foyAEv_Z_bQJUtM0_^?D=v83GM?bmu#X2m>JAj3=^@%2LG&Psh6Jz9k z?1s3MSh{m_b<57H#vOab3+!_W`YItpsA5FQfVIiX1&I`E5)cOdIM<>lqwI{;t$6u%n6)(^bqb#OW_}IDfHFYI^nEkMgXy8hYQ2v z2nXJkzU0!SqnDr%8-bHkGwyA8(2(xXp*^$Id6U#ZZgH|thWZ#hTDJ4H*@QA7EtwNH z<^d_894|pWCupHaR70PEp!Uk}Eybu!QX*N7!+n{}b5dd%JnY+t7%_Nk$r1mfrN zAL~FU7BW`p%NTiJ@puyoJ0<>FpUxjZ%l=T5TbZ}o=$&XSK#3w zr7AfB0aZpC!MYa5LH;H;vVVNtgkXg>H6n%cQB|MJrHNXp{@8dd7@uQe&O( zBQMb^7{u2{eEZCu+dmV$Dzw?N=8O#Sxp<5y#Ff{x2yghBkftc>4WqIQEc^7?fryuK z3#w8;q++qu{2e4|<&pUK$HP!U#d-lW<2%2cwesYdr6;iutC* z<-fi4ci-=?htsE*1OM&3u}JaCz$H167x-d;j zpsh}vD;=gEG+gG20b!y-2SpX&aR6~hq>kKZz?DqfZ!C9n`Q_@$b9vRrq@AwEI z6l1b3N8)HG<>eHNc~f8^V*s=Jiq2PV5pzsrdcN#&f3Or``kn0E$t^ovL+1JB*Z2(14{ z&2X+{Zm=0g4pPhveQF!BEN=B&^+E!sR;53@t#bG|nr-^JCz;tD3m#=;kG&i5;-+4=d_PD>P4gQ}f^VA#~ zGstUFyGKgjU;`gz1E{jcrYB+={yuP-K-h?eH8A~|d~No+;|t1NdeARUlymihA3q=U zD;FanUI2=a)M3#_g0{toTNQq-mGFFYdK%3z6%VYET9?<*>TL|55uBX3#wui3O=BVi zoqnF3y*YfIHG#9KjU+8%HsYlwh}f4BXy)-94c`MNs7xqFffVWjBh!c z?GRsLSKh5!abwu0=Ba1U}r=^~2>CwDAlpcK4Csfg76uX~jW=8sq{pzTZ`JNx$%JVkKY= z;xud_vm*#sbg^4d44s^xn_BnLMtAjIBmcHj>Zt+bGabk8Fu>;lMvM+{GMu1Ax9#@o zy6dQV`S`L6PpLfCU!OYgYjc~d!AI|Q0$O?|p^8~rzg^iMZ>s;CrnZ~b=jA)MHQ3hb zj|q-qr_T$NtZFAb*LlBYHrguD-A&5PV3$5Vc)TwlMK*WehbrXZZi=}FC@xBI zU!i#}pQ>dEPc1uVR~FOoySxS!r-Jv#zze^^#V3{09xKKk6II;|V66(}B% zz=niw$Y{z!RRu)0z`p+C++fY2Rs48o@*_;93`iA)4aPZ|bkCU0CO1Z{YpnOJyT82N zLGj(HHd}C^+WzwP$=LXYpH;6vu%}3@nax=8eIY;`ppx8=7faLf7Mhgdz)jX8YU5N2 z-sQ@+-``u+Ay}7zFq~8Lp-B3Tg1VIXr++-}C1sNHa&g)Xf8$5Wq+Ycy@27_Vc zMd9dPWuc3n8>h2NZ{n)zn2`)oT_8*Xb>+9#^H(PTm`-%e+qW&(P1 zP8a?qPX_!{f z{V~0$u3mVN=F-Qp;0D3~Rd2xRgV6NOx-Vkp>OGJEMxbOyOWns0ROt6R`fUOrHjFM* zR@{$Z(jSMq2vK>KxVY{kS*~Q6I4rfP!ew0ghK!IP0H`XdpKM%*M}&oF6$ke21oZA8 zjT{5~4s=sf8Y(F-#ivz#$Jsg@^vg`dN2pt!&OA8ym@KRX5gUh|ilQK3(D3Z$YZZ5= znL~g8LVU0X2Cp(o2psy~PntMaAPP0vDxJEYI8dPjEk|3G)10p{RoQK=HMaEmzyQ1d zEJEhf2Vx@P&)w!Qo8kbtE6~cmr9vTh2Co=_2}m3%LBEHl*U_N;K4`5=$B}x@d>t@j z%$uakR|!}>XuH}avMgU;F(>r1_}ueItZw(=NI|q)Ay(upbST_{H?1-)$vSA$sl(wG zdZ0mN=wOP<*PL$}=Ea%P&#HQlc5^Po&TG+mcZd+Id-lz1U+Au(a+Y)8LEy0U00yUQ z!OjPrK_N^Z_pADp^jG2s9i0%E6w4)U&t?DoU+NY4@5^yICRw7gi!iZNS`;Sz%YiND zm6R!iYi&;_1{eX+3(xDTgWF-l-YOjO#fv^H@V0$gpT*AlWZkUt3nlCRkRbVo^oTIA zFrni-y9APtPEv;*Ei!97ohTz2;QS$@(-Q&1Wq3e?s`~w@t3nawpD##$atd-C2-iHI zb0aH|ePHQg2H6Gt0G=J&J{m`oG_ihgC;|2k(9+T1JP(`ON$Wd#(2v#}*G^<(zVAPN zlA!&hH!-Au{Q2`H*DsqEoV)llZ3FukJfpG6bu8{JG)&C-Vf|>-c%q)yo&;p+5NQHU zohn^}25iVHLIpW;hOyZxTy0e<11{EIPhTz{HUJ_g{%abjLL+*#xVjzjQ;FxI6|aprW+N; zIK`x04MkseZALkBalWd?EC9I2b%#1EhN`_NkST@Gr(2Si4wraw_-Q z@BJ<02>$ui6-eqYZVKy7`uX{^*#Ax>0ay=&RbIdseNUKvN6un`A^41$nBQdrUB^wP z@3(1|&t13i>#09noAU0Nw76Hop}Pg>xh2imuj0pmY$~HWGS@cO6B%jnADBzN7D4y0-|NLNd24-RE+SAwQE-~w+1+_NA{b*Kv)8jl4 zu*PKQb+elbi9zueMEXfeY5^twOGeD+Dh3Gp)EJ=*=}4{|vxdfy*pk^g-yNCOT zjOgK*%Ak&v27C+%4qd&MpSZfad-57;FvP_Tfwx6TtzXLCSP*>96`fDOLjnvRVf@`1 zeeI%ELp(U|a@8InCUAp*#|T5?+pzp4l~=-Q5^(_W%e^uW^#w-)+By@_ygfYHhZG0(TsU`t4h3oP4<>24x{#i2Z=APgXmk=)AyZlLvzGFb0wR>UArRH~25>kfS! z{XryS1I~dQz(ThGMW2Q{QH2>mG!&z%0dprnu%JRC%F8iP#U#}vTCCAWlPv!VW z=I#;}=K-F$bkHt7+nNciS_F7p0_k!LgrXsp`C9BaS7Gci6l&BG)4S!B*FAoR(foQc z@WT!48EC4OC@=K|w`7egG0aWG$bG2j{s(Gcxfk zF$6$XR3y!k*TfQ(AM(W&`qudzqsK4q@i_-NLBiSslb;+kOB4!QynY(}MdVcy*UpU9 zLP2#yFMQpC&E;`*DA{R}>P`LY-QlHo!?tc)&!^d}F9*{PO$?Hf*Q}8CiK*KsQ-+^# ztN5NnAogw78nDn%v*XI%u=V@PyK9VBC5H*m-hTV}@9S(`xbe4#292vCM*1WbULm2d zm)uPRkkQG77~AuO*o`cYCmRM1gba)fOQ)WI1>Re%!zCm{GjKt;0DTb$?!PJ|cC>^7 z%-rKf*P1nL4dk(6XF@U1dOTpO+(hiau}41k&Yv%YJ}qyc0`~y3ch0HTP-yi;yVCcE zU&rzAe6!<-4?f+bd7y^_!A7PJsjuhn@C2j%aKuP-qp;D!=DAtwjD+9Y@T)gxWQ z{i~ViSO?c8Lm(cP*p^0}INw~4k955tbGkfb@<-w%r{?Zt0rW{N<)+{!Yyp9dr^zp5XaSu%TNY)~e!kamR$4Q9~ zKGpKOQ36Zg{eR`S5#Zvj?Ywiz|X{O#(~RXECn8 zq_R3qW+Z2)9@B7tm2TG|OlA|zhlkc5-^;xGfNgX${`{hw zf2wo!5a^nrZVO)chK7bh8_4%^kLqV1KBgz{ckbWsYcJp^=iJ-;K>_ooWV86~QPU2( zYwOFG2#w{>PkqDd7cO@4En2}E+uYe9oBiZ4%4ce=!IiMQ$>Q_6nY?(CgO48ykmW$7_BXxzlVj??cU@+{?X|IS1*fHj zXa4j;`2}#)YQp{X<8#~Q>+E|pnZ6?;ot=z_Uag^(gI^=+qK{-{1t3!T@4x@$MQSgW z6sP!VXc!h7-Tiz-GPbSd5Dl{Q{b`w$K`Exj{gz(K;lFg?d^T{MPNdFP)9rpztEXV&pcr^`uY=mv z#-_O8@@y6ff&*V!zTTI2c20cw0R60){!Jlh`QP%FW;a^lg7=sB3^;@hXWyTiMuexG zHSJ8Qek3c_iAZ|-26gu-G#)=qRV3%5{(BC)pT}H&x9v+E*LwxUYcNj2%#FjrPuy#~ z9|Z3Du6|*U1h1J}(Vi$9d#;1${=HrW&UHcj*J9qberLkY=O-q!U)?yjUD__TpNd`I zL~7%8$4_4#tZE#@y4{EC8hiT!>aVYA9AqD2bz!xGwvNI${)e)&{h$OAayxBP_%rcK zhL|MlBMVByVN~gCS`JW!(lxI4?2y^XKcxpq0Fr#&N%deip{uK`e8vBX7kqDiSYegr zw;aS@>+;eNAN)YB>&o3|+qF5li3YrTm^BjL^6&;l|GOiN$viZ`npQ)vpF$uQFO}t_ zb;Q3A0G)@)8fc>o{LGDE|KpJvK@-pZ&83X9V7_#*RK`R2*90!y`n$*;q#^T;wUIB zd8{k5>+8wdt<#BQ`eaNb?=Rq#2?<1GCk*rAcib<)E(5>=go^;A<3OW?!K~I+-vcrH zPB$swtmP{#!TaNK)};mB!0(vBuz-Sh3=JRh(Gy>YDn6 z-!(h1<-<5X9E+6dRxr>8d!6St7g|YZctI;?sV}y7>?^)!n~arzO-KqJF0%tuH=kYA z;**GttS)Bx)-fRz`*O?ypPy06`BCBwI_Gfw%zA$_bwrz%bkOp-UF$7gsw(ea=fSZ^ z-`k1`o|~hi0fzxBjpaf~QQ|Edn^72S@SGV>8RXj6{QMVVJ-7`UW!D{5FQ4nKuV4>{j%>h#Q;b4j zFn8EaXG%WJK=c4Y2K14BPWXH{n4lq5oc|!Zz<5QT=o()Rkfrid`kDIx03zd5RlRYF zJoM}bTX|8on`U*sPG5XxGQc5;1!Y)}hue4^`O<`j%3%9DC$>`>4hW2(fzzmpmJ~)M z<14=6?@Qx#{^(CP8WW#irKHi6~XE&N=!@%)%u^H0h~z7fsx!l zBmcJ&;X9{+8y+!31O1mAJ=O->S3shqXE-a^$nw!H9Dn8_4n#@f z8cnr7e){~Uvp}mocosf>E3J801nB?2*FNC@M$06pxc` zf--b)48G0*6(QxfwubXsy5M~n zFrMy2k@WTd0#o7$>(BRQ@#}lV5xf9(Jv}<=LIeaL=}i0?bpO!>Lj~|YeD?Lk1bM2d zAz7~z6m%z1^&&w)x5612*@(L&0JZTMiEMU)^)4gfRO8c$oYOU7i&xC}g1oZ2uNK9w!ZA5eDj_U z@{02Gy!6s@cJJ*J6soUimuf&(86klOCWfG54z+H zY1k}K4lPJXo*O*{U3jJaOm?Wf{G1t!3f=-ucvL{xh)BIFckVd zuGNhIlRaP5z8Hb_4o2aCa2PRr9T~aoEK(@0K%bIl(v9WlHTn+R_4TMJm9WUrG4f}2 zx>J7um*zKP1`X*3u?JAukmO#s`st4f7A`JQ6ai$Tf9Ik$jBl#PmmPd*?W5*8Q6Ce) za3GG{ZD;zwK5@4=7eY*DFo{JBp+jwp_fz|dbt5A8fiJ;`n3ykH_;cR7-!}GUU8me2 zns48hG%Zy<%>tv&(;G=X2t{#e|fb6HTG`Zd^hu{J@LbwAs8 zR@s|%;BVV!`DG7WPw)B#=T!t~^3oE8h#l`9r}=rTp1$=!8g%ZZI9av;!!}1k73xNW zt}~v#otY6-^?D{HwAHnm)w-Je))l3_)#qBKylL-B(IX&5%`HQRqt^R$tF1gqi3u5E zaJsXY0fjc4mu|%%`Ojr!_}yMkTP;vbOLz`A#)@Rbgl)x`>P>nrin}d(@4iwzg|suq zN@m^F>CvIH+7A)ly2WT zzUaL{?YHFNJ5xG@72LAFGa_+2)OSXrYviN<%4W-EoEimEf`zqkHLk7UEmU>8s`fV1 zsd+wUoElykwNV*L+h}@w#cv5|*bfMu^gcN3ox)1J^X=*)ce^_tYO|-Embk2W?mIg_ zn;y2k z3CAWgh{^3$t)I_PyrRS-NXkGrjn^FJ>`$X|&l!=b(wzdUtV=a85t;xpSBW^Ml)Sz< zHXe5V0lR3hU+}yw=Z^VE>vmLJkDrRp_YIsq+}O3h``pm=Fqj{5@Nd?>SNv>k{2U{& z9bEo5dA3)(tN43V(-P;oFNcBsdX`U{I`--?*4P(J)o4TYAI_wi9vK$fVJ{yk%-AEx z>=g#b;BMnfQ{{p*tjCDPTpjJ$bAR2^XYW@QU2s25(p9Xh&vgG?CmqRe(_}`@a|62f zJkEu!zD{6329}$z1qBys*4sqgG9~M92Bb`&b+&zb?v>`}!(`__ +- `Official IFC4 documentation homepage `__ +- `Official IFC4X3 documentation homepage `__ +- `List of all IFC2X3 classes `__ +- `List of all IFC4 classes `__ +- `List of all IFC4X3 classes `__ + +.. tip:: + + It is recommended to use IFC4. However, the IFC4X3 documentation is a lot + more friendly to newcomers. + +The official ISO documentation is written for a technical audience and may be +overwhelming. This guide will take you slowly through the core concepts, and +leave you with the knowledge you need to discover more. + +Before digging into theory, let's explore an existing IFC model. You can +download this sample IFC for this guide. + +.. container:: blockbutton + + `Download sample IFC `__ + +If you open up the model with a text editor, you will see text similar to this: + +:: + + ISO-10303-21; + HEADER;FILE_DESCRIPTION(('ViewDefinition [, QuantityTakeOffAddOnView, SpaceBoundary2ndLevelAddOnView]'),'2;1'); + FILE_NAME('AC20-FZK-Haus.ifc','2016-12-21T17:54:06',('Architect'),(''),'','',''); + FILE_SCHEMA(('IFC4')); + ENDSEC; + + DATA; + #3= IFCORGANIZATION($,'Nicht definiert',$,$,$); + #12= IFCOWNERHISTORY(#7,#11,$,.ADDED.,$,$,$,1482339244); + #13= IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.); + #14= IFCSIUNIT(*,.AREAUNIT.,$,.SQUARE_METRE.); + + ... + + #62= IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.00000000000E-5,#59,#60); + #66= IFCPROJECT('0lY6P5Ur90TAQnnnI6wtnb',#12,'Projekt-FZK-Haus','Projekt FZK-House create by KHH Forschuungszentrum Karlsruhe',$,$,$,(#62,#374),#49); + #77= IFCPROPERTYSINGLEVALUE('GS_TimeStamp',$,IFCTIMESTAMP(9685146),$); + #85= IFCPROPERTYSET('1mnk_H9cG6eU2r9ped0WRu',#12,'GSPset_TimeStamp',$,(#77)); + + ... + + #15033= IFCSHAPEREPRESENTATION(#15026,'Axis','Curve2D',(#15031)); + #15037= IFCPRODUCTDEFINITIONSHAPE($,$,(#15016,#15024,#15033)); + #15042= IFCWALLSTANDARDCASE('2XPyKWY018sA1ygZKgQPtU',#12,'Wand-Int-ERDG-4',$,$,#14983,#15037,'BC6F0F70-6195-495E-A2-FC-239713029DB1',$); + #15046= IFCMATERIAL('Leichtbeton 102890359',$,$); + + ... + + #15231= IFCRELDEFINESBYPROPERTIES('3Q0nMR5elnJFWzAhgkZqe1',#12,$,$,(#15042),#15229); + #15234= IFCWALLTYPE('2AEMyYvIjlsz7LRzqYHy64',#12,'Leichtbeton 102890359 240',$,$,$,(#15244,#15248,#15250,#17288,#17290,#17292,#18637,#18639,#18641,#19015,#19017,#19019,#20770,#20772,#20774),'8A396F22-E52B-6FDB-D1D5-6FDD2247C184',$,.NOTDEFINED.); + #15237= IFCDIRECTION((1.,0.,0.)); + #15239= IFCDIRECTION((0.,0.,1.)); + + ... etc + +The first thing you should notice is the line that defines that this is an +**IFC4** version. This determines what **IFC Classes** and **IFC Concepts** are +available. + +:: + + FILE_SCHEMA(('IFC4')); + +You'll notice certain **IFC Class** keywords jump out at you: things like +**IFCSIUNIT** which defines the length unit of metres, or **IFCPROJECT** which +defines the project, or **IFCPROPERTYSINGLEVALUE** which defines a property of +something, or **IFCWALLSTANDARDCASE** which defines a wall, or **IFCMATERIAL** +which defines a material, and so on. + +Let's see how to fetch this data with code. Let's start with loading the model. +Import the IfcOpenShell module, then use the ``open`` function to load the +model into a variable called ``model``. The first piece of information we want +to check is what IFC schema version we are using. We assume the model you are +learning with is IFC4. We'll then fetch all entities that use the **IfcSlab** +class. + +.. code-block:: python + + import ifcopenshell + model = ifcopenshell.open('/path/to/your/model.ifc') + print(model.schema) # May return IFC2X3, IFC4, or IFC4X3. + print(model.by_type("IfcSlab")) # Will return a list of IFCSLAB entities, like below: + # [ + # #34509=IfcSlab('1pPHnf7cXCpPsNEnQf8_6B',#12,'Bodenplatte',$,$,#34464,#34505,'E4D9CD4B-CA43-4735-94-BD-1FD4376BD455',.BASESLAB.), + # #59290=IfcSlab('2RGlQk4xH47RHK93zcTzUL',#12,'Slab-033',$,$,#59253,#59286,'DA0A17AC-B773-47AC-99-C5-D390C73AD5CC',.FLOOR.), + # #59553=IfcSlab('07Enbsqm9C7AQC9iyBwfSD',#12,'Dach-1',$,$,#59508,#59549,'E142B455-80E4-4B96-83-EC-E1589CA998DB',.ROOF.), + # #59753=IfcSlab('2IxUUNUVPB6Ob$eicCfP2N',#12,'Dach-2',$,$,#59716,#59749,'BD6D9414-37DF-40A8-88-40-301A32A9A5B5',.ROOF.) + # ] + +.. tip:: + + Try changing ``model.by_type("IfcSlab")`` to fetch different types of + entities based on their **IFC Class**. + +An overview of all IFC classes +------------------------------ + +There are hundreds of **IFC Classes**. You don't need to know them all, but +we'll help describe the general breakdown so you know where to find the +appropriate class for what you're after. + +**IFC Classes** are defined using an **Object Oriented** tree hierarchy. Child +**IFC Classes** inherit the attributes defined by the parent **IFC Class**. +This means that **IFC Classes** with common attributes are grouped together in +the tree. + +For example, because all **IfcObject** classes can have a **GlobalId** +attribute, that means that because **IfcWall** is a subtype of **IfcObject**, +it can also have a **GlobalId** attribute. + +.. image:: images/ifc-tree.png + +Important IFC concepts +---------------------- + +There are hundreds of **IFC Concepts** that allow you to describe relationships +between **IFC Classes**. In this guide, we'll focus on the five most common +**IFC Concepts** to get you started. + +Concept 1: the project context +------------------------------ + +Concept 2: spatial decomposition +-------------------------------- + +Concept 3: object typing +------------------------ + +Concept 4: attributes and property sets +--------------------------------------- + +Concept 5: material assignment +------------------------------ + +Self-learning IFC: how to learn more +------------------------------------ + + From 347bb2df4b44f5dbfe24dbce3da2c7dc20dadb6f Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 27 Sep 2023 18:21:35 +1000 Subject: [PATCH 188/190] Introductory docs written for IfcFM --- src/ifcopenshell-python/docs/ifcfm.rst | 58 +++++++++++++++++++++++++- 1 file changed, 57 insertions(+), 1 deletion(-) diff --git a/src/ifcopenshell-python/docs/ifcfm.rst b/src/ifcopenshell-python/docs/ifcfm.rst index cefbe99a87..2decea229a 100644 --- a/src/ifcopenshell-python/docs/ifcfm.rst +++ b/src/ifcopenshell-python/docs/ifcfm.rst @@ -1,4 +1,60 @@ IfcFM ===== -IfcFM is a utility to +**Facility managers** (FM) need to know a lot of information about the building +in order to maintain the facility and its assets effectively. This includes +information about spaces, services, and key equipment, such as who to call when +things break, what the model number is, the warranty period, associated +certificates, what valve or circuit breaker must be shut off prior to +maintenance, and a punch list of periodic maintenance tasks. + +Traditionally, this information is collected in numerous operations and +maintenance (O&M) manuals. IFC can collect this information incrementally +throughout the design development, construction, and commissioning stages of a +project. IFC's standardised and rich digital relationships can describe the +information that facility managers need. The most popular requirements +specification for IFC-based digital FM data is known as **COBie 2.4**. + +Despite already requesting and receiving IFC deliverables, many clients still +request FM data in a spreadsheet format. Unfortunately, many of these +spreadsheet deliverables are not produced from IFC databases. This defeats the +purpose of BIM: asset data is no longer richly stored using international +standards and the BIM model may no longer be trusted. + +IfcFM is a highly standards-compliant tool to convert FM data in IFC databases +to spreadsheets and other machine readable formats, such as ODS, XLSX, CSV, +Pandas, XML, and JSON. These formats may then be easily read, audited, or +imported into technologies that cannot work with IFC natively. + +Supported data standards +------------------------ + +IfcFM assumes that you want to convert IFC data grouped into one or more +categories of data (e.g. list of assets, list of spare parts, list of documents +and certificates, etc). Each category of data includes a list or schedule of +information, and may reference information in other categories. In a +spreadsheet format, each category correlates to a worksheet, where each +worksheet focuses on one type of data and has multiple columns (e.g. name of +manufacturer, point of contact, etc). There are four data standards compatible +with this data structure that IfcFM supports: + +- **COBie 2.4**: the most popular requirements specification for FM data + currently in use first published in 2007. It specifies requirements to + collect almost 20 categories of data, focusing on maintainable equipment and + finishes. It is published by the U.S. Army Corps of Engineers, National + Building Information Model (NBIMS-US) standard, version 2, chapter 2.4, and + in British Standard BS 1192-4:2014. +- **COBie 3.0**: an update to **COBie 2.4** published by the National Building + Information Model (NBIMS-US) standard. Unlike **COBie 2.4**, it is not a + British Standard nor developed by the U.S. Army Corps of Engineers. +- **AOH-BSEM**: a draft specification by buildingSMART based on the lessons + learned from the implementation of **COBie 2.4**, as part of a modular + approach to asset data exchange that supports not just buildings, but + infrastructure projects too. **AOH-BSEM** is set to supersede **COBIE 3.0** + with a focus primarily on equipment maintenance. +- **Vanilla IFC**: Regardless of any "named" requirement such as above, IFC + itself contains a number of standardised property sets and object types + related to assets, warranties, manufacturers, and construction / + installation. As expected, the majority of these are already referenced in + named standards. "Vanilla" IFC offers a "specification agnostic" approach + towards facility management data collection. From fa90e420eb8967498a954fdcd5dfefed3b417274 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 27 Sep 2023 12:34:51 +0500 Subject: [PATCH 189/190] info message for amount of purged elements --- src/blenderbim/blenderbim/bim/module/geometry/operator.py | 3 ++- src/blenderbim/blenderbim/bim/module/profile/operator.py | 4 +++- src/blenderbim/blenderbim/bim/module/type/operator.py | 3 ++- src/blenderbim/blenderbim/core/geometry.py | 3 +++ src/blenderbim/blenderbim/core/profile.py | 3 +++ src/blenderbim/blenderbim/core/type.py | 3 +++ 6 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/geometry/operator.py b/src/blenderbim/blenderbim/bim/module/geometry/operator.py index dcf63827b9..af1db13bd6 100644 --- a/src/blenderbim/blenderbim/bim/module/geometry/operator.py +++ b/src/blenderbim/blenderbim/bim/module/geometry/operator.py @@ -256,7 +256,8 @@ class PurgeUnusedRepresentations(bpy.types.Operator, Operator): bl_options = {"REGISTER", "UNDO"} def _execute(self, context): - core.purge_unused_representations(tool.Ifc, tool.Geometry) + purged_representations = core.purge_unused_representations(tool.Ifc, tool.Geometry) + self.report({"INFO"}, f"{purged_representations} representations were purged.") class UpdateRepresentation(bpy.types.Operator, Operator): diff --git a/src/blenderbim/blenderbim/bim/module/profile/operator.py b/src/blenderbim/blenderbim/bim/module/profile/operator.py index c8cc8c3925..e507a058bb 100644 --- a/src/blenderbim/blenderbim/bim/module/profile/operator.py +++ b/src/blenderbim/blenderbim/bim/module/profile/operator.py @@ -223,7 +223,9 @@ class PurgeUnusedProfiles(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): props = context.scene.BIMProfileProperties - core.purge_unused_profiles(tool.Ifc, tool.Profile) + purged_profiles = core.purge_unused_profiles(tool.Ifc, tool.Profile) + self.report({"INFO"}, f"{purged_profiles} profiles were purged.") + if props.is_editing: refresh() bpy.ops.bim.load_profiles() diff --git a/src/blenderbim/blenderbim/bim/module/type/operator.py b/src/blenderbim/blenderbim/bim/module/type/operator.py index ebb624829d..223ca0ce04 100644 --- a/src/blenderbim/blenderbim/bim/module/type/operator.py +++ b/src/blenderbim/blenderbim/bim/module/type/operator.py @@ -530,4 +530,5 @@ class PurgeUnusedTypes(bpy.types.Operator, tool.Ifc.Operator): bl_options = {"REGISTER", "UNDO"} def _execute(self, context): - core.purge_unused_types(tool.Ifc, tool.Type) + purged_types = core.purge_unused_types(tool.Ifc, tool.Type) + self.report({"INFO"}, f"{purged_types} types were purged.") diff --git a/src/blenderbim/blenderbim/core/geometry.py b/src/blenderbim/blenderbim/core/geometry.py index c26c54d822..b055a65d6c 100644 --- a/src/blenderbim/blenderbim/core/geometry.py +++ b/src/blenderbim/blenderbim/core/geometry.py @@ -158,9 +158,12 @@ def remove_representation(ifc, geometry, obj=None, representation=None): def purge_unused_representations(ifc, geometry): + purged_representations = 0 for representation in geometry.get_model_representations(): if ifc.get().get_total_inverses(representation) == 0: ifc.run("geometry.remove_representation", representation=representation) + purged_representations += 1 + return purged_representations def select_connection(geometry, connection=None): diff --git a/src/blenderbim/blenderbim/core/profile.py b/src/blenderbim/blenderbim/core/profile.py index 7f2e53b061..58029f1dc4 100644 --- a/src/blenderbim/blenderbim/core/profile.py +++ b/src/blenderbim/blenderbim/core/profile.py @@ -18,7 +18,10 @@ def purge_unused_profiles(ifc, profile): + purged_profiles = 0 for element_profile in profile.get_model_profiles(): if ifc.get().get_total_inverses(element_profile) > 0: continue ifc.run("profile.remove_profile", profile=element_profile) + purged_profiles += 1 + return purged_profiles diff --git a/src/blenderbim/blenderbim/core/type.py b/src/blenderbim/blenderbim/core/type.py index f4660895f6..ec36ad5ffe 100644 --- a/src/blenderbim/blenderbim/core/type.py +++ b/src/blenderbim/blenderbim/core/type.py @@ -32,10 +32,13 @@ def assign_type(ifc, type_tool, element=None, type=None): def purge_unused_types(ifc, type): + purged_types = 0 for element_type in type.get_model_types(): if not type.get_type_occurrences(element_type): obj = ifc.get_object(element_type) ifc.run("root.remove_product", product=element_type) + purged_types += 1 if obj: ifc.unlink(obj=obj) type.remove_object(obj) + return purged_types From 10672f3e1a107a77fa3c8e5c3447ca67d13dcfc8 Mon Sep 17 00:00:00 2001 From: Andrej730 Date: Wed, 27 Sep 2023 15:41:51 +0500 Subject: [PATCH 190/190] Print unused elements stats / purge unused elements Added to Purge Data section in Quality Control some tools - https://i.imgur.com/Nz3YoiH.png 1) Print unused elements - useful to investigate how much unused data you have in IFC file. https://i.imgur.com/9Fkn0JD.png 2) purge unused elements by ifc class, it's doing it in pretty naive way and can be dangerous, so maybe it should be moved to debug section. --- .../blenderbim/bim/module/debug/__init__.py | 2 + .../blenderbim/bim/module/debug/operator.py | 41 +++++++++++++++++++ .../blenderbim/bim/module/debug/prop.py | 1 + .../blenderbim/bim/module/project/ui.py | 7 ++++ src/blenderbim/blenderbim/core/debug.py | 8 ++++ src/blenderbim/blenderbim/tool/debug.py | 40 ++++++++++++++++++ 6 files changed, 99 insertions(+) diff --git a/src/blenderbim/blenderbim/bim/module/debug/__init__.py b/src/blenderbim/blenderbim/bim/module/debug/__init__.py index 74d59a6eec..a05c74561a 100644 --- a/src/blenderbim/blenderbim/bim/module/debug/__init__.py +++ b/src/blenderbim/blenderbim/bim/module/debug/__init__.py @@ -38,6 +38,8 @@ classes = ( operator.SelectHighPolygonMeshes, operator.SelectHighestPolygonMeshes, operator.ValidateIfcFile, + operator.PrintUnusedElementStats, + operator.PurgeUnusedElementsByClass, prop.BIMDebugProperties, ui.BIM_PT_debug, ) diff --git a/src/blenderbim/blenderbim/bim/module/debug/operator.py b/src/blenderbim/blenderbim/bim/module/debug/operator.py index 32cfbce6d3..bad542c984 100644 --- a/src/blenderbim/blenderbim/bim/module/debug/operator.py +++ b/src/blenderbim/blenderbim/bim/module/debug/operator.py @@ -454,3 +454,44 @@ class OverrideDisplayType(bpy.types.Operator): for obj in context.selected_objects: obj.display_type = self.display return {"FINISHED"} + + +class PrintUnusedElementStats(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.print_unused_elements_stats" + bl_label = "Purge Unused Elements Stats" + bl_options = {"REGISTER", "UNDO"} + bl_description = ( + "Print all unused elements in current IFC project in system console, not limited to the selected class" + ) + + ignore_contexts: bpy.props.BoolProperty(name="Ignore Contexts", default=True) + ignore_relationships: bpy.props.BoolProperty(name="Ignore Relationships", default=True) + ignore_types: bpy.props.BoolProperty(name="Ignore Types", default=True) + + def _execute(self, context): + props = context.scene.BIMDebugProperties + # ignore some classes that could have zero 0 inverse references by their nature + ignore_classes = [] + if self.ignore_contexts: + ignore_classes += ["IfcRepresentationContext"] + if self.ignore_relationships: + ignore_classes += ["IfcRelationship"] + if self.ignore_types: + ignore_classes += ["IfcTypeProduct"] + + unused_elements = tool.Debug.print_unused_elements_stats(props.ifc_class_purge, ignore_classes) + self.report({"INFO"}, f"{unused_elements} unused elements found, check the system console for the details.") + + +class PurgeUnusedElementsByClass(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.purge_unused_elements_by_class" + bl_label = "Purge Unused Elements By Class" + bl_description = ( + "Will find all elements of class that have no inverse refernces and will remove them, use very carefully." + ) + bl_options = {"REGISTER", "UNDO"} + + def _execute(self, context): + props = context.scene.BIMDebugProperties + purged_elements = core.purge_unused_elements(tool.Ifc, tool.Debug, props.ifc_class_purge) + self.report({"INFO"}, f"{purged_elements} unused elements found and removed.") diff --git a/src/blenderbim/blenderbim/bim/module/debug/prop.py b/src/blenderbim/blenderbim/bim/module/debug/prop.py index 0825b0f0f6..fff62ef57b 100644 --- a/src/blenderbim/blenderbim/bim/module/debug/prop.py +++ b/src/blenderbim/blenderbim/bim/module/debug/prop.py @@ -50,3 +50,4 @@ class BIMDebugProperties(PropertyGroup): name="Display Type", default="BOUNDS", ) + ifc_class_purge: StringProperty(name="Unused Elements IFC Class", default="") diff --git a/src/blenderbim/blenderbim/bim/module/project/ui.py b/src/blenderbim/blenderbim/bim/module/project/ui.py index 8b61c4320d..0797238bd1 100644 --- a/src/blenderbim/blenderbim/bim/module/project/ui.py +++ b/src/blenderbim/blenderbim/bim/module/project/ui.py @@ -398,3 +398,10 @@ class BIM_PT_purge(Panel): layout.operator("bim.purge_unused_profiles") layout.operator("bim.purge_unused_types") layout.operator("bim.purge_unused_representations") + layout.separator() + + layout.label(text="Purge unused elements by class:") + row = layout.row(align=True) + row.prop(context.scene.BIMDebugProperties, "ifc_class_purge", text="") + row.operator("bim.purge_unused_elements_by_class", text="", icon="TRASH") + row.operator("bim.print_unused_elements_stats", text="", icon="INFO") diff --git a/src/blenderbim/blenderbim/core/debug.py b/src/blenderbim/blenderbim/core/debug.py index 65e7ac0607..fd6356aa0d 100644 --- a/src/blenderbim/blenderbim/core/debug.py +++ b/src/blenderbim/blenderbim/core/debug.py @@ -23,3 +23,11 @@ def parse_express(debug, filename): def purge_hdf5_cache(debug): debug.purge_hdf5_cache() + + +def purge_unused_elements(ifc, debug, ifc_class): + ifc_file = ifc.get() + unused_elements = [i for i in ifc_file.by_type(ifc_class) if ifc_file.get_total_inverses(i) == 0] + unused_elements_amount = len(unused_elements) + debug.remove_unused_elements(unused_elements) + return unused_elements_amount diff --git a/src/blenderbim/blenderbim/tool/debug.py b/src/blenderbim/blenderbim/tool/debug.py index 7f0888e2ef..3bf44856a1 100644 --- a/src/blenderbim/blenderbim/tool/debug.py +++ b/src/blenderbim/blenderbim/tool/debug.py @@ -20,6 +20,7 @@ import os import bpy import ifcopenshell.express import blenderbim.core.tool +import blenderbim.tool as tool from blenderbim.bim.ifc import IfcStore @@ -48,3 +49,42 @@ class Debug(blenderbim.core.tool.Debug): obj = bpy.data.objects.new(name, mesh) bpy.context.scene.collection.objects.link(obj) return obj + + @classmethod + def remove_unused_elements(cls, elements): + ifc_file = tool.Ifc.get() + for element in elements: + ifcopenshell.util.element.remove_deep2(ifc_file, element) + + @classmethod + def print_unused_elements_stats(cls, requested_ifc_class="", ignore_classes=tuple()): + ifc_file = tool.Ifc.get() + + # get list of ifc classes used in model + classes = set() + requested_ifc_classes = set() + for el in ifc_file: + if any(el.is_a(i) for i in ignore_classes): + continue + classes.add(el.is_a()) + if requested_ifc_class and el.is_a(requested_ifc_class): + requested_ifc_classes.add(el.is_a()) + + # count unused elements for each class + unused = dict() + for c in classes: + uses = [i for i in ifc_file.by_type(c) if ifc_file.get_total_inverses(i) == 0] + if not uses: + continue + unused[c] = len(uses) + + # print classes and their unsued elements in ascending order + if unused: + print("Unused elements by classes:") + for ifc_class in sorted(unused.keys(), key=lambda x: unused[x]): + class_string = ifc_class + if ifc_class in requested_ifc_classes: + class_string = "---> " + class_string + print(f"{class_string: <50} {unused[ifc_class]: >5}") + + return sum(unused.values())