From 8c3d10a2bd62fd94dfd0c909afd6802473dbbcc0 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 29 May 2024 15:14:11 +1000 Subject: [PATCH] Reimplement manual qty calculator using autodetected calculator functions and redo qto UI --- .../blenderbim/bim/module/pset/operator.py | 2 - .../blenderbim/bim/module/qto/__init__.py | 9 +- .../blenderbim/bim/module/qto/calculator.py | 96 +++++++++++--- .../blenderbim/bim/module/qto/data.py | 30 ++--- .../blenderbim/bim/module/qto/operator.py | 119 ++++------------- .../blenderbim/bim/module/qto/prop.py | 57 ++++---- .../blenderbim/bim/module/qto/ui.py | 125 ++++++++++++------ src/blenderbim/blenderbim/core/qto.py | 23 +--- src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json | 2 +- src/ifc5d/ifc5d/qto.py | 57 ++++++-- 10 files changed, 282 insertions(+), 238 deletions(-) diff --git a/src/blenderbim/blenderbim/bim/module/pset/operator.py b/src/blenderbim/blenderbim/bim/module/pset/operator.py index 8896cd3f15..b3d55ba5e0 100644 --- a/src/blenderbim/blenderbim/bim/module/pset/operator.py +++ b/src/blenderbim/blenderbim/bim/module/pset/operator.py @@ -27,10 +27,8 @@ import blenderbim.bim.helper import blenderbim.bim.handler import blenderbim.tool as tool import blenderbim.core.pset as core -import blenderbim.core.qto as QtoCore import blenderbim.bim.module.pset.data from blenderbim.bim.ifc import IfcStore -from blenderbim.bim.module.pset.qto_calculator import QtoCalculator class Operator: diff --git a/src/blenderbim/blenderbim/bim/module/qto/__init__.py b/src/blenderbim/blenderbim/bim/module/qto/__init__.py index 9924927fa0..dada98ae88 100644 --- a/src/blenderbim/blenderbim/bim/module/qto/__init__.py +++ b/src/blenderbim/blenderbim/bim/module/qto/__init__.py @@ -20,16 +20,17 @@ import bpy from . import ui, prop, operator classes = ( - operator.AssignBaseQto, operator.CalculateCircleRadius, operator.CalculateEdgeLengths, operator.CalculateFaceAreas, operator.CalculateObjectVolumes, - operator.ExecuteQtoMethod, + operator.CalculateSingleQuantity, operator.PerformQuantityTakeOff, - operator.QuantifyObjects, prop.BIMQtoProperties, - ui.BIM_PT_qto_utilities, + ui.BIM_PT_qto, + ui.BIM_PT_qto_manual, + ui.BIM_PT_qto_simple, + ui.BIM_PT_qto_cost, ) diff --git a/src/blenderbim/blenderbim/bim/module/qto/calculator.py b/src/blenderbim/blenderbim/bim/module/qto/calculator.py index 874bf37e72..bb9456cc50 100644 --- a/src/blenderbim/blenderbim/bim/module/qto/calculator.py +++ b/src/blenderbim/blenderbim/bim/module/qto/calculator.py @@ -38,17 +38,19 @@ VectorTuple = tuple[float, float, float] def get_units(o: bpy.types.Object, vg_index: int) -> int: return len([v for v in o.data.vertices if vg_index in [g.group for g in v.groups]]) -def get_linear_length(o: bpy.types.Object) -> float: - """_summary_: Returns the length of the longest edge of the object bounding box - :param blender-object o: Blender Object - :return float: Length +def get_linear_length(o: bpy.types.Object) -> float: + """Returns the length of the longest edge of the object bounding box + + :param o: Blender Object + :return: Length """ x = (Vector(o.bound_box[4]) - Vector(o.bound_box[0])).length y = (Vector(o.bound_box[3]) - Vector(o.bound_box[0])).length z = (Vector(o.bound_box[1]) - Vector(o.bound_box[0])).length return max(x, y, z) + def get_length(o: bpy.types.Object, vg_index: Optional[int] = None, main_axis: str = "x") -> float: if vg_index is None: x = (Vector(o.bound_box[4]) - Vector(o.bound_box[0])).length @@ -74,22 +76,26 @@ def get_length(o: bpy.types.Object, vg_index: Optional[int] = None, main_axis: s length += get_edge_distance(o, e) return length + def get_stair_length(obj: bpy.types.Object) -> float: length = get_length(obj) height = get_height(obj) stair_length = math.sqrt(pow(length, 2) + pow(height, 2)) return stair_length + def get_net_stair_area(obj: bpy.types.Object) -> float: OBB_obj = get_OBB_object(obj) OBB_net_footprint_area = get_net_footprint_area(OBB_obj) return OBB_net_footprint_area + def get_gross_stair_area(obj: bpy.types.Object) -> float: OBB_obj = get_OBB_object(obj) OBB_gross_footprint_area = get_gross_footprint_area(OBB_obj) return OBB_gross_footprint_area + def get_parametric_axis(obj: bpy.types.Object) -> Literal["AXIS2", "AXIS3", None]: relating_type = ifcopenshell.util.element.get_type(tool.Ifc.get_entity(obj)) if relating_type: @@ -105,6 +111,7 @@ def get_parametric_axis(obj: bpy.types.Object) -> Literal["AXIS2", "AXIS3", None return None return None + def get_covering_gross_area(obj: bpy.types.Object) -> float: parametrix_axis = get_parametric_axis(obj) if not parametrix_axis: @@ -114,6 +121,7 @@ def get_covering_gross_area(obj: bpy.types.Object) -> float: elif parametrix_axis == "AXIS3": return get_gross_footprint_area(obj) + def get_covering_net_area(obj: bpy.types.Object) -> float: parametrix_axis = get_parametric_axis(obj) if not parametrix_axis: @@ -123,6 +131,7 @@ def get_covering_net_area(obj: bpy.types.Object) -> float: elif parametrix_axis == "AXIS3": return get_net_footprint_area(obj) + def get_covering_width(obj: bpy.types.Object) -> float: parametrix_axis = get_parametric_axis(obj) if not parametrix_axis: @@ -132,6 +141,7 @@ def get_covering_width(obj: bpy.types.Object) -> float: elif parametrix_axis == "AXIS3": return get_height(obj) + def get_width(o: bpy.types.Object) -> float: """_summary_: Returns the width of the object bounding box @@ -142,6 +152,7 @@ def get_width(o: bpy.types.Object) -> float: y = (Vector(o.bound_box[3]) - Vector(o.bound_box[0])).length return min(x, y) + def get_height(o: bpy.types.Object) -> float: """_summary_: Returns the height of the object bounding box @@ -150,38 +161,45 @@ def get_height(o: bpy.types.Object) -> float: """ return (Vector(o.bound_box[1]) - Vector(o.bound_box[0])).length + def get_opening_height(obj: bpy.types.Object) -> float: if is_opening_horizontal(obj): return get_width(obj) else: return get_height(obj) + def get_opening_depth(obj: bpy.types.Object) -> float: if is_opening_horizontal(obj): return get_height(obj) else: return get_width(obj) + def get_opening_mapping_area(obj: bpy.types.Object) -> float: if is_opening_horizontal(obj): return get_net_footprint_area(obj) else: return get_net_side_area(obj) + def get_finish_ceiling_height(obj: bpy.types.Object) -> float: floor_height = get_finish_floor_height(obj) ceiling_height = get_ceiling_height(obj) finish_ceiling_height = ceiling_height - floor_height return finish_ceiling_height + def get_max_global_z(obj: bpy.types.Object) -> float: z_values = [(obj.matrix_world @ Vector(co))[2] for co in obj.bound_box] return max(z_values) + def get_min_global_z(obj: bpy.types.Object) -> float: z_values = [(obj.matrix_world @ Vector(co))[2] for co in obj.bound_box] return min(z_values) + def get_finish_floor_height(obj: bpy.types.Object) -> float: space_min_z_value = get_min_global_z(obj) @@ -200,6 +218,7 @@ def get_finish_floor_height(obj: bpy.types.Object) -> float: return flooring_max_z_value - space_min_z_value + def get_ceiling_height(obj: bpy.types.Object) -> float: space_min_z_value = get_min_global_z(obj) space_max_z_value = get_max_global_z(obj) @@ -219,6 +238,7 @@ def get_ceiling_height(obj: bpy.types.Object) -> float: return ceiling_min_z_value - space_min_z_value + def get_net_perimeter(o: bpy.types.Object) -> float: parsed_edges = [] shared_edges = [] @@ -234,6 +254,7 @@ def get_net_perimeter(o: bpy.types.Object) -> float: perimeter -= get_edge_key_distance(o, edge_key) return perimeter + def get_gross_perimeter(o: bpy.types.Object) -> float: element = tool.Ifc.get_entity(o) mesh = get_gross_element_mesh(element) @@ -242,14 +263,17 @@ def get_gross_perimeter(o: bpy.types.Object) -> float: delete_obj(gross_obj) return gross_perimeter + def get_space_net_perimeter(obj: bpy.types.Object) -> float: pass + def get_rectangular_perimeter(obj: bpy.types.Object) -> float: length = get_length(obj, main_axis="x") height = get_height(obj) return (length + height) * 2 + def get_lowest_polygons(o: bpy.types.Object) -> list[bpy.types.MeshPolygon]: lowest_polygons = [] lowest_z = None @@ -266,6 +290,7 @@ def get_lowest_polygons(o: bpy.types.Object) -> list[bpy.types.MeshPolygon]: lowest_z = z return lowest_polygons + def get_highest_polygons(o: bpy.types.Object) -> list[bpy.types.MeshPolygon]: highest_polygons = [] highest_z = None @@ -282,12 +307,15 @@ def get_highest_polygons(o: bpy.types.Object) -> list[bpy.types.MeshPolygon]: highest_z = z return highest_polygons + def get_edge_key_distance(obj: bpy.types.Object, edge_key: tuple[int, int]) -> float: return (obj.data.vertices[edge_key[1]].co - obj.data.vertices[edge_key[0]].co).length + def get_edge_distance(obj: bpy.types.Object, edge: bpy.types.MeshEdge) -> float: return (obj.data.vertices[edge.vertices[1]].co - obj.data.vertices[edge.vertices[0]].co).length + def get_net_floor_area(obj: bpy.types.Object) -> float: decompositions = get_obj_decompositions(obj) if not decompositions: @@ -304,6 +332,7 @@ def get_net_floor_area(obj: bpy.types.Object) -> float: return total_net_floor_area + def get_gross_ceiling_area(obj: bpy.types.Object) -> float: decompositions = get_obj_decompositions(obj) if not decompositions: @@ -320,6 +349,7 @@ def get_gross_ceiling_area(obj: bpy.types.Object) -> float: return total_gross_ceiling_area + def get_net_ceiling_area(obj: bpy.types.Object) -> float: decompositions = get_obj_decompositions(obj) if not decompositions: @@ -340,6 +370,7 @@ def get_net_ceiling_area(obj: bpy.types.Object) -> float: return total_net_ceiling_area + def get_space_net_volume(obj: bpy.types.Object) -> float: decompositions = get_obj_decompositions(obj) if not decompositions: @@ -355,6 +386,7 @@ def get_space_net_volume(obj: bpy.types.Object) -> float: return total_space_net_volume + def get_net_footprint_area(o: bpy.types.Object) -> float: """_summary_: Returns the area of the footprint of the object, excluding any holes @@ -366,6 +398,7 @@ def get_net_footprint_area(o: bpy.types.Object) -> float: area += polygon.area return area + def get_gross_footprint_area(o: bpy.types.Object) -> float: """_summary_: Returns the area of the footprint of the object, without related opening and excluding any holes @@ -382,6 +415,7 @@ def get_gross_footprint_area(o: bpy.types.Object) -> float: delete_mesh(mesh) return gross_footprint_area + def get_net_roofprint_area(o: bpy.types.Object) -> float: # Is roofprint the right word? Couldn't think of anything better - vulevukusej """_summary_: Returns the area of the net roofprint of the object, excluding any holes @@ -394,6 +428,7 @@ def get_net_roofprint_area(o: bpy.types.Object) -> float: area += polygon.area return area + def get_side_area(o: bpy.types.Object) -> float: # There are a few dumb options for this, but this seems the dumbest # until I get more practical experience on what works best. @@ -402,6 +437,7 @@ def get_side_area(o: bpy.types.Object) -> float: z = (Vector(o.bound_box[1]) - Vector(o.bound_box[0])).length return max(x * z, y * z) + def get_cross_section_area(obj: bpy.types.Object) -> float: representation = tool.Ifc.get().by_id(obj.data.BIMMeshProperties.ifc_definition_id) item = representation.Items[0] @@ -418,6 +454,7 @@ def get_cross_section_area(obj: bpy.types.Object) -> float: return area # TODO handle other types of sections, and then fall back to mesh parsing + def get_gross_surface_area(o: bpy.types.Object, vg_index: Optional[int] = None) -> float: if vg_index is None: if not has_openings(o): @@ -436,21 +473,25 @@ def get_gross_surface_area(o: bpy.types.Object, vg_index: Optional[int] = None) area += polygon.area return area + def get_net_surface_area(obj: bpy.types.Object) -> float: return get_mesh_area(obj.data) + def get_mesh_area(mesh: bpy.types.Mesh) -> float: area = 0 for polygon in mesh.polygons: area += polygon.area return area + def is_polygon_in_vg(polygon: bpy.types.MeshPolygon, vertices_in_vg: list[bpy.types.MeshVertex]) -> bool: for v in polygon.vertices: if v not in vertices_in_vg: return False return True + def get_net_volume(o: bpy.types.Object) -> float: o_mesh = bmesh.new() o_mesh.from_mesh(o.data) @@ -458,6 +499,7 @@ def get_net_volume(o: bpy.types.Object) -> float: o_mesh.free() return volume + def get_gross_volume(o: bpy.types.Object) -> float: if not has_openings(o): return get_net_volume(o) @@ -473,17 +515,18 @@ def get_gross_volume(o: bpy.types.Object) -> float: return gross_volume -def has_openings( - obj: bpy.types.Object -) -> Union[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]]: + +def has_openings(obj: bpy.types.Object) -> Union[ifcopenshell.entity_instance, list[ifcopenshell.entity_instance]]: element = tool.Ifc.get_entity(obj) return element and getattr(element, "HasOpenings", []) + def get_obj_decompositions(obj: bpy.types.Object) -> list[ifcopenshell.entity_instance]: element = tool.Ifc.get_entity(obj) decompositions = ifcopenshell.util.element.get_decomposition(element) return decompositions + def get_gross_weight(obj: bpy.types.Object) -> Union[float, None]: obj_mass_density = get_obj_mass_density(obj) if not obj_mass_density: @@ -492,6 +535,7 @@ def get_gross_weight(obj: bpy.types.Object) -> Union[float, None]: gross_weight = obj_mass_density * gross_volume return gross_weight + def get_net_weight(obj: bpy.types.Object) -> Union[float, None]: obj_mass_density = get_obj_mass_density(obj) if not obj_mass_density: @@ -500,6 +544,7 @@ def get_net_weight(obj: bpy.types.Object) -> Union[float, None]: net_weight = obj_mass_density * net_volume return net_weight + def get_obj_mass_density(obj: bpy.types.Object) -> Union[float, None]: entity = tool.Ifc.get_entity(obj) material = ifcopenshell.util.element.get_material(entity) @@ -546,6 +591,7 @@ def get_obj_mass_density(obj: bpy.types.Object) -> Union[float, None]: else: return + def get_opening_type(opening: bpy.types.Object, obj: bpy.types.Object) -> Literal["OPENING", "RECESS"]: """_summary_: Returns the opening type - OPENING / RECESS @@ -565,8 +611,8 @@ def get_opening_type(opening: bpy.types.Object, obj: bpy.types.Object) -> Litera # If an odd number of face-normal vectors intersect with the object, then the void is a recess, otherwise it's an opening return "OPENING" if ray_intersections % 2 == 0 else "RECESS" + def get_opening_area( - obj: bpy.types.Object, angle_z1: int = 45, angle_z2: int = 135, @@ -625,8 +671,8 @@ def get_opening_area( return total_opening_area + def get_lateral_area( - obj: bpy.types.Object, subtract_openings: bool = True, exclude_end_areas: bool = False, @@ -665,9 +711,7 @@ def get_lateral_area( top_axis = x_axis area = 0 - total_opening_area = ( - 0 if subtract_openings else get_opening_area(obj, angle_z1=angle_z1, angle_z2=angle_z2) - ) + total_opening_area = 0 if subtract_openings else get_opening_area(obj, angle_z1=angle_z1, angle_z2=angle_z2) polygons = obj.data.polygons for polygon in polygons: @@ -685,6 +729,7 @@ def get_lateral_area( area += polygon.area return area + total_opening_area + def get_gross_side_area(obj: bpy.types.Object) -> float: if not has_openings(obj): return get_net_side_area(obj) @@ -693,14 +738,17 @@ def get_gross_side_area(obj: bpy.types.Object) -> float: return gross_side_area + def get_net_side_area(obj: bpy.types.Object) -> float: net_side_area = get_lateral_area(obj, exclude_end_areas=True, main_axis="x") / 2 return net_side_area + def get_outer_surface_area(obj: bpy.types.Object) -> float: outer_surface_area = get_lateral_area(obj, exclude_end_areas=True, angle_z1=0, angle_z2=360) return outer_surface_area + def get_end_area(obj: bpy.types.Object) -> float: element = tool.Ifc.get_entity(obj) gross_mesh = get_gross_element_mesh(element) @@ -715,6 +763,7 @@ def get_end_area(obj: bpy.types.Object) -> float: return end_area + def get_gross_top_area(obj: bpy.types.Object, angle: int = 45) -> float: """_summary_: Returns the gross top area of the object. @@ -748,6 +797,7 @@ def get_gross_top_area(obj: bpy.types.Object, angle: int = 45) -> float: area += polygon.area return area + opening_area + # curently net top area is larger then projected area, because its taking into account internal polygons, or window sills def get_net_top_area(obj: bpy.types.Object, angle: int = 45, ignore_internal: bool = True) -> float: """_summary_: Returns the net top area of the object. @@ -775,6 +825,7 @@ def get_net_top_area(obj: bpy.types.Object, angle: int = 45, ignore_internal: bo return area + def get_projected_area(obj, projection_axis: AxisType = "z", is_gross: bool = True) -> float: """_summary_: Returns the projected area of the object. @@ -814,6 +865,7 @@ def get_projected_area(obj, projection_axis: AxisType = "z", is_gross: bool = Tr return projected_polygon.area + void_area return projected_polygon.area + def get_OBB_object(obj: bpy.types.Object) -> bpy.types.Object: """_summary_: Returns the Oriented-Bounding-Box (OBB) of the object. @@ -855,6 +907,7 @@ def get_OBB_object(obj: bpy.types.Object) -> bpy.types.Object: return new_OBB_object + def get_AABB_object(obj: bpy.types.Object) -> bpy.types.Object: """_summary_: Returns the Axis-Aligned-Bounding-Box (AABB) of the object. @@ -909,8 +962,8 @@ def get_AABB_object(obj: bpy.types.Object) -> bpy.types.Object: return new_AABB_object + def get_bisected_obj( - obj: bpy.types.Object, plane_co_pos: VectorTuple, plane_no_pos: VectorTuple, @@ -954,6 +1007,7 @@ def get_bisected_obj( return bis_obj + def get_total_contact_area(obj: bpy.types.Object, class_filter: list[str] = ["IfcElement"]) -> float: """_summary_: Returns the total contact area of the object with other objects. @@ -970,6 +1024,7 @@ def get_total_contact_area(obj: bpy.types.Object, class_filter: list[str] = ["If return total_contact_area + def get_touching_objects(obj: bpy.types.Object, class_filter: list[str]) -> list[bpy.types.Object]: """_summary_: Returns a list of objects that are touching the object. @@ -1019,6 +1074,7 @@ def get_touching_objects(obj: bpy.types.Object, class_filter: list[str]) -> list return touching_objects + def get_contact_area(object1: bpy.types.Object, object2: bpy.types.Object) -> float: """_summary_: Returns the contact area between two objects. @@ -1034,8 +1090,8 @@ def get_contact_area(object1: bpy.types.Object, object2: bpy.types.Object) -> fl total_area += get_intersection_between_polygons(object1, poly1, object2, poly2) return total_area + def get_intersection_between_polygons( - object1: bpy.types.Object, poly1: bpy.types.MeshPolygon, object2: bpy.types.Object, @@ -1083,9 +1139,8 @@ def get_intersection_between_polygons( # TopologicalError - Generated Geometry might be invalid return 0 -def create_shapely_polygon( - obj: bpy.types.Object, polygon: bpy.types.MeshPolygon, trans_matrix: Matrix -) -> Polygon: + +def create_shapely_polygon(obj: bpy.types.Object, polygon: bpy.types.MeshPolygon, trans_matrix: Matrix) -> Polygon: """_summary_: Create a shapely polygon :param blender-object obj: Blender Object @@ -1104,11 +1159,13 @@ def create_shapely_polygon( polygon_tuples.append((x, y)) return Polygon(polygon_tuples) + def get_gross_element_mesh(element: ifcopenshell.entity_instance) -> bpy.types.Mesh: settings = ifcopenshell.geom.settings() settings.set(settings.DISABLE_OPENING_SUBTRACTIONS, True) return create_mesh_from_shape(element, settings) + def create_mesh_from_shape( element: ifcopenshell.entity_instance, settings: Optional[ifcopenshell.geom.settings] = None ) -> bpy.types.Mesh: @@ -1138,11 +1195,13 @@ def create_mesh_from_shape( mesh.update() return mesh + def get_bmesh_from_mesh(mesh: bpy.types.Mesh) -> bmesh.types.BMesh: bm = bmesh.new() bm.from_mesh(mesh) return bm + def get_object_main_axis(o: bpy.types.Object) -> AxisType: """_summary_: Returns the main object axis. Useful for profile-defined objects. @@ -1162,6 +1221,7 @@ def get_object_main_axis(o: bpy.types.Object) -> AxisType: else: return "x" + def is_opening_horizontal(o: bpy.types.Object) -> bool: x = (Vector(o.bound_box[4]) - Vector(o.bound_box[0])).length y = (Vector(o.bound_box[3]) - Vector(o.bound_box[0])).length @@ -1169,10 +1229,12 @@ def is_opening_horizontal(o: bpy.types.Object) -> bool: return z < x and z < y + def delete_mesh(mesh: bpy.types.Mesh) -> None: mesh.user_clear() bpy.data.meshes.remove(mesh) + def delete_obj(obj: bpy.types.Object) -> None: bpy.data.objects.remove(obj, do_unlink=True) diff --git a/src/blenderbim/blenderbim/bim/module/qto/data.py b/src/blenderbim/blenderbim/bim/module/qto/data.py index 7d5fcc6a66..1be10de8fd 100644 --- a/src/blenderbim/blenderbim/bim/module/qto/data.py +++ b/src/blenderbim/blenderbim/bim/module/qto/data.py @@ -19,9 +19,11 @@ import bpy import blenderbim.tool as tool + def refresh(): QtoData.is_loaded = False + class QtoData: data = {} is_loaded = False @@ -29,9 +31,9 @@ class QtoData: @classmethod def load(cls): cls.data = { - "has_cost_item" : cls.has_cost_item(), - "relating_cost_items" : cls.relating_cost_items(), - } + "has_cost_item": cls.has_cost_item(), + "relating_cost_items": cls.relating_cost_items(), + } cls.is_loaded = True @@ -53,23 +55,13 @@ class QtoData: for relating_cost_item in relating_cost_items: results.append( { - 'cost_item_id' : relating_cost_item['cost_item_id'], - 'cost_item_name' : relating_cost_item['cost_item_name'], - 'quantity_id' : relating_cost_item['quantity_id'], - 'quantity_name' : relating_cost_item['quantity_name'], - 'quantity_value' : relating_cost_item['quantity_value'], - 'quantity_type' : relating_cost_item['quantity_type'], + "cost_item_id": relating_cost_item["cost_item_id"], + "cost_item_name": relating_cost_item["cost_item_name"], + "quantity_id": relating_cost_item["quantity_id"], + "quantity_name": relating_cost_item["quantity_name"], + "quantity_value": relating_cost_item["quantity_value"], + "quantity_type": relating_cost_item["quantity_type"], } ) return results - - - - - - - - - - diff --git a/src/blenderbim/blenderbim/bim/module/qto/operator.py b/src/blenderbim/blenderbim/bim/module/qto/operator.py index 7a12a45123..9be81dfafc 100644 --- a/src/blenderbim/blenderbim/bim/module/qto/operator.py +++ b/src/blenderbim/blenderbim/bim/module/qto/operator.py @@ -23,7 +23,6 @@ import blenderbim.tool as tool import blenderbim.core.qto as core from blenderbim.bim.ifc import IfcStore from blenderbim.bim.module.qto import helper -from blenderbim.bim.module.pset.qto_calculator import QtoCalculator class CalculateCircleRadius(bpy.types.Operator): @@ -85,104 +84,37 @@ class CalculateObjectVolumes(bpy.types.Operator): return {"FINISHED"} -class ExecuteQtoMethod(bpy.types.Operator): - bl_idname = "bim.execute_qto_method" - bl_label = "Execute Qto Method" +class CalculateSingleQuantity(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.calculate_single_quantity" + bl_label = "Calculate Single Quantity" bl_options = {"REGISTER", "UNDO"} - - @classmethod - def poll(cls, context): - return context.selected_objects - - def execute(self, context): - selected_mesh_objects = [o for o in context.selected_objects if o.type == "MESH"] - props = context.scene.BIMQtoProperties - result = 0 - if props.qto_methods == "HEIGHT": - for obj in selected_mesh_objects: - result += helper.calculate_height(obj) - elif props.qto_methods == "VOLUME": - result = helper.calculate_volumes(selected_mesh_objects, context) - elif props.qto_methods == "FORMWORK": - result = helper.calculate_formwork_area(selected_mesh_objects, context) - elif props.qto_methods == "SIDE_FORMWORK": - result = helper.calculate_side_formwork_area(selected_mesh_objects, context) - elif props.qto_methods == "NetFootprintArea": - result = QtoCalculator().get_net_footprint_area(selected_mesh_objects[0]) - elif props.qto_methods == "NetRoofprintArea": - result = QtoCalculator().get_net_roofprint_area(selected_mesh_objects[0]) - elif props.qto_methods == "LateralArea": - result = QtoCalculator().get_lateral_area(selected_mesh_objects[0]) - elif props.qto_methods == "TotalSurfaceArea": - result = QtoCalculator().get_total_surface_area(selected_mesh_objects[0]) - elif props.qto_methods == "OpeningArea": - result = QtoCalculator().get_opening_area(selected_mesh_objects[0]) - elif props.qto_methods == "GrossTopArea": - result = QtoCalculator().get_gross_top_area(selected_mesh_objects[0]) - elif props.qto_methods == "NetTopArea": - result = QtoCalculator().get_net_top_area(selected_mesh_objects[0]) - elif props.qto_methods == "ProjectedArea": - result = QtoCalculator().get_projected_area(selected_mesh_objects[0]) - elif props.qto_methods == "TotalContactArea": - result = QtoCalculator().get_total_contact_area(selected_mesh_objects[0]) - elif props.qto_methods == "ContactArea": - result = QtoCalculator().get_contact_area(selected_mesh_objects[0], selected_mesh_objects[1]) - props.qto_result = str(round(result, 3)) - return {"FINISHED"} - - -class QuantifyObjects(bpy.types.Operator): - bl_idname = "bim.quantify_objects" - bl_label = "Quantify Objects" - bl_options = {"REGISTER", "UNDO"} - - @classmethod - def poll(cls, context): - return IfcStore.get_file() and context.selected_objects - - def execute(self, context): - return IfcStore.execute_ifc_operator(self, context) - - def _execute(self, context): - props = context.scene.BIMQtoProperties - self.file = IfcStore.get_file() - for obj in (o for o in context.selected_objects if o.type == "MESH"): - if not obj.BIMObjectProperties.ifc_definition_id: - continue - result = 0 - if props.qto_methods == "HEIGHT": - result = helper.calculate_height(obj) - elif props.qto_methods == "VOLUME": - result = helper.calculate_volumes([obj], context) - elif props.qto_methods == "FORMWORK": - result = helper.calculate_formwork_area([obj], context) - elif props.qto_methods == "SIDE_FORMWORK": - result = helper.calculate_side_formwork_area([obj], context) - if not result: - continue - result = round(result, 3) - qto = ifcopenshell.api.run( - "pset.add_qto", - self.file, - product=self.file.by_id(obj.BIMObjectProperties.ifc_definition_id), - name=props.qto_name, - ) - ifcopenshell.api.run("pset.edit_qto", self.file, qto=qto, properties={props.prop_name: result}) - return {"FINISHED"} - - -class AssignBaseQto(bpy.types.Operator, tool.Ifc.Operator): - bl_idname = "bim.assign_objects_base_qto" - bl_label = "Assign IFC Object Quantity Set" - bl_options = {"REGISTER", "UNDO"} - bl_description = "Assign IFC quantity set to selected object" + bl_description = "Calculate a single quantity using a function on the selected objects" @classmethod def poll(cls, context): return tool.Ifc.get() and context.selected_objects def _execute(self, context): - core.assign_objects_base_qto(tool.Ifc, tool.Qto, selected_objects=context.selected_objects) + import ifc5d.qto + + props = context.scene.BIMQtoProperties + elements = set() + for obj in context.selected_objects: + element = tool.Ifc.get_entity(obj) + if element: + elements.add(element) + + rules = { + "calculators": { + props.calculator: { + "IfcProduct": {props.qto_name: {props.prop_name: props.calculator_function}}, + } + } + } + + ifc_file = tool.Ifc.get() + results = ifc5d.qto.quantify(ifc_file, elements, rules) + ifc5d.qto.edit_qtos(ifc_file, results) return {"FINISHED"} @@ -199,13 +131,14 @@ class PerformQuantityTakeOff(bpy.types.Operator, tool.Ifc.Operator): def _execute(self, context): import ifc5d.qto + props = context.scene.BIMQtoProperties elements = set() for obj in context.selected_objects: element = tool.Ifc.get_entity(obj) if element: elements.add(element) - rules = ifc5d.qto.get_rules("IFC4QtoBaseQuantities") + rules = ifc5d.qto.rules[props.qto_rule] ifc_file = tool.Ifc.get() results = ifc5d.qto.quantify(ifc_file, elements, rules) diff --git a/src/blenderbim/blenderbim/bim/module/qto/prop.py b/src/blenderbim/blenderbim/bim/module/qto/prop.py index 8829c6178a..3cc1e9d446 100644 --- a/src/blenderbim/blenderbim/bim/module/qto/prop.py +++ b/src/blenderbim/blenderbim/bim/module/qto/prop.py @@ -17,6 +17,7 @@ # along with BlenderBIM Add-on. If not, see . import bpy +import ifc5d.qto from blenderbim.bim.prop import StrProperty, Attribute from bpy.types import PropertyGroup from bpy.props import ( @@ -31,34 +32,32 @@ from bpy.props import ( ) +def get_qto_rule(self, context): + results = [] + for rule_id, rule in ifc5d.qto.rules.items(): + results.append((rule_id, rule["name"], rule["description"])) + return results + + +def get_calculator(self, context): + results = [] + for name, calculator in ifc5d.qto.calculators.items(): + results.append((name, name, calculator.__doc__)) + return results + + +def get_calculator_function(self, context): + calculator = ifc5d.qto.calculators[self.calculator] + results = [] + for function in calculator.get_functions(): + results.append((function.id, function.name, function.description)) + return results + + class BIMQtoProperties(PropertyGroup): + qto_rule: EnumProperty(items=get_qto_rule, name="Qto Rule") + calculator: EnumProperty(items=get_calculator, name="Calculator") + calculator_function: EnumProperty(items=get_calculator_function, name="Calculator Function") qto_result: StringProperty(default="", name="Qto Result") - qto_methods: EnumProperty( - items=[ - ("HEIGHT", "Height", "Calculate the Z height of an object"), - ("VOLUME", "Volume", "Calculate the volume of an object"), - ( - "FORMWORK", - "Formwork", - "Calculate the exposed formwork for all bottoms and sides (e.g. for beams and slabs) of one or more objects", - ), - ( - "SIDE_FORMWORK", - "Side Formwork", - "Calculate the exposed formwork for all sides only (e.g. for columns) of one or more objects", - ), - ("NetFootprintArea", "Net footprint area", "Calculate the net footprint area"), - ("NetRoofprintArea", "Net roofprint area", "Calculate the net roofprint area"), - ("LateralArea", "Lateral area", "Calculate the lateral area"), - ("TotalSurfaceArea", "Total surface area", "Calculate the total surface area"), - ("OpeningArea", "Opening area", "Calculate the opening area"), - ("GrossTopArea", "Gross top area", "Calculate the gross top area"), - ("NetTopArea", "Net top area", "Calculate the net top area"), - ("ProjectedArea", "Projected area", "Calculate the projected area"), - ("TotalContactArea", "Total contact area", "Get the total contact area"), - ("ContactArea", "Contact area between two objects", "Get the contact area") - ], - name="Qto Methods", - ) - qto_name: StringProperty(name="Qto Name") - prop_name: StringProperty(name="Prop Name") + qto_name: StringProperty(name="Qto Name", default="My_Qto") + prop_name: StringProperty(name="Prop Name", default="MyDimension") diff --git a/src/blenderbim/blenderbim/bim/module/qto/ui.py b/src/blenderbim/blenderbim/bim/module/qto/ui.py index c7a79d1522..c7f4863463 100644 --- a/src/blenderbim/blenderbim/bim/module/qto/ui.py +++ b/src/blenderbim/blenderbim/bim/module/qto/ui.py @@ -20,8 +20,8 @@ import bpy from blenderbim.bim.module.qto.data import QtoData -class BIM_PT_qto_utilities(bpy.types.Panel): - bl_idname = "BIM_PT_qto_utilities" +class BIM_PT_qto(bpy.types.Panel): + bl_idname = "BIM_PT_qto" bl_label = "Quantity Take-off" bl_options = {"DEFAULT_CLOSED"} bl_space_type = "PROPERTIES" @@ -31,9 +31,56 @@ class BIM_PT_qto_utilities(bpy.types.Panel): bl_options = {"HIDE_HEADER"} def draw(self, context): - if not QtoData.is_loaded: - QtoData.load() + layout = self.layout + props = context.scene.BIMQtoProperties + row = layout.row() + if context.selected_objects: + row.label(text=f"Quantifying {len(context.selected_objects)} Selected Objects", icon="MOD_EDGESPLIT") + else: + row.label(text="Quantifying All Objects", icon="MOD_EDGESPLIT") + row = layout.row() + row.prop(props, "qto_rule", text="") + row = layout.row() + row.operator("bim.perform_quantity_take_off") + + +class BIM_PT_qto_manual(bpy.types.Panel): + bl_idname = "BIM_PT_qto_manual" + bl_label = "Manual Quantification" + bl_options = {"DEFAULT_CLOSED"} + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "scene" + bl_parent_id = "BIM_PT_tab_qto" + + def draw(self, context): + layout = self.layout + props = context.scene.BIMQtoProperties + + row = layout.row() + row.prop(props, "calculator") + row = layout.row() + row.prop(props, "calculator_function", text="Function") + + row = layout.row(align=True) + row.prop(props, "qto_name", text="") + row.prop(props, "prop_name", text="") + + row = layout.row() + row.operator("bim.calculate_single_quantity") + + +class BIM_PT_qto_simple(bpy.types.Panel): + bl_idname = "BIM_PT_qto_simple" + bl_label = "Simple Quantity Calculator" + bl_options = {"DEFAULT_CLOSED"} + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "scene" + bl_parent_id = "BIM_PT_tab_qto" + + def draw(self, context): layout = self.layout props = context.scene.BIMQtoProperties @@ -49,42 +96,44 @@ class BIM_PT_qto_utilities(bpy.types.Panel): row = layout.row(align=True) row.operator("bim.calculate_object_volumes") - row = layout.row(align=True) - row.prop(props, "qto_methods", text="") - row.operator("bim.execute_qto_method", icon="PROPERTIES", text="") - row = layout.row(align=True) - row.prop(props, "qto_name", text="") - row.prop(props, "prop_name", text="") - row.operator("bim.quantify_objects", icon="COPYDOWN", text="") +class BIM_PT_qto_cost(bpy.types.Panel): + bl_idname = "BIM_PT_qto_cost" + bl_label = "Parametric Cost Relationships" + bl_options = {"DEFAULT_CLOSED"} + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "scene" + bl_parent_id = "BIM_PT_tab_qto" - row = layout.row(align=True) - row.operator("bim.assign_objects_base_qto") + def draw(self, context): + if not QtoData.is_loaded: + QtoData.load() - row = layout.row(align=True) - row.operator("bim.calculate_all_quantities", icon="MOD_EDGESPLIT") + if not context.selected_objects: + row = self.layout.row() + row.label(text="No Selected Object") + return - if context.selected_objects: - row = layout.row(align=True) - row.label(text=f"Relating Cost Item:") - - if QtoData.data['has_cost_item']: - for relating_cost_item in QtoData.data['relating_cost_items']: - row.label(text=f"\n") - row = layout.row(align=True) - row.label(text=f"Cost item name:") - row.label(text=f"{relating_cost_item['cost_item_name']}") - row = layout.row(align=True) - row.label(text=f"Quantity name:") - row.label(text=f"{relating_cost_item['quantity_name']}") - row = layout.row(align=True) - row.label(text=f"Quantity value:") - row.label(text=f"{relating_cost_item['quantity_value']}") - row = layout.row(align=True) - row.label(text=f"Quantity type:") - row.label(text=f"{relating_cost_item['quantity_type']}") - row = layout.row(align=True) - else: - row = layout.row(align=True) - row.label(text = f"No cost item related") + if not QtoData.data["has_cost_item"]: + row = self.layout.row() + row.label(text="No Related Cost Item") + return + row = self.layout.row(align=True) + row.label(text="Relating Cost Item:") + for relating_cost_item in QtoData.data["relating_cost_items"]: + row.label(text="\n") + row = self.layout.row(align=True) + row.label(text="Cost item name:") + row.label(text=f"{relating_cost_item['cost_item_name']}") + row = self.layout.row(align=True) + row.label(text="Quantity name:") + row.label(text=f"{relating_cost_item['quantity_name']}") + row = self.layout.row(align=True) + row.label(text="Quantity value:") + row.label(text=f"{relating_cost_item['quantity_value']}") + row = self.layout.row(align=True) + row.label(text="Quantity type:") + row.label(text=f"{relating_cost_item['quantity_type']}") + row = self.layout.row(align=True) diff --git a/src/blenderbim/blenderbim/core/qto.py b/src/blenderbim/blenderbim/core/qto.py index a1495f3d68..1b31bd69ad 100644 --- a/src/blenderbim/blenderbim/core/qto.py +++ b/src/blenderbim/blenderbim/core/qto.py @@ -17,35 +17,14 @@ # along with BlenderBIM Add-on. If not, see . from __future__ import annotations -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING if TYPE_CHECKING: import bpy - import ifcopenshell import blenderbim.tool as tool - from blenderbim.bim.module.pset.qto_calculator import QtoCalculator def calculate_circle_radius(qto: tool.Qto, obj: bpy.types.Object) -> float: result = qto.get_radius_of_selected_vertices(obj) qto.set_qto_result(result) return result - - -def assign_objects_base_qto(ifc: tool.Ifc, qto: tool.Qto, selected_objects: list[bpy.types.Object]) -> None: - for obj in selected_objects: - assign_object_base_qto(ifc, qto, obj) - - -def assign_object_base_qto(ifc: tool.Ifc, qto: tool.Qto, obj: bpy.types.Object) -> None: - product = ifc.get_entity(obj) - if not product: - return - base_quantity_name = qto.get_applicable_base_quantity_name(product) - if not base_quantity_name: - return - ifc.run( - "pset.add_qto", - product=product, - name=base_quantity_name, - ) diff --git a/src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json b/src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json index 3211ee6724..29b9045161 100644 --- a/src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json +++ b/src/ifc5d/ifc5d/IFC4QtoBaseQuantities.json @@ -2,7 +2,7 @@ "name": "IFC4 Base Quantities - IfcOpenShell", "description": "This ruleset quantifies every single possible standardised base quantity in IFC4 using only IfcOpenShell as a geometry processor.", "calculators": { - "IOSTriangulation": { + "IfcOpenShell": { "IfcActuator": { "Qto_ActuatorBaseQuantities": { "GrossWeight": null diff --git a/src/ifc5d/ifc5d/qto.py b/src/ifc5d/ifc5d/qto.py index 5957abbdc8..a4ddbd617e 100644 --- a/src/ifc5d/ifc5d/qto.py +++ b/src/ifc5d/ifc5d/qto.py @@ -23,16 +23,20 @@ import ifcopenshell.api import ifcopenshell.api.pset import ifcopenshell.util.selector import multiprocessing -from typing import Optional +from collections import namedtuple +from typing import Iterable -def get_rules(name: str): - cwd = os.path.dirname(os.path.realpath(__file__)) +Function = namedtuple("Function", ["id", "name", "description"]) +rules = {} + +cwd = os.path.dirname(os.path.realpath(__file__)) +for name in ("IFC4QtoBaseQuantities", "IFC4QtoBaseQuantitiesBlender"): with open(os.path.join(cwd, name + ".json"), "r") as f: - return json.load(f) + rules[name] = json.load(f) -def quantify(ifc_file: ifcopenshell.file, elements: set[ifcopenshell.entity_instance], rules: dict): +def quantify(ifc_file: ifcopenshell.file, elements: set[ifcopenshell.entity_instance], rules: dict) -> dict: results = {} for calculator, queries in rules["calculators"].items(): calculator = calculators[calculator] @@ -43,7 +47,7 @@ def quantify(ifc_file: ifcopenshell.file, elements: set[ifcopenshell.entity_inst return results -def edit_qtos(ifc_file, results): +def edit_qtos(ifc_file, results) -> None: for element, qtos in results.items(): for name, quantities in qtos.items(): qto = ifcopenshell.util.element.get_pset(element, name, should_inherit=False) @@ -54,14 +58,17 @@ def edit_qtos(ifc_file, results): ifcopenshell.api.pset.edit_qto(ifc_file, qto=qto, properties=quantities) -class IOSTriangulation: +class IfcOpenShell: + """Calculates Model body context geometry using the default IfcOpenShell + iterator on triangulation elements.""" + @staticmethod def calculate( ifc_file: ifcopenshell.file, elements: set[ifcopenshell.entity_instance], qtos: dict, results: dict, - ): + ) -> None: import ifcopenshell import ifcopenshell.geom import ifcopenshell.util.shape @@ -89,10 +96,10 @@ class IOSTriangulation: tasks = [] if gross_qtos: - tasks.append((IOSTriangulation.create_iterator(ifc_file, gross_settings, elements), gross_qtos)) + tasks.append((IfcOpenShell.create_iterator(ifc_file, gross_settings, list(elements)), gross_qtos)) if net_qtos: - tasks.append((IOSTriangulation.create_iterator(ifc_file, net_settings, elements), net_qtos)) + tasks.append((IfcOpenShell.create_iterator(ifc_file, net_settings, list(elements)), net_qtos)) for iterator, qtos in tasks: if iterator.initialize(): @@ -108,13 +115,26 @@ class IOSTriangulation: break @staticmethod - def create_iterator(ifc_file, settings, elements): + def create_iterator( + ifc_file: ifcopenshell.file, settings: ifcopenshell.geom.settings, elements: list[ifcopenshell.entity_instance] + ) -> ifcopenshell.geom.iterator: return ifcopenshell.geom.iterator(settings, ifc_file, multiprocessing.cpu_count(), include=elements) + @staticmethod + def get_functions() -> list[Function]: + return [ + Function("get_volume", "Volume", "Calculates the volume of a manifold shape"), + Function("get_x", "X Length", "Calculates the length along the local X axis"), + ] + class Blender: + """Calculates geometry based on currently loaded Blender objects.""" + @staticmethod - def calculate(ifc_file: ifcopenshell.file, elements: set[ifcopenshell.entity_instance], qtos: dict, results: dict): + def calculate( + ifc_file: ifcopenshell.file, elements: set[ifcopenshell.entity_instance], qtos: dict, results: dict + ) -> None: import blenderbim.tool as tool import blenderbim.bim.module.qto.calculator as calculator @@ -132,5 +152,16 @@ class Blender: formula_function = formula_functions[formula] = getattr(calculator, formula) results[element][name][quantity] = formula_function(obj) + @staticmethod + def get_functions() -> list[Function]: + return [ + Function( + "get_linear_length", + "Maximum Bounding Length", + "Calculates the length of the maximum local bounding box", + ), + Function("get_length", "Length", "Calculates the length assumed as the maximum of the local X or Y axis"), + ] -calculators = {"Blender": Blender, "IOSTriangulation": IOSTriangulation} + +calculators = {"Blender": Blender, "IfcOpenShell": IfcOpenShell}