diff --git a/.github/workflows/publish-pyodide-demo-app.yml b/.github/workflows/publish-pyodide-demo-app.yml new file mode 100644 index 0000000000..59fcf5c9f5 --- /dev/null +++ b/.github/workflows/publish-pyodide-demo-app.yml @@ -0,0 +1,48 @@ +name: Deploy Pyodide Demo App to GitHub Pages + +permissions: + id-token: write + pages: write + +on: + push: + paths: + - 'src/pyodide/**' + - '.github/workflows/publish-pyodide-demo-app.yml' + +jobs: + activate: + runs-on: ubuntu-latest + if: | + github.repository == 'IfcOpenShell/IfcOpenShell' + steps: + - name: Set env + run: echo ok go + + build: + needs: activate + runs-on: ubuntu-latest + steps: + - name: Checkout (recursive) + uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 0 + - name: Setup Pages + uses: actions/configure-pages@v5 + - name: Upload static files as artifact + id: deployment + uses: actions/upload-pages-artifact@v3 + with: + path: src/pyodide/demo-app/ + + deploy: + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + runs-on: ubuntu-latest + needs: build + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index c219a65be7..69fbc5d66b 100644 --- a/.gitignore +++ b/.gitignore @@ -98,3 +98,5 @@ src/ifcopenshell-python/ifcopenshell/ifcopenshell_wrapper.py # Brickschema src/bonsai/bonsai/bim/schema/Brick.ttl +bonsaiDecoratorForLoads.code-workspace +dev_environment.bat diff --git a/.gitmodules b/.gitmodules index 9fbc1888bb..017cd2ae8e 100644 --- a/.gitmodules +++ b/.gitmodules @@ -20,3 +20,6 @@ [submodule "src/ifcopenshell-python/ifcopenshell/simple_spf"] path = src/ifcopenshell-python/ifcopenshell/simple_spf url = https://github.com/IfcOpenShell/step-file-parser +[submodule "src/pyodide/demo-app/wheels"] + path = src/pyodide/demo-app/wheels + url = https://github.com/IfcOpenShell/wasm-wheels diff --git a/src/bonsai/blenderbim_icons.blend b/src/bonsai/blenderbim_icons.blend index d9535415ba..8d11a5ffda 100644 Binary files a/src/bonsai/blenderbim_icons.blend and b/src/bonsai/blenderbim_icons.blend differ diff --git a/src/bonsai/bonsai/bim/handler.py b/src/bonsai/bonsai/bim/handler.py index e24d984757..1f63ff18b8 100644 --- a/src/bonsai/bonsai/bim/handler.py +++ b/src/bonsai/bonsai/bim/handler.py @@ -134,7 +134,7 @@ def update_bim_tool_props(): if AuthoringData.data["active_material_usage"] == "LAYER2": x_angle = get_x_angle(extrusion) axis = tool.Model.get_wall_axis(obj)["reference"] - props.extrusion_depth = extrusion.Depth * si_conversion * cos(x_angle) + props.extrusion_depth = abs(extrusion.Depth * si_conversion * cos(x_angle)) props.length = (axis[1] - axis[0]).length props.x_angle = x_angle diff --git a/src/bonsai/bonsai/bim/import_ifc.py b/src/bonsai/bonsai/bim/import_ifc.py index c30bbca3db..5417392052 100644 --- a/src/bonsai/bonsai/bim/import_ifc.py +++ b/src/bonsai/bonsai/bim/import_ifc.py @@ -261,8 +261,6 @@ class IfcImporter: self.profile_code("Setup arrays") tool.Project.load_linked_models_from_ifc() self.profile_code("Load linked models") - self.lock_scales() - self.profile_code("Lock objects scales") self.add_project_to_scene() self.profile_code("Add project to scene") if self.ifc_import_settings.should_clean_mesh and len(self.file.by_type("IfcElement")) < 1000: @@ -1130,21 +1128,6 @@ class IfcImporter: bpy.context.scene.BIMAggregateProperties.aggregate_decorator = True - def lock_scales(self) -> None: - elements = set(self.file.by_type("IfcProduct")) - while elements: - element = elements.pop() - if not getattr(element, "HasOpenings", False): - continue - voided_elements = tool.Aggregate.get_parts_recursively(element) - voided_elements.add(element) - elements.difference_update(voided_elements) - for element in voided_elements: - if not (obj := tool.Ifc.get_object(element)): - continue - tool.Geometry.lock_scale(obj) - - class IfcImportSettings: def __init__(self): self.logger: logging.Logger = None diff --git a/src/bonsai/bonsai/bim/module/aggregate/decorator.py b/src/bonsai/bonsai/bim/module/aggregate/decorator.py index 911fc86890..e1afe6b507 100644 --- a/src/bonsai/bonsai/bim/module/aggregate/decorator.py +++ b/src/bonsai/bonsai/bim/module/aggregate/decorator.py @@ -207,7 +207,6 @@ class AggregateDecorator: parts = ifcopenshell.util.element.get_parts(tool.Ifc.get_entity(aggregate)) parts_objs = [tool.Ifc.get_object(p) for p in parts] - indices, edges = create_bounding_box(parts_objs) self.line_shader.uniform_float("lineWidth", 0.5) self.draw_batch("LINES", indices, color, edges) @@ -230,9 +229,7 @@ class AggregateModeDecorator: cls.handlers.append( SpaceView3D.draw_handler_add(handler.draw_aggregate_empty, (context,), "WINDOW", "POST_VIEW") ) - cls.handlers.append( - SpaceView3D.draw_handler_add(handler.draw_new_objects, (context,), "WINDOW", "POST_VIEW") - ) + cls.handlers.append(SpaceView3D.draw_handler_add(handler.draw_new_objects, (context,), "WINDOW", "POST_VIEW")) cls.is_installed = True @classmethod @@ -352,12 +349,11 @@ class AggregateModeDecorator: new_objs = [o for o in new_objs if o.data] for obj in new_objs: element = tool.Ifc.get_entity(obj) - if (aggregate := ifcopenshell.util.element.get_aggregate(element)) == tool.Ifc.get_entity(props.editing_aggregate): + if (aggregate := ifcopenshell.util.element.get_aggregate(element)) == tool.Ifc.get_entity( + props.editing_aggregate + ): continue if element and element.is_a("IfcElement"): data = ItemDecorator.get_obj_data(obj) if data: - self.draw_batch( - "TRIS", data["verts"], transparent_color((1, 0, 0, 1)), data["tris"] - ) - + self.draw_batch("TRIS", data["verts"], transparent_color((1, 0, 0, 1)), data["tris"]) diff --git a/src/bonsai/bonsai/bim/module/aggregate/operator.py b/src/bonsai/bonsai/bim/module/aggregate/operator.py index 87b46412b7..49516d1f82 100644 --- a/src/bonsai/bonsai/bim/module/aggregate/operator.py +++ b/src/bonsai/bonsai/bim/module/aggregate/operator.py @@ -375,6 +375,7 @@ class BIM_OT_select_linked_aggregates(bpy.types.Operator): return {"FINISHED"} + class BIM_OT_disable_aggregate_mode(bpy.types.Operator): bl_idname = "bim.disable_aggregate_mode" bl_label = "Disable Aggregate Mode" @@ -385,6 +386,7 @@ class BIM_OT_disable_aggregate_mode(bpy.types.Operator): bonsai.core.aggregate.disable_aggregate_mode(tool.Aggregate) return {"FINISHED"} + class BIM_OT_toggle_aggregate_mode_local_view(bpy.types.Operator): bl_idname = "bim.toggle_aggregate_mode_local_view" bl_label = "Toggle Aggregate Mode Local View" @@ -403,6 +405,7 @@ class BIM_OT_toggle_aggregate_mode_local_view(bpy.types.Operator): return {"FINISHED"} + class BIM_OT_aggregate_assign_new_objects_in_aggregate_mode(bpy.types.Operator): bl_idname = "bim.aggregate_assign_new_objects_in_aggregate_mode" bl_label = "Aggregate Assign New Objects In Aggregate Mode" @@ -424,7 +427,9 @@ class BIM_OT_aggregate_assign_new_objects_in_aggregate_mode(bpy.types.Operator): new_objs = [o for o in new_objs if o.data] for obj in new_objs: element = tool.Ifc.get_entity(obj) - if (aggregate := ifcopenshell.util.element.get_aggregate(element)) == tool.Ifc.get_entity(props.editing_aggregate): + if (aggregate := ifcopenshell.util.element.get_aggregate(element)) == tool.Ifc.get_entity( + props.editing_aggregate + ): continue if element and element.is_a("IfcElement"): obj.select_set(True) @@ -439,4 +444,3 @@ class BIM_OT_aggregate_assign_new_objects_in_aggregate_mode(bpy.types.Operator): BIM_OT_aggregate_assign_object._execute(self, context) return {"FINISHED"} - diff --git a/src/bonsai/bonsai/bim/module/classification/ui.py b/src/bonsai/bonsai/bim/module/classification/ui.py index 300cfc5b8c..fcef0ad4bd 100644 --- a/src/bonsai/bonsai/bim/module/classification/ui.py +++ b/src/bonsai/bonsai/bim/module/classification/ui.py @@ -313,10 +313,8 @@ class BIM_PT_material_classifications(Panel, ReferenceUI): if not tool.Ifc.get(): return False props = context.scene.BIMMaterialProperties - if props.materials and props.active_material_index < len(props.materials): - material = props.materials[props.active_material_index] - if material.ifc_definition_id: - return True + if props.is_editing and (material := props.active_material) and material.ifc_definition_id: + return True return False def draw(self, context): diff --git a/src/bonsai/bonsai/bim/module/geometry/__init__.py b/src/bonsai/bonsai/bim/module/geometry/__init__.py index 82ff57feb2..b7a6676991 100644 --- a/src/bonsai/bonsai/bim/module/geometry/__init__.py +++ b/src/bonsai/bonsai/bim/module/geometry/__init__.py @@ -18,6 +18,7 @@ import bpy from . import ui, prop, operator +from bpy.app.handlers import persistent classes = ( operator.AddCurvelikeItem, @@ -89,7 +90,20 @@ classes = ( addon_keymaps = [] +@persistent +def block_scale(scene): + if obj := (getattr(bpy.context, "active_object", None) or bpy.context.view_layer.objects.active): + if isinstance(obj, bpy.types.Object) and obj.BIMObjectProperties.ifc_definition_id: + if obj.scale != (1, 1, 1): + obj.scale = (1, 1, 1) + elif isinstance(obj, bpy.types.Mesh) and obj.BIMMeshProperties.ifc_definition_id: + if obj.scale != (1, 1, 1): + obj.scale = (1, 1, 1) + + def register(): + bpy.app.handlers.depsgraph_update_pre.append(block_scale) + operator.OverrideDuplicateMoveMacro.define("BIM_OT_override_object_duplicate_move") operator.OverrideDuplicateMoveMacro.define("TRANSFORM_OT_translate") operator.OverrideDuplicateMoveLinkedMacro.define("BIM_OT_override_object_duplicate_move_linked") @@ -158,6 +172,8 @@ def register(): def unregister(): + bpy.app.handlers.depsgraph_update_pre.remove(block_scale) + bpy.types.VIEW3D_MT_object.remove(ui.object_menu) bpy.types.OUTLINER_MT_object.remove(ui.outliner_menu) bpy.types.VIEW3D_MT_object_context_menu.remove(ui.outliner_menu) diff --git a/src/bonsai/bonsai/bim/module/geometry/helper.py b/src/bonsai/bonsai/bim/module/geometry/helper.py index bee6593996..38ca24d15d 100644 --- a/src/bonsai/bonsai/bim/module/geometry/helper.py +++ b/src/bonsai/bonsai/bim/module/geometry/helper.py @@ -454,7 +454,7 @@ class Helper: x_axis = (mesh.vertices[loop[0]].co - center).normalized() else: x_axis = (mesh.vertices[loop[1]].co - mesh.vertices[loop[0]].co).normalized() - z_axis = profile_face.polygons[0].normal.normalized() * (-1) + z_axis = profile_face.polygons[0].normal.normalized() * -1 y_axis = z_axis.cross(x_axis).normalized() matrix = Matrix((x_axis, y_axis, z_axis)) matrix.normalize() diff --git a/src/bonsai/bonsai/bim/module/geometry/operator.py b/src/bonsai/bonsai/bim/module/geometry/operator.py index 9208ba302a..02664b0193 100644 --- a/src/bonsai/bonsai/bim/module/geometry/operator.py +++ b/src/bonsai/bonsai/bim/module/geometry/operator.py @@ -1858,7 +1858,7 @@ class OverrideEscape(bpy.types.Operator): bpy.ops.bim.hide_all_openings() elif context.scene.BIMAggregateProperties.in_aggregate_mode: bpy.ops.bim.disable_aggregate_mode() - elif active_object:=context.active_object: + elif active_object := context.active_object: if tool.Blender.Modifier.try_canceling_editing_modifier_parameters_or_path(active_object): pass return {"FINISHED"} @@ -1902,7 +1902,9 @@ class OverrideModeSetEdit(bpy.types.Operator, tool.Ifc.Operator): self.report({"ERROR"}, f"Element '{obj.name}' is in item mode and cannot be edited directly") elif obj in [o.obj for o in context.scene.BIMAggregateProperties.not_editing_objects]: obj.select_set(False) - self.report({"ERROR"}, f"Element '{obj.name}' does not belong to this aggregate and cannot be edited directly") + self.report( + {"ERROR"}, f"Element '{obj.name}' does not belong to this aggregate and cannot be edited directly" + ) elif obj in bpy.context.scene.BIMProjectProperties.clipping_planes_objs: self.report({"ERROR"}, "Clipping planes cannot be edited") elif element: @@ -1971,6 +1973,13 @@ class OverrideModeSetEdit(bpy.types.Operator, tool.Ifc.Operator): ProfileDecorator.install(context) if not bpy.app.background: tool.Blender.set_viewport_tool("bim.cad_tool") + elif item.is_a("IfcAnnotationFillArea"): + tool.Model.import_annotation_fill_area(item, obj=obj) + obj.data.BIMMeshProperties.ifc_definition_id = item.id() + self.enable_edit_mode(context) + ProfileDecorator.install(context) + if not bpy.app.background: + tool.Blender.set_viewport_tool("bim.cad_tool") elif tool.Geometry.is_curvelike_item(item): tool.Model.import_curve(item, obj=obj) obj.data.BIMMeshProperties.ifc_definition_id = item.id() @@ -2124,10 +2133,7 @@ class OverrideModeSetObject(bpy.types.Operator, tool.Ifc.Operator): tool.Geometry.import_item(obj) elif item.is_a("IfcSweptAreaSolid"): ProfileDecorator.uninstall() - - profile = tool.Model.export_profile(obj) - - if not profile: + if not (profile := tool.Model.export_profile(obj)): def msg(self, context): self.layout.label(text="INVALID PROFILE") @@ -2184,6 +2190,26 @@ class OverrideModeSetObject(bpy.types.Operator, tool.Ifc.Operator): product=element, representation=new_footprint, ) + elif item.is_a("IfcAnnotationFillArea"): + ProfileDecorator.uninstall() + if not (profile := tool.Model.export_annotation_fill_area(obj)): + + def msg(self, context): + self.layout.label(text="INVALID PROFILE") + + bpy.context.window_manager.popup_menu(msg, title="Error", icon="ERROR") + ProfileDecorator.install(bpy.context) + self.enable_edit_mode(bpy.context) + return + + for inverse in tool.Ifc.get().get_inverse(item): + ifcopenshell.util.element.replace_attribute(inverse, item, profile) + ifcopenshell.util.element.remove_deep2(tool.Ifc.get(), item) + obj.data.BIMMeshProperties.ifc_definition_id = profile.id() + + tool.Geometry.reload_representation(bpy.context.scene.BIMGeometryProperties.representation_obj) + tool.Geometry.import_item(obj) + tool.Geometry.import_item_attributes(obj) elif tool.Geometry.is_curvelike_item(item): ProfileDecorator.uninstall() new = tool.Model.export_curves(obj) @@ -2924,7 +2950,7 @@ class OverrideMoveAggregate(bpy.types.Operator): obj.select_set(False) continue element = tool.Ifc.get_entity(obj) - if not element or props.in_aggregate_mode: + if not element or not element.is_a("IfcElement") or props.in_aggregate_mode: continue parts = ifcopenshell.util.element.get_parts(element) if parts: diff --git a/src/bonsai/bonsai/bim/module/material/prop.py b/src/bonsai/bonsai/bim/module/material/prop.py index e6cf5ae827..b623083d5f 100644 --- a/src/bonsai/bonsai/bim/module/material/prop.py +++ b/src/bonsai/bonsai/bim/module/material/prop.py @@ -124,6 +124,11 @@ class BIMMaterialProperties(PropertyGroup): styles: EnumProperty(items=get_styles, name="Styles") contexts: EnumProperty(items=get_contexts, name="Contexts") + @property + def active_material(self): + if self.active_material_index < len(self.materials): + return self.materials[self.active_material_index] + class BIMObjectMaterialProperties(PropertyGroup): material_type: EnumProperty(items=get_object_material_type, name="Material Type") diff --git a/src/bonsai/bonsai/bim/module/model/__init__.py b/src/bonsai/bonsai/bim/module/model/__init__.py index 2be31b25c1..6bf42d99e9 100644 --- a/src/bonsai/bonsai/bim/module/model/__init__.py +++ b/src/bonsai/bonsai/bim/module/model/__init__.py @@ -95,6 +95,7 @@ classes = ( profile.ChangeCardinalPoint, profile.ChangeProfileDepth, profile.DisableEditingExtrusionAxis, + profile.DrawPolylineProfile, profile.EditExtrusionAxis, profile.EnableEditingExtrusionAxis, profile.ExtendProfile, @@ -127,6 +128,7 @@ classes = ( prop.SnapMousePoint, prop.PolylinePoint, prop.Polyline, + prop.ProductPreviewItem, prop.BIMModelProperties, prop.BIMArrayProperties, prop.BIMStairProperties, @@ -136,6 +138,7 @@ classes = ( prop.BIMRailingProperties, prop.BIMRoofProperties, prop.BIMPolylineProperties, + prop.BIMProductPreviewProperties, ui.BIM_PT_array, ui.BIM_PT_stair, ui.BIM_PT_sverchok, @@ -218,6 +221,7 @@ def register(): bpy.types.Scene.BIMModelProperties = bpy.props.PointerProperty(type=prop.BIMModelProperties) bpy.types.Scene.BIMPolylineProperties = bpy.props.PointerProperty(type=prop.BIMPolylineProperties) + bpy.types.Scene.BIMProductPreviewProperties = bpy.props.PointerProperty(type=prop.BIMProductPreviewProperties) bpy.types.Object.BIMArrayProperties = bpy.props.PointerProperty(type=prop.BIMArrayProperties) bpy.types.Object.BIMStairProperties = bpy.props.PointerProperty(type=prop.BIMStairProperties) bpy.types.Object.BIMSverchokProperties = bpy.props.PointerProperty(type=prop.BIMSverchokProperties) @@ -248,6 +252,7 @@ def unregister(): del bpy.types.Scene.BIMModelProperties del bpy.types.Scene.BIMPolylineProperties + del bpy.types.Scene.BIMProductPreviewProperties del bpy.types.Object.BIMArrayProperties del bpy.types.Object.BIMStairProperties del bpy.types.Object.BIMSverchokProperties diff --git a/src/bonsai/bonsai/bim/module/model/decorator.py b/src/bonsai/bonsai/bim/module/model/decorator.py index 86c5e72e55..0d1bd6079e 100644 --- a/src/bonsai/bonsai/bim/module/model/decorator.py +++ b/src/bonsai/bonsai/bim/module/model/decorator.py @@ -25,15 +25,14 @@ import bmesh import ifcopenshell import bonsai.tool as tool import math -from math import sin, cos, tan, radians +from math import sin, cos, radians from bpy.types import SpaceView3D from bpy_extras import view3d_utils -from mathutils import Vector, Matrix, Quaternion +from mathutils import Vector, Matrix from gpu_extras.batch import batch_for_shader from gpu_extras.presets import draw_circle_2d from typing import Union from bonsai.bim.module.drawing.helper import format_distance -from bonsai.bim.module.geometry.decorator import ItemDecorator def transparent_color(color, alpha=0.1): @@ -786,6 +785,10 @@ class ProductDecorator: @classmethod def uninstall(cls): + props = bpy.context.scene.BIMProductPreviewProperties # updated by model/polyline.py + props.verts.clear() + props.edges.clear() + props.tris.clear() for handler in cls.handlers: try: SpaceView3D.draw_handler_remove(handler, "WINDOW") @@ -799,315 +802,12 @@ class ProductDecorator: shader.uniform_float("color", color) batch.draw(shader) - def get_wall_preview_data(cls, context, relating_type): - def create_bmesh_from_vertices(vertices): - bm = bmesh.new() - - new_verts = [bm.verts.new(v) for v in polyline_vertices] - if is_closed: - new_edges = [bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)] - new_edges.append( - bm.edges.new((new_verts[-1], new_verts[0])) - ) # Add an edge between the last an first point to make it closed. - else: - new_edges = [bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)] - - bm.verts.index_update() - bm.edges.index_update() - return bm - - # Get properties from object type - layers = tool.Model.get_material_layer_parameters(relating_type) - if not layers["thickness"]: - return - thickness = layers["thickness"] - model_props = context.scene.BIMModelProperties - direction_sense = model_props.direction_sense - direction = 1 - if direction_sense == "NEGATIVE": - direction = -1 - offset_type = model_props.offset_type - offset = 0 - if offset_type == "CENTER": - offset = -thickness / 2 - elif offset_type == "INTERIOR": - offset = -thickness - - unit_system = tool.Drawing.get_unit_system() - factor = 1 - if unit_system == "IMPERIAL": - factor = 3.048 - if unit_system == "METRIC": - unit_length = context.scene.unit_settings.length_unit - if unit_length == "MILLIMETERS": - factor = 1000 - - # For the model properties, the offset value should just be converted - # However, for the wall preview logic that follows, offset and thickness must change direction - model_props.offset = offset * factor - thickness *= direction - offset *= direction - - height = float(model_props.extrusion_depth) - rl = float(model_props.rl1) - x_angle = float(model_props.x_angle) - angle_distortion = height * tan(x_angle) - - wall_preview_data = {} - wall_preview_data["verts"] = [] - - # Verts - polyline_vertices = [] - polyline_data = context.scene.BIMPolylineProperties.insertion_polyline - polyline_points = polyline_data[0].polyline_points if polyline_data else [] - if len(polyline_points) < 2: - wall_preview_data = [] - return - for point in polyline_points: - polyline_vertices.append(Vector((point.x, point.y, point.z))) - - is_closed = False - if ( - polyline_vertices[0].x == polyline_vertices[-1].x - and polyline_vertices[0].y == polyline_vertices[-1].y - and polyline_vertices[0].z == polyline_vertices[-1].z - ): - is_closed = True - polyline_vertices.pop(-1) # Remove the last point. The edges are going to inform that the shape is closed. - - bm_base = create_bmesh_from_vertices(polyline_vertices) - base_vertices = tool.Cad.offset_edges(bm_base, offset) - offset_base_verts = tool.Cad.offset_edges(bm_base, thickness + offset) - top_vertices = tool.Cad.offset_edges(bm_base, angle_distortion + offset) - offset_top_verts = tool.Cad.offset_edges(bm_base, angle_distortion + thickness + offset) - if is_closed: - base_vertices.append(base_vertices[0]) - offset_base_verts.append(offset_base_verts[0]) - top_vertices.append(top_vertices[0]) - offset_top_verts.append(offset_top_verts[0]) - - if offset_base_verts is not None: - for v in base_vertices: - wall_preview_data["verts"].append((v.co.x, v.co.y, v.co.z + rl)) - - for v in offset_base_verts[::-1]: - wall_preview_data["verts"].append((v.co.x, v.co.y, v.co.z + rl)) - - for v in top_vertices: - wall_preview_data["verts"].append((v.co.x, v.co.y, v.co.z + rl + height)) - - for v in offset_top_verts[::-1]: - wall_preview_data["verts"].append((v.co.x, v.co.y, v.co.z + rl + height)) - - bm_base.free() - - # Edges and Tris - points = [] - side_edges_1 = [] - side_edges_2 = [] - base_edges = [] - - for i in range(len(wall_preview_data["verts"])): - points.append(Vector(wall_preview_data["verts"][i])) - - n = len(points) // 2 - bottom_side_1 = [[i, (i + 1) % (n)] for i in range((n - 1) // 2)] - bottom_side_2 = [[i, (i + 1) % (n)] for i in range(n // 2, n - 1)] - bottom_connections = [[i, n - i - 1] for i in range(n // 2)] - bottom_loop = bottom_connections + bottom_side_1 + bottom_side_2 - side_edges_1.extend(bottom_side_1) - side_edges_2.extend(bottom_side_2) - base_edges.extend(bottom_loop) - - upper_side_1 = [[i + n for i in edges] for edges in bottom_side_1] - upper_side_2 = [[i + n for i in edges] for edges in bottom_side_2] - upper_loop = [[i + n for i in edges] for edges in bottom_loop] - side_edges_1.extend(upper_side_1) - side_edges_2.extend(upper_side_2) - base_edges.extend(upper_loop) - - loops = [side_edges_1, side_edges_2, base_edges] - - wall_preview_data["edges"] = [] - wall_preview_data["tris"] = [] - for i, group in enumerate(loops): - bm = bmesh.new() - - new_verts = [bm.verts.new(v) for v in points] - new_edges = [bm.edges.new((new_verts[e[0]], new_verts[e[1]])) for e in group] - - bm.verts.index_update() - bm.edges.index_update() - - if i == 2: - new_faces = bmesh.ops.contextual_create(bm, geom=bm.edges) - new_faces = bmesh.ops.bridge_loops(bm, edges=bm.edges, use_pairs=True, use_cyclic=True) - - bm.verts.index_update() - bm.edges.index_update() - edges = [[v.index for v in e.verts] for e in bm.edges] - tris = [[l.vert.index for l in loop] for loop in bm.calc_loop_triangles()] - wall_preview_data["edges"].extend(edges) - wall_preview_data["tris"].extend(tris) - - wall_preview_data["edges"] = list(set(tuple(e) for e in wall_preview_data["edges"])) - wall_preview_data["tris"] = list(set(tuple(t) for t in wall_preview_data["tris"])) - - return wall_preview_data - - def get_product_preview_data(cls, context, relating_type): - model_props = context.scene.BIMModelProperties - if relating_type.is_a("IfcDoorType"): - rl = float(model_props.rl1) - elif relating_type.is_a("IfcWindowType"): - rl = float(model_props.rl2) - else: - rl = 0 - snap_prop = context.scene.BIMPolylineProperties.snap_mouse_point[0] - mouse_point = Vector((snap_prop.x, snap_prop.y, snap_prop.z)) - snap_obj = bpy.data.objects.get(snap_prop.snap_object) - snap_element = tool.Ifc.get_entity(snap_obj) - rot_mat = Quaternion() - if snap_element and snap_element.is_a("IfcWall"): - rot_mat = snap_obj.matrix_world.to_quaternion() - - obj_type = tool.Ifc.get_object(relating_type) - if obj_type.data: - data = ItemDecorator.get_obj_data(obj_type) - data["verts"] = [tuple(obj_type.matrix_world.inverted() @ Vector(v)) for v in data["verts"]] - data["verts"] = [tuple(rot_mat @ (Vector((v[0], v[1], (v[2] + rl)))) + mouse_point) for v in data["verts"]] - return data - - def get_profile_preview_data(self, context, relating_type): - material = ifcopenshell.util.element.get_material(relating_type) - try: - profile = material.MaterialProfiles[0].Profile - except: - return {} - - model_props = context.scene.BIMModelProperties - extrusion_depth = model_props.extrusion_depth - cardinal_point = model_props.cardinal_point - rot_mat = Quaternion() - if relating_type.is_a("IfcBeamType"): - y_rot = Quaternion((0.0, 1.0, 0.0), radians(90)) - z_rot = Quaternion((0.0, 0.0, 1.0), radians(90)) - rot_mat = y_rot @ z_rot - # Get profile data - settings = ifcopenshell.geom.settings() - settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) - shape = ifcopenshell.geom.create_shape(settings, profile) - - verts = shape.verts - if not verts: - raise RuntimeError("Profile shape has no vertices, it probably is invalid.") - - edges = shape.edges - - grouped_verts = [[verts[i], verts[i + 1], 0] for i in range(0, len(verts), 3)] - grouped_edges = [[edges[i], edges[i + 1]] for i in range(0, len(edges), 2)] - - # Create offsets based on cardinal point - min_x = min(v[0] for v in grouped_verts) - max_x = max(v[0] for v in grouped_verts) - min_y = min(v[1] for v in grouped_verts) - max_y = max(v[1] for v in grouped_verts) - - x_offset = (max_x - min_x) / 2 - y_offset = (max_y - min_y) / 2 - - match cardinal_point: - case "1": - grouped_verts = [(v[0] - x_offset, v[1] + y_offset, v[2]) for v in grouped_verts] - case "2": - grouped_verts = [(v[0], v[1] + y_offset, v[2]) for v in grouped_verts] - case "3": - grouped_verts = [(v[0] + x_offset, v[1] + y_offset, v[2]) for v in grouped_verts] - case "4": - grouped_verts = [(v[0] - x_offset, v[1], v[2]) for v in grouped_verts] - case "5": - grouped_verts = [(v[0], v[1], v[2]) for v in grouped_verts] - case "6": - grouped_verts = [(v[0] + x_offset, v[1], v[2]) for v in grouped_verts] - case "7": - grouped_verts = [(v[0] - x_offset, v[1] - y_offset, v[2]) for v in grouped_verts] - case "8": - grouped_verts = [(v[0], v[1] - y_offset, v[2]) for v in grouped_verts] - case "9": - grouped_verts = [(v[0] + x_offset, v[1] - y_offset, v[2]) for v in grouped_verts] - - # Create extrusion bmesh - bm = bmesh.new() - - new_verts = [bm.verts.new(v) for v in grouped_verts] - new_edges = [bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(grouped_verts) - 1)] - - bm.verts.index_update() - bm.edges.index_update() - - new_faces = bmesh.ops.contextual_create(bm, geom=bm.edges) - - new_faces = bmesh.ops.extrude_face_region(bm, geom=bm.faces) - new_verts = [e for e in new_faces["geom"] if isinstance(e, bmesh.types.BMVert)] - new_faces = bmesh.ops.translate(bm, verts=new_verts, vec=(0.0, 0.0, extrusion_depth)) - - bm.verts.index_update() - bm.edges.index_update() - tris = [[loop.vert.index for loop in triangles] for triangles in bm.calc_loop_triangles()] - - # Create bounding box - verts = bm.verts - i = len(verts) - - min_x = min(v.co.x for v in verts) - max_x = max(v.co.x for v in verts) - min_y = min(v.co.y for v in verts) - max_y = max(v.co.y for v in verts) - min_z = min(v.co.z for v in verts) - max_z = max(v.co.z for v in verts) - - bbox_verts = [ - (min_x, min_y, min_z), - (max_x, min_y, min_z), - (max_x, max_y, min_z), - (min_x, max_y, min_z), - (min_x, min_y, max_z), - (max_x, min_y, max_z), - (max_x, max_y, max_z), - (min_x, max_y, max_z), - ] - - bbox_edges = [ - (0 + i, 3 + i), - (3 + i, 7 + i), - (7 + i, 4 + i), - (4 + i, 0 + i), - (0 + i, 1 + i), - (3 + i, 2 + i), - (7 + i, 6 + i), - (4 + i, 5 + i), - (1 + i, 2 + i), - (2 + i, 6 + i), - (6 + i, 5 + i), - (5 + i, 1 + i), - ] - - # Calculate rotation, mouse position, angle and cardinal point - # TODO Angle - snap_prop = context.scene.BIMPolylineProperties.snap_mouse_point[0] - mouse_point = Vector((snap_prop.x, snap_prop.y, snap_prop.z)) + def get_product_preview_data(self, context): + props = context.scene.BIMProductPreviewProperties data = {} - - verts = [tuple(v.co) for v in verts] - verts.extend(bbox_verts) - verts = [tuple(rot_mat @ Vector(v)) for v in verts] - verts = [tuple(Vector(v) + mouse_point) for v in verts] - data["verts"] = verts - data["edges"] = bbox_edges - # data["edges"] = [(edge.verts[0].index, edge.verts[1].index) for edge in bm.edges] - data["tris"] = tris - - bm.free() + data["verts"] = [(*v.value_3d,) for v in props.verts] + data["edges"] = [(int(e.value_2d[0]), int(e.value_2d[1])) for e in props.edges] + data["tris"] = [(int(t.value_3d[0]), int(t.value_3d[1]), int(t.value_3d[2])) for t in props.tris] return data def draw_product_preview(self, context): @@ -1134,25 +834,7 @@ class ProductDecorator: else: return - # Wall - if self.relating_type.is_a("IfcWallType"): - wall_preview_data = self.get_wall_preview_data(context, self.relating_type) - if wall_preview_data: - self.draw_batch("LINES", wall_preview_data["verts"], decorator_color, wall_preview_data["edges"]) - self.draw_batch( - "TRIS", wall_preview_data["verts"], transparent_color(decorator_color), wall_preview_data["tris"] - ) - - # Mesh type products - product_preview_data = self.get_product_preview_data(context, self.relating_type) - if product_preview_data: - self.draw_batch("LINES", product_preview_data["verts"], decorator_color, product_preview_data["edges"]) - self.draw_batch( - "TRIS", product_preview_data["verts"], transparent_color(decorator_color), product_preview_data["tris"] - ) - - # Profile type products - product_preview_data = self.get_profile_preview_data(context, self.relating_type) + product_preview_data = self.get_product_preview_data(context) if product_preview_data: self.draw_batch("LINES", product_preview_data["verts"], decorator_color, product_preview_data["edges"]) self.draw_batch( diff --git a/src/bonsai/bonsai/bim/module/model/door.py b/src/bonsai/bonsai/bim/module/model/door.py index a8aa7b66c2..d8208feaea 100644 --- a/src/bonsai/bonsai/bim/module/model/door.py +++ b/src/bonsai/bonsai/bim/module/model/door.py @@ -19,8 +19,6 @@ import bpy import bmesh -from bmesh.types import BMVert, BMFace - import ifcopenshell import ifcopenshell.api import ifcopenshell.util.element @@ -169,22 +167,6 @@ def update_door_modifier_representation(obj: bpy.types.Object) -> None: tool.Model.update_simple_openings(element) -# TODO: move it out to tools -def bm_sort_out_geom( - geom_data: list[Union[bmesh.types.BMVert, bmesh.types.BMEdge, bmesh.types.BMFace]] -) -> dict[str, Any]: - geom_dict = {"verts": [], "edges": [], "faces": []} - - for el in geom_data: - if isinstance(el, BMVert): - geom_dict["verts"].append(el) - elif isinstance(el, BMFace): - geom_dict["faces"].append(el) - else: - geom_dict["edges"].append(el) - return geom_dict - - def bm_mirror( bm: bmesh.types.BMesh, verts: list[bmesh.types.BMVert], @@ -208,7 +190,7 @@ def bm_mirror( for v in verts: faces.update(v.link_faces) duplicated = bmesh.ops.duplicate(bm, geom=list(faces)) - verts = bm_sort_out_geom(duplicated["geom"])["verts"] + verts = tool.Model.bm_sort_out_geom(duplicated["geom"])["verts"] bmesh.ops.transform(bm, verts=verts, matrix=matrix, space=Matrix.Identity(4)) return verts @@ -242,7 +224,7 @@ def create_bm_extruded_profile( extruded = bmesh.ops.extrude_face_region(bm, geom=new_faces) extrusion_vector = extrusion_vector * magnitude - extruded_verts = bm_sort_out_geom(extruded["geom"])["verts"] + extruded_verts = tool.Model.bm_sort_out_geom(extruded["geom"])["verts"] bmesh.ops.translate(bm, vec=extrusion_vector, verts=extruded_verts) bmesh.ops.translate(bm, vec=position, verts=new_verts + extruded_verts) @@ -304,7 +286,7 @@ def create_bm_door_lining( extruded = bmesh.ops.extrude_face_region(bm, geom=new_faces) extrusion_vector = Vector((0, 1, 0)) * depth - translate_verts = [v for v in extruded["geom"] if isinstance(v, BMVert)] + translate_verts = [v for v in extruded["geom"] if isinstance(v, bmesh.types.BMVert)] bmesh.ops.translate(bm, vec=extrusion_vector, verts=translate_verts) bmesh.ops.translate(bm, vec=position, verts=new_verts + translate_verts) @@ -572,7 +554,7 @@ class AddDoor(bpy.types.Operator, tool.Ifc.Operator): update_door_modifier_representation(obj) def _execute(self, context): - for obj in context.selected_objects: + for obj in tool.Blender.get_selected_objects(): if not tool.Blender.Modifier.is_eligible_for_door_modifier(obj): continue self.add_door_on_object(obj) @@ -610,7 +592,7 @@ class CancelEditingDoor(bpy.types.Operator, tool.Ifc.Operator): props.is_editing = False def _execute(self, context): - for obj in context.selected_objects: + for obj in tool.Blender.get_selected_objects(): self.cancel_editing_door_on_object(obj) return {"FINISHED"} @@ -642,7 +624,7 @@ class FinishEditingDoor(bpy.types.Operator, tool.Ifc.Operator): ifcopenshell.api.run("pset.edit_pset", tool.Ifc.get(), pset=pset, properties={"Data": door_data}) def _execute(self, context): - for obj in context.selected_objects: + for obj in tool.Blender.get_selected_objects(): self.finish_editing_door_on_object(obj) return {"FINISHED"} @@ -666,7 +648,7 @@ class EnableEditingDoor(bpy.types.Operator, tool.Ifc.Operator): props.is_editing = True def _execute(self, context): - for obj in context.selected_objects: + for obj in tool.Blender.get_selected_objects(): self.edit_door_on_obj(obj) return {"FINISHED"} @@ -686,6 +668,6 @@ class RemoveDoor(bpy.types.Operator, tool.Ifc.Operator): ifcopenshell.api.run("pset.remove_pset", tool.Ifc.get(), product=element, pset=pset) def _execute(self, context): - for obj in context.selected_objects: + for obj in tool.Blender.get_selected_objects(): self.remove_door_on_object(obj) return {"FINISHED"} diff --git a/src/bonsai/bonsai/bim/module/model/polyline.py b/src/bonsai/bonsai/bim/module/model/polyline.py index 62c155ba98..85ff369a43 100644 --- a/src/bonsai/bonsai/bim/module/model/polyline.py +++ b/src/bonsai/bonsai/bim/module/model/polyline.py @@ -35,14 +35,440 @@ import bonsai.core.geometry import bonsai.core.model as core import bonsai.tool as tool from bonsai.bim.ifc import IfcStore -from math import pi, sin, cos, degrees -from mathutils import Vector, Matrix +from math import pi, sin, cos, degrees, tan +from mathutils import Vector, Matrix, Quaternion from bonsai.bim.module.model.opening import FilledOpeningGenerator from bonsai.bim.module.model.decorator import PolylineDecorator +from bonsai.bim.module.geometry.decorator import ItemDecorator from typing import Optional, Union, Literal from lark import Lark, Transformer +def get_wall_preview_data(context, relating_type): + def create_bmesh_from_vertices(vertices): + bm = bmesh.new() + + new_verts = [bm.verts.new(v) for v in polyline_vertices] + if is_closed: + new_edges = [bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)] + new_edges.append( + bm.edges.new((new_verts[-1], new_verts[0])) + ) # Add an edge between the last an first point to make it closed. + else: + new_edges = [bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(new_verts) - 1)] + + bm.verts.index_update() + bm.edges.index_update() + return bm + + # Get properties from object type + layers = tool.Model.get_material_layer_parameters(relating_type) + if not layers["thickness"]: + return + thickness = layers["thickness"] + model_props = context.scene.BIMModelProperties + direction_sense = model_props.direction_sense + direction = 1 + if direction_sense == "NEGATIVE": + direction = -1 + offset_type = model_props.offset_type + offset = 0 + if offset_type == "CENTER": + offset = -thickness / 2 + elif offset_type == "INTERIOR": + offset = -thickness + + unit_system = tool.Drawing.get_unit_system() + factor = 1 + if unit_system == "IMPERIAL": + factor = 3.048 + if unit_system == "METRIC": + unit_length = context.scene.unit_settings.length_unit + if unit_length == "MILLIMETERS": + factor = 1000 + + # For the model properties, the offset value should just be converted + # However, for the wall preview logic that follows, offset and thickness must change direction + model_props.offset = offset * factor + thickness *= direction + offset *= direction + + height = float(model_props.extrusion_depth) + rl = float(model_props.rl1) + x_angle = float(model_props.x_angle) + angle_distortion = height * tan(x_angle) + + data = {} + data["verts"] = [] + + # Verts + polyline_vertices = [] + polyline_data = context.scene.BIMPolylineProperties.insertion_polyline + polyline_points = polyline_data[0].polyline_points if polyline_data else [] + if len(polyline_points) < 2: + data = [] + return + for point in polyline_points: + polyline_vertices.append(Vector((point.x, point.y, point.z))) + + is_closed = False + if ( + polyline_vertices[0].x == polyline_vertices[-1].x + and polyline_vertices[0].y == polyline_vertices[-1].y + and polyline_vertices[0].z == polyline_vertices[-1].z + ): + is_closed = True + polyline_vertices.pop(-1) # Remove the last point. The edges are going to inform that the shape is closed. + + bm_base = create_bmesh_from_vertices(polyline_vertices) + base_vertices = tool.Cad.offset_edges(bm_base, offset) + offset_base_verts = tool.Cad.offset_edges(bm_base, thickness + offset) + top_vertices = tool.Cad.offset_edges(bm_base, angle_distortion + offset) + offset_top_verts = tool.Cad.offset_edges(bm_base, angle_distortion + thickness + offset) + if is_closed: + base_vertices.append(base_vertices[0]) + offset_base_verts.append(offset_base_verts[0]) + top_vertices.append(top_vertices[0]) + offset_top_verts.append(offset_top_verts[0]) + + if offset_base_verts is not None: + for v in base_vertices: + data["verts"].append((v.co.x, v.co.y, v.co.z + rl)) + + for v in offset_base_verts[::-1]: + data["verts"].append((v.co.x, v.co.y, v.co.z + rl)) + + for v in top_vertices: + data["verts"].append((v.co.x, v.co.y, v.co.z + rl + height)) + + for v in offset_top_verts[::-1]: + data["verts"].append((v.co.x, v.co.y, v.co.z + rl + height)) + + bm_base.free() + + # Edges and Tris + points = [] + side_edges_1 = [] + side_edges_2 = [] + base_edges = [] + + for i in range(len(data["verts"])): + points.append(Vector(data["verts"][i])) + + n = len(points) // 2 + bottom_side_1 = [[i, (i + 1) % (n)] for i in range((n - 1) // 2)] + bottom_side_2 = [[i, (i + 1) % (n)] for i in range(n // 2, n - 1)] + bottom_connections = [[i, n - i - 1] for i in range(n // 2)] + bottom_loop = bottom_connections + bottom_side_1 + bottom_side_2 + side_edges_1.extend(bottom_side_1) + side_edges_2.extend(bottom_side_2) + base_edges.extend(bottom_loop) + + upper_side_1 = [[i + n for i in edges] for edges in bottom_side_1] + upper_side_2 = [[i + n for i in edges] for edges in bottom_side_2] + upper_loop = [[i + n for i in edges] for edges in bottom_loop] + side_edges_1.extend(upper_side_1) + side_edges_2.extend(upper_side_2) + base_edges.extend(upper_loop) + + loops = [side_edges_1, side_edges_2, base_edges] + + data["edges"] = [] + data["tris"] = [] + for i, group in enumerate(loops): + bm = bmesh.new() + + new_verts = [bm.verts.new(v) for v in points] + new_edges = [bm.edges.new((new_verts[e[0]], new_verts[e[1]])) for e in group] + + bm.verts.index_update() + bm.edges.index_update() + + if i == 2: + new_faces = bmesh.ops.contextual_create(bm, geom=bm.edges) + new_faces = bmesh.ops.bridge_loops(bm, edges=bm.edges, use_pairs=True, use_cyclic=True) + + bm.verts.index_update() + bm.edges.index_update() + edges = [[v.index for v in e.verts] for e in bm.edges] + tris = [[l.vert.index for l in loop] for loop in bm.calc_loop_triangles()] + data["edges"].extend(edges) + data["tris"].extend(tris) + + data["edges"] = list(set(tuple(e) for e in data["edges"])) + data["tris"] = list(set(tuple(t) for t in data["tris"])) + + return data + + +def get_vertical_profile_preview_data(context, relating_type): + material = ifcopenshell.util.element.get_material(relating_type) + try: + profile = material.MaterialProfiles[0].Profile + except: + return {} + + model_props = context.scene.BIMModelProperties + extrusion_depth = model_props.extrusion_depth + cardinal_point = model_props.cardinal_point + rot_mat = Quaternion() + if relating_type.is_a("IfcBeamType"): + y_rot = Quaternion((0.0, 1.0, 0.0), radians(90)) + z_rot = Quaternion((0.0, 0.0, 1.0), radians(90)) + rot_mat = y_rot @ z_rot + # Get profile data + settings = ifcopenshell.geom.settings() + settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) + shape = ifcopenshell.geom.create_shape(settings, profile) + + verts = shape.verts + if not verts: + raise RuntimeError("Profile shape has no vertices, it probably is invalid.") + + edges = shape.edges + + grouped_verts = [[verts[i], verts[i + 1], 0] for i in range(0, len(verts), 3)] + grouped_edges = [[edges[i], edges[i + 1]] for i in range(0, len(edges), 2)] + + # Create offsets based on cardinal point + min_x = min(v[0] for v in grouped_verts) + max_x = max(v[0] for v in grouped_verts) + min_y = min(v[1] for v in grouped_verts) + max_y = max(v[1] for v in grouped_verts) + + x_offset = (max_x - min_x) / 2 + y_offset = (max_y - min_y) / 2 + + match cardinal_point: + case "1": + grouped_verts = [(v[0] - x_offset, v[1] + y_offset, v[2]) for v in grouped_verts] + case "2": + grouped_verts = [(v[0], v[1] + y_offset, v[2]) for v in grouped_verts] + case "3": + grouped_verts = [(v[0] + x_offset, v[1] + y_offset, v[2]) for v in grouped_verts] + case "4": + grouped_verts = [(v[0] - x_offset, v[1], v[2]) for v in grouped_verts] + case "5": + grouped_verts = [(v[0], v[1], v[2]) for v in grouped_verts] + case "6": + grouped_verts = [(v[0] + x_offset, v[1], v[2]) for v in grouped_verts] + case "7": + grouped_verts = [(v[0] - x_offset, v[1] - y_offset, v[2]) for v in grouped_verts] + case "8": + grouped_verts = [(v[0], v[1] - y_offset, v[2]) for v in grouped_verts] + case "9": + grouped_verts = [(v[0] + x_offset, v[1] - y_offset, v[2]) for v in grouped_verts] + + # Create extrusion bmesh + bm = bmesh.new() + + grouped_verts.append(grouped_verts[0]) # Close profile + new_verts = [bm.verts.new(v) for v in grouped_verts] + new_edges = [bm.edges.new((new_verts[i], new_verts[i + 1])) for i in range(len(grouped_verts) - 1)] + + bm.verts.index_update() + bm.edges.index_update() + + bmesh.ops.remove_doubles(bm, verts=bm.verts, dist=0.001) + + new_faces = bmesh.ops.contextual_create(bm, geom=bm.edges) + + new_faces = bmesh.ops.extrude_face_region(bm, geom=bm.faces, use_dissolve_ortho_edges=True) + new_verts = [e for e in new_faces["geom"] if isinstance(e, bmesh.types.BMVert)] + new_faces = bmesh.ops.translate(bm, verts=new_verts, vec=(0.0, 0.0, extrusion_depth)) + + bm.verts.index_update() + bm.edges.index_update() + tris = [[loop.vert.index for loop in triangles] for triangles in bm.calc_loop_triangles()] + + # Calculate rotation, mouse position, angle and cardinal point + snap_prop = context.scene.BIMPolylineProperties.snap_mouse_point[0] + mouse_point = Vector((snap_prop.x, snap_prop.y, snap_prop.z)) + data = {} + + verts = [tuple(v.co) for v in bm.verts] + verts = [tuple(rot_mat @ Vector(v)) for v in verts] + verts = [tuple(Vector(v) + mouse_point) for v in verts] + min_z = min(v.co.z for v in bm.verts) + max_z = max(v.co.z for v in bm.verts) + # Add axis verts + verts.append(tuple(mouse_point)) + verts.append(tuple(mouse_point + Vector((0, 0, max_z)))) + # Add only profile edges + edges = [] + for edge in bm.edges: + if (edge.verts[0].co.z == min_z and edge.verts[1].co.z == min_z) or ( + edge.verts[0].co.z == max_z and edge.verts[1].co.z == max_z + ): + edges.append(edge) + # Add axis edge + edges = [(edge.verts[0].index, edge.verts[1].index) for edge in edges] + edges.append((len(verts) - 1, len(verts) - 2)) + data["verts"] = verts + data["edges"] = edges + data["tris"] = tris + + bm.free() + + return data + + +def get_horizontal_profile_preview_data(context, relating_type): + material = ifcopenshell.util.element.get_material(relating_type) + try: + profile_curve = material.MaterialProfiles[0].Profile + except: + return {} + + model_props = context.scene.BIMModelProperties + cardinal_point = model_props.cardinal_point + + polyline_verts = [] + polyline_data = context.scene.BIMPolylineProperties.insertion_polyline + polyline_points = polyline_data[0].polyline_points if polyline_data else [] + if len(polyline_points) < 2: + return + for point in polyline_points: + polyline_verts.append(Vector((point.x, point.y, point.z))) + polyline_edges = [(i, i + 1) for i in range(len(polyline_verts) - 1)] + + # Get profile shape + settings = ifcopenshell.geom.settings() + settings.set("dimensionality", ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS) + shape = ifcopenshell.geom.create_shape(settings, profile_curve) + + verts = shape.verts + if not verts: + raise RuntimeError("Profile shape has no vertices, it probably is invalid.") + + edges = shape.edges + + grouped_verts = [[verts[i], verts[i + 1], 0] for i in range(0, len(verts), 3)] + grouped_edges = [[edges[i], edges[i + 1]] for i in range(0, len(edges), 2)] + + # Create offsets based on cardinal point + min_x = min(v[0] for v in grouped_verts) + max_x = max(v[0] for v in grouped_verts) + min_y = min(v[1] for v in grouped_verts) + max_y = max(v[1] for v in grouped_verts) + + x_offset = (max_x - min_x) / 2 + y_offset = (max_y - min_y) / 2 + + match cardinal_point: + case "1": + grouped_verts = [(v[0] - x_offset, v[1] + y_offset, v[2]) for v in grouped_verts] + case "2": + grouped_verts = [(v[0], v[1] + y_offset, v[2]) for v in grouped_verts] + case "3": + grouped_verts = [(v[0] + x_offset, v[1] + y_offset, v[2]) for v in grouped_verts] + case "4": + grouped_verts = [(v[0] - x_offset, v[1], v[2]) for v in grouped_verts] + case "5": + grouped_verts = [(v[0], v[1], v[2]) for v in grouped_verts] + case "6": + grouped_verts = [(v[0] + x_offset, v[1], v[2]) for v in grouped_verts] + case "7": + grouped_verts = [(v[0] - x_offset, v[1] - y_offset, v[2]) for v in grouped_verts] + case "8": + grouped_verts = [(v[0], v[1] - y_offset, v[2]) for v in grouped_verts] + case "9": + grouped_verts = [(v[0] + x_offset, v[1] - y_offset, v[2]) for v in grouped_verts] + + # Create profile curve + scale_mat = Matrix.Scale(-1, 4, (1.0, 0.0, 0.0)) + grouped_verts = [scale_mat @ Vector(v) for v in grouped_verts] + profile_curve = bpy.data.curves.new("Profile", type="CURVE") + profile_curve.dimensions = "2D" + profile_curve.splines.new("POLY") + profile_curve.splines[0].points.add(len(grouped_verts)) + + for i, point in enumerate(profile_curve.splines[0].points): + if i == len(grouped_verts): # Close curve + point.co = Vector((*grouped_verts[0], 0)) + continue + point.co = Vector((*grouped_verts[i], 0)) + profile_obj = bpy.data.objects.new("Profile", profile_curve) + + # Create path curve with profile object as bevel + path_curve = bpy.data.curves.new("Polyline", type="CURVE") + path_curve.dimensions = "2D" + path_curve.splines.new("POLY") + path_curve.splines[0].points.add(len(polyline_verts) - 1) + for i, point in enumerate(path_curve.splines[0].points): + point.co = Vector((*polyline_verts[i], 0)) + path_curve.splines[0].use_smooth = False + path_curve.bevel_mode = "OBJECT" + path_curve.bevel_object = profile_obj + + # Convert path curve to mesh + # This operation throws a warning when done during gpu drawing, so it was removed from the decorator file to be handled here + path_obj = bpy.data.objects.new("Preview", path_curve) + context.scene.collection.objects.link(path_obj) + bpy.context.view_layer.objects.active = path_obj + dg = context.evaluated_depsgraph_get() + path_obj = path_obj.evaluated_get(dg) + me = path_obj.to_mesh() + + # Create bmesh from path mesh + bm = bmesh.new() + new_verts = [bm.verts.new(v.co) for v in me.vertices] + index = [[v for v in edge.vertices] for edge in me.edges] + new_edges = [bm.edges.new((new_verts[i[0]], new_verts[i[1]])) for i in index] + for face in me.polygons: + verts = [new_verts[i] for i in face.vertices] + bm.faces.new(verts) + bm.verts.index_update() + bm.edges.index_update() + tris = [[loop.vert.index for loop in triangles] for triangles in bm.calc_loop_triangles()] + + bpy.data.objects.remove(bpy.data.objects[path_obj.name], do_unlink=True) + bpy.data.objects.remove(bpy.data.objects[profile_obj.name], do_unlink=True) + try: + bpy.data.curves.remove(profile_obj.data, do_unlink=True) + except: + pass + try: + bpy.data.curves.remove(path_obj.data, do_unlink=True) + except: + pass + + data = {} + data["verts"] = [tuple(v.co) for v in bm.verts] + data["edges"] = [(edge.verts[0].index, edge.verts[1].index) for edge in bm.edges] + data["tris"] = tris + + bm.free() + + return data + + +def get_product_preview_data(context, relating_type): + model_props = context.scene.BIMModelProperties + if relating_type.is_a("IfcDoorType"): + rl = float(model_props.rl1) + elif relating_type.is_a("IfcWindowType"): + rl = float(model_props.rl2) + else: + rl = 0 + snap_prop = context.scene.BIMPolylineProperties.snap_mouse_point[0] + default_container_elevation = tool.Ifc.get_object(tool.Root.get_default_container()).location.z + mouse_point = Vector((snap_prop.x, snap_prop.y, default_container_elevation)) + snap_obj = bpy.data.objects.get(snap_prop.snap_object) + snap_element = tool.Ifc.get_entity(snap_obj) + rot_mat = Quaternion() + if snap_element and snap_element.is_a("IfcWall"): + rot_mat = snap_obj.matrix_world.to_quaternion() + + obj_type = tool.Ifc.get_object(relating_type) + if obj_type.data: + data = ItemDecorator.get_obj_data(obj_type) + data["verts"] = [tuple(obj_type.matrix_world.inverted() @ Vector(v)) for v in data["verts"]] + data["verts"] = [tuple(rot_mat @ (Vector((v[0], v[1], (v[2] + rl)))) + mouse_point) for v in data["verts"]] + + return data + + class PolylineOperator: # TODO Fill doc strings """ """ @@ -387,6 +813,34 @@ class PolylineOperator: tool.Polyline.remove_last_polyline_point() tool.Blender.update_viewport() + def get_product_preview_data(self, context: bpy.types.Context, relating_type: ifcopenshell.entity_isntance): + if tool.Model.get_usage_type(relating_type) == "PROFILE" and relating_type.is_a() not in {"IfcColumnType"}: + data = get_horizontal_profile_preview_data(context, relating_type) + elif tool.Model.get_usage_type(relating_type) == "PROFILE" and relating_type.is_a() in {"IfcColumnType"}: + data = get_vertical_profile_preview_data(context, relating_type) + elif tool.Model.get_usage_type(relating_type) == "LAYER2": + data = get_wall_preview_data(context, relating_type) + else: + data = get_product_preview_data(context, relating_type) + + # Update properties so it can be used by the decorator + if not data: + return + props = context.scene.BIMProductPreviewProperties + props.verts.clear() + props.edges.clear() + props.tris.clear() + + for vert in data["verts"]: + v = props.verts.add() + v.value_3d = vert + for edge in data["edges"]: + e = props.edges.add() + e.value_2d = edge + for tri in data["tris"]: + t = props.tris.add() + t.value_3d = tri + def modal(self, context: bpy.types.Context, event: bpy.types.Event) -> Union[set[str], None]: PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) tool.Blender.update_viewport() diff --git a/src/bonsai/bonsai/bim/module/model/product.py b/src/bonsai/bonsai/bim/module/model/product.py index b888353cd9..b572376532 100644 --- a/src/bonsai/bonsai/bim/module/model/product.py +++ b/src/bonsai/bonsai/bim/module/model/product.py @@ -191,6 +191,8 @@ class AddOccurrence(bpy.types.Operator, PolylineOperator): if event.value == "RELEASE" and event.type == "LEFTMOUSE": self.create_occurrence(context, event) + self.get_product_preview_data(context, self.relating_type) + cancel = self.handle_cancelation(context, event) if cancel is not None: ProductDecorator.uninstall() diff --git a/src/bonsai/bonsai/bim/module/model/profile.py b/src/bonsai/bonsai/bim/module/model/profile.py index e10eb578bf..0d0bfc0227 100644 --- a/src/bonsai/bonsai/bim/module/model/profile.py +++ b/src/bonsai/bonsai/bim/module/model/profile.py @@ -32,11 +32,13 @@ import bonsai.core.type import bonsai.core.geometry import bonsai.core.material import bonsai.core.root -from math import pi, degrees, inf +from math import pi, degrees, inf, atan2 from mathutils import Vector, Matrix, Quaternion from bonsai.bim.module.geometry.helper import Helper from bonsai.bim.module.model.wall import DumbWallRecalculator -from bonsai.bim.module.model.decorator import ProfileDecorator +from bonsai.bim.module.model.decorator import ProfileDecorator, PolylineDecorator, ProductDecorator +from bonsai.bim.module.model.polyline import PolylineOperator +from typing import Union, Any class DumbProfileGenerator: @@ -44,7 +46,7 @@ class DumbProfileGenerator: self.relating_type = relating_type self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) - def generate(self): + def generate(self, insertion_type="CURSOR"): self.file = tool.Ifc.get() self.unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) material = ifcopenshell.util.element.get_material(self.relating_type) @@ -67,7 +69,28 @@ class DumbProfileGenerator: self.rotation = 0 self.location = Vector((0, 0, 0)) self.cardinal_point = int(bpy.context.scene.BIMModelProperties.cardinal_point) - return self.derive_from_cursor() + if insertion_type == "POLYLINE": + return self.derive_from_polyline() + elif insertion_type == "CURSOR": + return self.derive_from_cursor() + + def derive_from_polyline(self) -> tuple[list[Union[dict[str, Any], None]], bool]: + polyline_data = bpy.context.scene.BIMPolylineProperties.insertion_polyline + polyline_points = polyline_data[0].polyline_points if polyline_data else [] + is_polyline_closed = False + if len(polyline_points) > 3: + first_vec = Vector((polyline_points[0].x, polyline_points[0].y, polyline_points[0].z)) + last_vec = Vector((polyline_points[-1].x, polyline_points[-1].y, polyline_points[-1].z)) + if first_vec == last_vec: + is_polyline_closed = True + + profiles = [] + for i in range(len(polyline_points) - 1): + vec1 = Vector((polyline_points[i].x, polyline_points[i].y, polyline_points[i].z)) + vec2 = Vector((polyline_points[i + 1].x, polyline_points[i + 1].y, polyline_points[i + 1].z)) + coords = (vec1, vec2) + profiles.append(self.create_profile_from_2_points(coords)) + return profiles, is_polyline_closed def derive_from_cursor(self): self.location = bpy.context.scene.cursor.location @@ -82,10 +105,13 @@ class DumbProfileGenerator: obj = bpy.data.objects.new(tool.Model.generate_occurrence_name(self.relating_type, ifc_class), mesh) matrix_world = Matrix() - if self.relating_type.is_a() in ["IfcBeamType", "IfcMemberType"] or self.relating_type.is_a( + if self.relating_type.is_a() in ["IfcBeamType", "IfcCoveringType", "IfcMemberType"] or self.relating_type.is_a( "IfcFlowSegmentType" ): matrix_world = Matrix.Rotation(pi / 2, 4, "Z") @ Matrix.Rotation(pi / 2, 4, "X") @ matrix_world + + matrix_world = Matrix.Rotation(self.rotation, 4, "Z") @ matrix_world + matrix_world.translation = self.location if self.container_obj: matrix_world.translation.z = self.container_obj.location.z @@ -146,6 +172,25 @@ class DumbProfileGenerator: return obj + def create_profile_from_2_points(self, coords, should_round=False) -> Union[dict[str, Any], None]: + direction = coords[1] - coords[0] + length = direction.length + if round(length, 4) < 0.1: + return + data = {"coords": coords} + + self.depth = length + self.rotation = atan2(direction[1], direction[0]) + if should_round: + # Round to nearest 50mm (yes, metric for now) + self.length = 0.05 * round(length / 0.05) + # Round to nearest 5 degrees + nearest_degree = (math.pi / 180) * 5 + self.rotation = nearest_degree * round(self.rotation / nearest_degree) + self.location = coords[0] + data["obj"] = self.create_profile() + return data + class DumbProfileRegenerator: def regenerate_from_profile_def(self, profile): @@ -1064,3 +1109,114 @@ class EditExtrusionAxis(bpy.types.Operator, tool.Ifc.Operator): joiner = DumbProfileJoiner() joiner.set_depth(obj, depth) return {"FINISHED"} + + +class DrawPolylineProfile(bpy.types.Operator, PolylineOperator): + bl_idname = "bim.draw_polyline_profile" + bl_label = "Draw Polyline Profile" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return context.space_data.type == "VIEW_3D" + + def __init__(self): + super().__init__() + self.relating_type = None + props = bpy.context.scene.BIMModelProperties + relating_type_id = props.relating_type_id + if relating_type_id: + self.relating_type = tool.Ifc.get().by_id(int(relating_type_id)) + + def create_profiles_from_polyline(self, context: bpy.types.Context) -> Union[set[str], None]: + if not self.relating_type: + return {"FINISHED"} + + model_props = context.scene.BIMModelProperties + direction_sense = model_props.direction_sense + offset = model_props.offset + + profiles, is_polyline_closed = DumbProfileGenerator(self.relating_type).generate("POLYLINE") + if profiles: + if is_polyline_closed: + for profile1, profile2 in zip(profiles, profiles[1:] + [profiles[0]]): + DumbProfileJoiner().join_V(profile2["obj"], profile1["obj"]) + else: + for profile1, profile2 in zip(profiles[:-1], profiles[1:]): + DumbProfileJoiner().join_V(profile2["obj"], profile1["obj"]) + + def modal(self, context, event): + if not self.relating_type: + self.report({"WARNING"}, "You need to select a profile type.") + PolylineDecorator.uninstall() + tool.Blender.update_viewport() + return {"FINISHED"} + + PolylineDecorator.update(event, self.tool_state, self.input_ui, self.snapping_points[0]) + tool.Blender.update_viewport() + + self.handle_lock_axis(context, event) # Must come before "PASS_TRHOUGH" + + if event.type in {"MIDDLEMOUSE", "WHEELUPMOUSE", "WHEELDOWNMOUSE"}: + self.handle_mouse_move(context, event) + return {"PASS_THROUGH"} + + # Wall axis settings + if event.value == "RELEASE" and event.type == "F": + direction_sense = context.scene.BIMModelProperties.direction_sense + context.scene.BIMModelProperties.direction_sense = ( + "NEGATIVE" if direction_sense == "POSITIVE" else "POSITIVE" + ) + + if event.value == "RELEASE" and event.type == "O": + offset_type = context.scene.BIMModelProperties.offset_type + items = ["EXTERIOR", "CENTER", "INTERIOR"] + index = items.index(offset_type) + size = len(items) + context.scene.BIMModelProperties.offset_type = items[((index + 1) % size)] + + props = bpy.context.scene.BIMModelProperties + wall_config = f"""Direction: {props.direction_sense} + Offset Type: {props.offset_type} + Offset Value: {props.offset} + """ + self.handle_instructions(context, wall_config) + + self.handle_mouse_move(context, event, should_round=True) + + self.choose_axis(event) + + self.handle_snap_selection(context, event) + + if ( + not self.tool_state.is_input_on + and event.value == "RELEASE" + and event.type in {"RET", "NUMPAD_ENTER", "RIGHTMOUSE"} + ): + self.create_profiles_from_polyline(context) + context.workspace.status_text_set(text=None) + ProductDecorator.uninstall() + PolylineDecorator.uninstall() + tool.Polyline.clear_polyline() + tool.Blender.update_viewport() + return {"FINISHED"} + + self.handle_keyboard_input(context, event) + + self.handle_inserting_polyline(context, event) + + self.get_product_preview_data(context, self.relating_type) + + cancel = self.handle_cancelation(context, event) + if cancel is not None: + ProductDecorator.uninstall() + return cancel + + return {"RUNNING_MODAL"} + + def invoke(self, context, event): + super().invoke(context, event) + ProductDecorator.install(context) + self.tool_state.use_default_container = True + self.tool_state.plane_method = "XY" + return {"RUNNING_MODAL"} diff --git a/src/bonsai/bonsai/bim/module/model/prop.py b/src/bonsai/bonsai/bim/module/model/prop.py index c725c6178c..78ebe62751 100644 --- a/src/bonsai/bonsai/bim/module/model/prop.py +++ b/src/bonsai/bonsai/bim/module/model/prop.py @@ -112,6 +112,12 @@ def update_search_name(self, context): bpy.ops.bim.load_type_thumbnails(ifc_class=self.ifc_class) +def update_x_angle(self, context): + angle_deg = math.degrees(self.x_angle) + if tool.Cad.is_x(angle_deg, -90, 0.5) or tool.Cad.is_x(angle_deg, 90, 0.5): + self.x_angle = 0 + + class BIMModelProperties(PropertyGroup): ifc_class: bpy.props.EnumProperty(items=get_ifc_class, name="Construction Class", update=update_ifc_class) relating_type_id: bpy.props.EnumProperty( @@ -183,8 +189,10 @@ class BIMModelProperties(PropertyGroup): 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 rl3: bpy.props.FloatProperty(name="RL", default=1, subtype="DISTANCE", description="Z offset for space calculation") - x_angle: bpy.props.FloatProperty(name="X Angle", default=0, subtype="ANGLE", min=-pi / 180 * 89, max=pi / 180 * 89) - type_page: bpy.props.IntProperty(name="Type Page", default=1, min=1,update=update_type_page) + type_page: bpy.props.IntProperty(name="Type Page", default=1, min=1, update=update_type_page) + x_angle: bpy.props.FloatProperty( + name="X Angle", default=0, subtype="ANGLE", min=math.radians(-180), max=math.radians(180), update=update_x_angle + ) type_name: bpy.props.StringProperty(name="Name", default="TYPEX") boundary_class: bpy.props.EnumProperty(items=get_boundary_class, name="Boundary Class") direction_sense: bpy.props.EnumProperty( @@ -842,3 +850,14 @@ class BIMPolylineProperties(PropertyGroup): snap_mouse_ref: bpy.props.CollectionProperty(type=SnapMousePoint) insertion_polyline: bpy.props.CollectionProperty(type=Polyline) measurement_polyline: bpy.props.CollectionProperty(type=Polyline) + + +class ProductPreviewItem(PropertyGroup): + value_3d: bpy.props.FloatVectorProperty() + value_2d: bpy.props.FloatVectorProperty(size=2) + + +class BIMProductPreviewProperties(PropertyGroup): + verts: bpy.props.CollectionProperty(type=ProductPreviewItem) + edges: bpy.props.CollectionProperty(type=ProductPreviewItem) + tris: bpy.props.CollectionProperty(type=ProductPreviewItem) diff --git a/src/bonsai/bonsai/bim/module/model/railing.py b/src/bonsai/bonsai/bim/module/model/railing.py index 1dc6638b5c..edf4eae35d 100644 --- a/src/bonsai/bonsai/bim/module/model/railing.py +++ b/src/bonsai/bonsai/bim/module/model/railing.py @@ -26,7 +26,6 @@ import ifcopenshell.util.unit import bonsai.core.root import bonsai.core.geometry import bonsai.tool as tool -from bonsai.bim.module.model.door import bm_sort_out_geom from bonsai.bim.module.model.data import RailingData, refresh from bonsai.bim.module.model.decorator import ProfileDecorator @@ -47,7 +46,7 @@ def bm_split_edge_at_offset(edge: bmesh.types.BMEdge, offset: float) -> dict[str split_output_0 = bmesh.utils.edge_split(edge, v0, offset / edge_len) split_output_1 = bmesh.utils.edge_split(edge, v1, offset / (edge_len - offset)) - new_geometry = bm_sort_out_geom(split_output_0 + split_output_1) + new_geometry = tool.Model.bm_sort_out_geom(split_output_0 + split_output_1) return new_geometry @@ -175,11 +174,11 @@ def update_railing_modifier_bmesh(context: bpy.types.Context) -> None: ortho_vector = edge_dir.cross(V_(0, 0, 1)) extruded_geom = bmesh.ops.extrude_edge_only(bm, edges=[main_edge])["geom"] - extruded_verts = bm_sort_out_geom(extruded_geom)["verts"] + extruded_verts = tool.Model.bm_sort_out_geom(extruded_geom)["verts"] bmesh.ops.translate(bm, vec=ortho_vector * (-thickness / 2), verts=extruded_verts) extruded_geom = bmesh.ops.extrude_edge_only(bm, edges=[main_edge])["geom"] - extruded_verts = bm_sort_out_geom(extruded_geom)["verts"] + extruded_verts = tool.Model.bm_sort_out_geom(extruded_geom)["verts"] bmesh.ops.translate(bm, vec=ortho_vector * (thickness / 2), verts=extruded_verts) # dissolve middle edge @@ -187,7 +186,7 @@ def update_railing_modifier_bmesh(context: bpy.types.Context) -> None: # height extruded_geom = bmesh.ops.extrude_face_region(bm, geom=bm.faces)["geom"] - extruded_verts = bm_sort_out_geom(extruded_geom)["verts"] + extruded_verts = tool.Model.bm_sort_out_geom(extruded_geom)["verts"] extrusion_vector = Vector((0, 0, 1)) * height bmesh.ops.translate(bm, vec=extrusion_vector, verts=extruded_verts) diff --git a/src/bonsai/bonsai/bim/module/model/roof.py b/src/bonsai/bonsai/bim/module/model/roof.py index e60fdc65a7..d31a251c94 100644 --- a/src/bonsai/bonsai/bim/module/model/roof.py +++ b/src/bonsai/bonsai/bim/module/model/roof.py @@ -26,7 +26,6 @@ import ifcopenshell.util.unit import bonsai.core.root import bonsai.tool as tool from bonsai.bim.helper import convert_property_group_from_si -from bonsai.bim.module.model.door import bm_sort_out_geom from bonsai.bim.module.model.data import RoofData, refresh from bonsai.bim.module.model.decorator import ProfileDecorator @@ -327,7 +326,7 @@ def generate_hiped_roof_bmesh( bmesh.ops.delete(bm, geom=bottom_chords_to_remove, context="EDGES") # add roof thickness - extrusion_geom = bm_sort_out_geom(bmesh.ops.extrude_face_region(bm, geom=bm.faces)["geom"]) + extrusion_geom = tool.Model.bm_sort_out_geom(bmesh.ops.extrude_face_region(bm, geom=bm.faces)["geom"]) extruded_edges = extrusion_geom["edges"] extruded_verts = extrusion_geom["verts"] rafter_edge_angle = pi / 2 - rafter_edge_angle diff --git a/src/bonsai/bonsai/bim/module/model/wall.py b/src/bonsai/bonsai/bim/module/model/wall.py index 88db7f3a26..1988ab7864 100644 --- a/src/bonsai/bonsai/bim/module/model/wall.py +++ b/src/bonsai/bonsai/bim/module/model/wall.py @@ -421,6 +421,8 @@ class DrawPolylineWall(bpy.types.Operator, PolylineOperator): self.handle_inserting_polyline(context, event) + self.get_product_preview_data(context, self.relating_type) + cancel = self.handle_cancelation(context, event) if cancel is not None: ProductDecorator.uninstall() diff --git a/src/bonsai/bonsai/bim/module/model/workspace.py b/src/bonsai/bonsai/bim/module/model/workspace.py index 98a9fc1921..90500a41d2 100644 --- a/src/bonsai/bonsai/bim/module/model/workspace.py +++ b/src/bonsai/bonsai/bim/module/model/workspace.py @@ -118,10 +118,6 @@ class BimTool(WorkSpaceTool): EditObjectUI.draw(context, layout, ifc_element_type=cls.ifc_element_type) else: CreateObjectUI.draw(context, layout, ifc_element_type=cls.ifc_element_type) - # Show some UI for spatial elements that are unselectable by default. - if active_ifc_object: - EditObjectUI.layout = layout # Prevent .draw_modes from using old layout and crash. - EditObjectUI.draw_modes(context) class WallTool(BimTool): @@ -955,6 +951,7 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): def hotkey_S_A(self): props = bpy.context.scene.BIMModelProperties relating_type_id = AuthoringData.data["relating_type_id_current"] + relating_type_class = AuthoringData.data["ifc_class_current"] if relating_type_id is None: self.report({"ERROR"}, "No relating type selected") return @@ -994,6 +991,12 @@ class Hotkey(bpy.types.Operator, tool.Ifc.Operator): relating_type_id and tool.Model.get_usage_type(tool.Ifc.get().by_id(int(relating_type_id))) == "LAYER3" ): bpy.ops.bim.draw_polyline_slab("INVOKE_DEFAULT") + elif ( + relating_type_id + and tool.Model.get_usage_type(tool.Ifc.get().by_id(int(relating_type_id))) == "PROFILE" + and relating_type_class not in {"IfcColumnType"} + ): + bpy.ops.bim.draw_polyline_profile("INVOKE_DEFAULT") else: bpy.ops.bim.add_occurrence("INVOKE_DEFAULT") diff --git a/src/bonsai/bonsai/bim/module/patch/operator.py b/src/bonsai/bonsai/bim/module/patch/operator.py index fe2b021c1d..0a1f4049ca 100644 --- a/src/bonsai/bonsai/bim/module/patch/operator.py +++ b/src/bonsai/bonsai/bim/module/patch/operator.py @@ -98,12 +98,6 @@ class ExecuteIfcPatch(bpy.types.Operator): if props.should_load_from_memory and tool.Ifc.get(): args["file"] = tool.Ifc.get() - if ifcpatch.get_patch_input_argument_use(recipe_name) == "REQUIRED": - self.report( - {"ERROR"}, - f"The recipe '{recipe_name}' is not currently supported if file is loaded from memory.", - ) - return {"CANCELLED"} else: args["input"] = cast(str, props.ifc_patch_input) args["file"] = cast(ifcopenshell.file, ifcopenshell.open(props.ifc_patch_input)) diff --git a/src/bonsai/bonsai/bim/module/pset/__init__.py b/src/bonsai/bonsai/bim/module/pset/__init__.py index 5e53bde928..0ab35183e4 100644 --- a/src/bonsai/bonsai/bim/module/pset/__init__.py +++ b/src/bonsai/bonsai/bim/module/pset/__init__.py @@ -47,7 +47,6 @@ classes = ( ui.BIM_PT_object_psets, ui.BIM_PT_object_qtos, ui.BIM_PT_material_psets, - ui.BIM_PT_material_set_psets, ui.BIM_PT_material_set_item_psets, ui.BIM_PT_task_qtos, ui.BIM_PT_resource_qtos, @@ -66,7 +65,6 @@ classes = ( def register(): bpy.types.Object.PsetProperties = bpy.props.PointerProperty(type=prop.PsetProperties) bpy.types.Scene.MaterialPsetProperties = bpy.props.PointerProperty(type=prop.PsetProperties) - bpy.types.Object.MaterialSetPsetProperties = bpy.props.PointerProperty(type=prop.PsetProperties) bpy.types.Object.MaterialSetItemPsetProperties = bpy.props.PointerProperty(type=prop.PsetProperties) bpy.types.Scene.TaskPsetProperties = bpy.props.PointerProperty(type=prop.PsetProperties) bpy.types.Scene.ResourcePsetProperties = bpy.props.PointerProperty(type=prop.PsetProperties) @@ -82,7 +80,6 @@ def register(): def unregister(): del bpy.types.Object.PsetProperties del bpy.types.Scene.MaterialPsetProperties - del bpy.types.Object.MaterialSetPsetProperties del bpy.types.Object.MaterialSetItemPsetProperties del bpy.types.Scene.TaskPsetProperties del bpy.types.Scene.ResourcePsetProperties diff --git a/src/bonsai/bonsai/bim/module/pset/data.py b/src/bonsai/bonsai/bim/module/pset/data.py index 7f1e391629..6506f7cce7 100644 --- a/src/bonsai/bonsai/bim/module/pset/data.py +++ b/src/bonsai/bonsai/bim/module/pset/data.py @@ -33,7 +33,6 @@ def refresh(): ObjectPsetsData.is_loaded = False ObjectQtosData.is_loaded = False MaterialPsetsData.is_loaded = False - MaterialSetPsetsData.is_loaded = False MaterialSetItemPsetsData.is_loaded = False TaskQtosData.is_loaded = False ResourceQtosData.is_loaded = False @@ -185,7 +184,7 @@ class MaterialPsetsData(Data): if material.ifc_definition_id: material = tool.Ifc.get().by_id(material.ifc_definition_id) category = getattr(material, "Category", None) or None - psets = bonsai.bim.schema.ifc.psetqto.get_applicable("IfcMaterial", category, pset_only=True) + psets = bonsai.bim.schema.ifc.psetqto.get_applicable(props.material_type, category, pset_only=True) psetnames = cls.format_pset_enum(psets) assigned_names = ifcopenshell.util.element.get_psets( material, psets_only=True, should_inherit=False @@ -194,22 +193,6 @@ class MaterialPsetsData(Data): return [] -class MaterialSetPsetsData(Data): - data = {} - is_loaded = False - - @classmethod - def load(cls): - psets = {} - element = tool.Ifc.get_entity(bpy.context.active_object) - if element: - material = ifcopenshell.util.element.get_material(element, should_skip_usage=True) - if material and "Set" in material.is_a(): - psets = cls.psetqtos(material) - cls.data = {"psets": psets} - cls.is_loaded = True - - class MaterialSetItemPsetsData(Data): data = {} is_loaded = False diff --git a/src/bonsai/bonsai/bim/module/pset/ui.py b/src/bonsai/bonsai/bim/module/pset/ui.py index 98839b66c3..93d087c54e 100644 --- a/src/bonsai/bonsai/bim/module/pset/ui.py +++ b/src/bonsai/bonsai/bim/module/pset/ui.py @@ -25,7 +25,6 @@ from bonsai.bim.module.pset.data import ( ObjectPsetsData, ObjectQtosData, MaterialPsetsData, - MaterialSetPsetsData, MaterialSetItemPsetsData, TaskQtosData, ResourceQtosData, @@ -88,7 +87,7 @@ def draw_enumerated_property( def get_active_pset_obj_name(context: bpy.types.Context, obj_type: tool.Ifc.OBJECT_TYPE) -> str: - if obj_type in ("Object", "MaterialSet", "MaterialSetItem"): + if obj_type in ("Object", "MaterialSetItem"): return context.active_object.name return "" @@ -197,7 +196,9 @@ def draw_psetqto_ui( row = box.row(align=True) row.scale_y = 0.8 row.label(text=prop["Name"]) - op = row.operator("bim.select_similar", text=get_display_value(nominal_value), icon="NONE", emboss=False) + op = row.operator( + "bim.select_similar", text=get_display_value(nominal_value), icon="NONE", emboss=False + ) op.key = '"' + pset["Name"].replace('"', '\\"') + '"."' + prop["Name"].replace('"', '\\"') + '"' # calculate sum of all selected objects if active_operator: @@ -400,10 +401,8 @@ class BIM_PT_material_psets(Panel): if not ifc_file or ifc_file.schema == "IFC2X3": return False # We don't support material psets in IFC2X3 because they suck props = context.scene.BIMMaterialProperties - if props.materials and props.active_material_index < len(props.materials): - material = props.materials[props.active_material_index] - if material.ifc_definition_id: - return True + if props.is_editing and (material := props.active_material) and material.ifc_definition_id: + return True return False def draw(self, context): @@ -430,46 +429,6 @@ class BIM_PT_material_psets(Panel): draw_psetqto_ui(context, pset["id"], pset, props, self.layout, "Material") -class BIM_PT_material_set_psets(Panel): - bl_label = "Material Set Property Sets" - bl_idname = "BIM_PT_material_set_psets" - bl_options = {"DEFAULT_CLOSED"} - bl_space_type = "PROPERTIES" - bl_region_type = "WINDOW" - bl_context = "object" - bl_parent_id = "BIM_PT_object_material" - - @classmethod - def poll(cls, context): - if not context.active_object: - return False - if not tool.Ifc.get() or tool.Ifc.get().schema == "IFC2X3": - return False # We don't support material psets in IFC2X3 because they suck - if not tool.Ifc.get_entity(context.active_object): - return False - if not ObjectMaterialData.is_loaded: - ObjectMaterialData.load() - ifc_class = ObjectMaterialData.data["material_class"] - return bool(ifc_class and "Set" in ifc_class) - - def draw(self, context): - if not MaterialSetPsetsData.is_loaded: - MaterialSetPsetsData.load() - - props = context.active_object.MaterialSetPsetProperties - row = self.layout.row(align=True) - prop_with_search(row, props, "pset_name", text="") - op = row.operator("bim.add_pset", icon="ADD", text="") - op.obj = context.active_object.name - op.obj_type = "MaterialSet" - - if not props.active_pset_id and props.active_pset_name and props.active_pset_type == "PSET": - draw_psetqto_ui(context, 0, {}, props, self.layout, "MaterialSet") - - for pset in MaterialSetPsetsData.data["psets"]: - draw_psetqto_ui(context, pset["id"], pset, props, self.layout, "MaterialSet") - - class BIM_PT_material_set_item_psets(Panel): bl_label = "Material Set Item Property Sets" bl_idname = "BIM_PT_material_set_item_psets" diff --git a/src/bonsai/bonsai/bim/module/root/operator.py b/src/bonsai/bonsai/bim/module/root/operator.py index 7cde90af05..8a606ddc9b 100644 --- a/src/bonsai/bonsai/bim/module/root/operator.py +++ b/src/bonsai/bonsai/bim/module/root/operator.py @@ -532,19 +532,19 @@ class AddElement(bpy.types.Operator, tool.Ifc.Operator): "material.assign_profile", tool.Ifc.get(), material_profile=material_profile, profile=profile ) elif representation_template == "WINDOW": - with context.temp_override(active_object=obj): + with context.temp_override(active_object=obj, selected_objects=[]): bpy.ops.bim.add_window() elif representation_template == "DOOR": - with context.temp_override(active_object=obj): + with context.temp_override(active_object=obj, selected_objects=[]): bpy.ops.bim.add_door() elif representation_template == "STAIR": - with context.temp_override(active_object=obj): + with context.temp_override(active_object=obj, selected_objects=[]): bpy.ops.bim.add_stair() elif representation_template == "RAILING": - with context.temp_override(active_object=obj): + with context.temp_override(active_object=obj, selected_objects=[]): bpy.ops.bim.add_railing() elif representation_template == "ROOF": - with context.temp_override(active_object=obj): + with context.temp_override(active_object=obj, selected_objects=[]): bpy.ops.bim.add_roof() def draw(self, context): diff --git a/src/bonsai/bonsai/bim/module/structural/__init__.py b/src/bonsai/bonsai/bim/module/structural/__init__.py index 22bd3c1f1f..db72ad497c 100644 --- a/src/bonsai/bonsai/bim/module/structural/__init__.py +++ b/src/bonsai/bonsai/bim/module/structural/__init__.py @@ -20,6 +20,7 @@ import bpy from . import ui, prop, operator, workspace classes = ( + operator.ShowLoads, operator.LoadStructuralAnalysisModels, operator.DisableStructuralAnalysisModelEditingUI, operator.AddStructuralAnalysisModel, @@ -83,6 +84,7 @@ classes = ( ui.BIM_PT_connected_structural_members, ui.BIM_UL_structural_analysis_models, ui.BIM_UL_structural_activities, + ui.BIM_PT_show_structural_activities, ui.BIM_PT_structural_load_cases, ui.BIM_UL_structural_loads, ui.BIM_PT_structural_loads, diff --git a/src/bonsai/bonsai/bim/module/structural/data.py b/src/bonsai/bonsai/bim/module/structural/data.py index c088889f94..9a55a826fb 100644 --- a/src/bonsai/bonsai/bim/module/structural/data.py +++ b/src/bonsai/bonsai/bim/module/structural/data.py @@ -30,6 +30,50 @@ def refresh(): StructuralLoadCasesData.is_loaded = False StructuralLoadsData.is_loaded = False BoundaryConditionsData.is_loaded = False + LoadGroupDecorationData.is_loaded = False + + +class LoadGroupDecorationData: + data = {} + is_loaded = False + + @classmethod + def load(cls): + cls.data = {"load groups to show": cls.load_groups_to_show()} + cls.is_loaded = True + + @classmethod + def load_groups_to_show(cls): + ret = [] + abrv = { + "LOAD_CASE": "L.Case: ", + "LOAD_COMBINATION": "L.Comb: ", + "LOAD_GROUP": "L.Gr: ", + "USERDEFINED": "U.Def: ", + "NOTDEFINED": "N.Def: ", + } + models = tool.Ifc.get().by_type("IfcStructuralAnalysisModel") + m = models[0] + props = bpy.context.scene.BIMStructuralProperties + if props.activity_type == "Action": + groups = m.LoadedBy or [] + for g in groups: + ret.append((str(g.id()), ". " + abrv[g.PredefinedType] + " " + g.Name, "")) + related_objects = [rel.RelatedObjects for rel in g.IsGroupedBy] + for item in related_objects: + for subgoup in [sg for sg in item if sg.is_a("IfcStructuralLoadGroup")]: + ret.append((str(subgoup.id()), ". " + abrv[subgoup.PredefinedType] + subgoup.Name, "")) + + if props.activity_type == "External Reaction": + groups = m.HasResults or [] + for g in groups: + result_name = g.ResultForLoadGroup.Name or "" + group_name = g.Name or "" + ret.append((str(g.id()), group_name + " " + result_name, "")) + + if len(ret) == 0: + ret.append(("", "", "")) + return ret class StructuralBoundaryConditionsData: diff --git a/src/bonsai/bonsai/bim/module/structural/decorator.py b/src/bonsai/bonsai/bim/module/structural/decorator.py new file mode 100644 index 0000000000..f17c643d34 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/structural/decorator.py @@ -0,0 +1,156 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2020, 2021 Dion Moult +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . + +from mathutils import Vector +import numpy as np +import bpy +import gpu +import blf +from bpy.types import SpaceView3D +from gpu_extras.batch import batch_for_shader +from typing import Iterable, Union +from bonsai.bim.module.structural.load_decoration_data import ShaderInfo + + +class LoadsDecorator: + """Decorator to show strucutural loads in 3D""" + + is_installed = False + handlers = [] + decoration_data = None + text_info = [] + shader_info = [] + depth_array = None + + @classmethod + def install(cls, context: bpy.types.Context) -> None: + if cls.is_installed: + cls.uninstall() + handler = cls() + cls.handlers.append( + SpaceView3D.draw_handler_add(handler.draw_load_values, ((context,)), "WINDOW", "POST_PIXEL") + ) + cls.handlers.append(SpaceView3D.draw_handler_add(handler, (), "WINDOW", "POST_VIEW")) + cls.decoration_data = ShaderInfo() + cls.update() + cls.is_installed = True + + @classmethod + def uninstall(cls) -> None: + for handler in cls.handlers: + try: + SpaceView3D.draw_handler_remove(handler, "WINDOW") + except ValueError: + pass + cls.is_installed = False + + @classmethod + def update(cls) -> None: + cls.decoration_data.update() + cls.text_info = cls.decoration_data.text_info + cls.shader_info = cls.decoration_data.info + + def __call__(self) -> None: + """set gpu configurations to draw 3D representations""" + # set open gl configurations + original_blend = gpu.state.blend_get() + original_depth_test = gpu.state.depth_test_get() + gpu.state.blend_set("ALPHA") + gpu.state.depth_test_set("LESS_EQUAL") + + self.draw_batch() + + # restore opengl configurations + gpu.state.blend_set(original_blend) + gpu.state.depth_test_set(original_depth_test) + + def draw_batch(self) -> None: + """draw the 3D representation of loads""" + if not self.decoration_data.is_empty: + for info in self.shader_info: + shader = info["shader"] + args = info["args"] + indices = info["indices"] + batch = batch_for_shader(shader, "TRIS", args, indices=indices) + matrix = bpy.context.region_data.perspective_matrix + shader.bind() + shader.uniform_float("viewProjectionMatrix", matrix) + + for key, value in info["uniforms"]: + shader.uniform_float(key, value) + + batch.draw(shader) + + def draw_load_values(self, context: bpy.types.Context) -> None: + """draw text representing the load values""" + # getting depth buffer info, code adapted from: + # https://blender.stackexchange.com/questions/177185/is-there-a-way-to-render-depth-buffer-into-a-texture-with-gpu-bgl-python-modules + framebuffer = gpu.state.active_framebuffer_get() + width = context.region.width + height = context.region.height + depth_buffer = framebuffer.read_depth(0, 0, width, height) + depth_array = np.array(depth_buffer.to_list()) + + f = context.area.spaces.active.clip_end + n = context.area.spaces.active.clip_start + + self.depth_array = n / (f - (f - n) * depth_array) * (f - n) + + for info in self.text_info: + text_position = self.location_3d_to_region_2d(info["position"], context) + if text_position is not None: + font_id = 0 + blf.position(font_id, text_position[0], text_position[1], text_position[2]) + blf.size(font_id, 20.0) + blf.color(font_id, 0.9, 0.9, 0.9, 1.0) + blf.draw(font_id, info["text"]) + + def location_3d_to_region_2d(self, coord: Iterable, context: bpy.types.Context) -> Union[Vector, None]: + """Convert from 3D space to 2D screen space. + Filter out the text supposed to be hidden by 3D elements, using the depth array. + It also hides text that are distant from the camera view to avoid clutter""" + + coord = Vector(coord) + rv3d = context.region_data + perspective = rv3d.view_perspective + view_matrix = rv3d.view_matrix + point_view_space = view_matrix @ coord + + if perspective == "ORTHO" or -10 < point_view_space.z < 0: + prj = rv3d.perspective_matrix @ Vector((coord[0], coord[1], coord[2], 1.0)) + width_half = context.region.width / 2.0 + height_half = context.region.height / 2.0 + coord_2d = Vector( + ( + width_half + width_half * (prj.x / prj.w), + height_half + height_half * (prj.y / prj.w), + point_view_space.z, + ) + ) + if ( + coord_2d[0] < 0 + or coord_2d[0] > context.region.width + or coord_2d[1] > context.region.height + or coord_2d[1] < 0 + ): + return None + depth = self.depth_array[int(coord_2d[1])][int(coord_2d[0])] + if -0.98 * point_view_space.z > depth: + return None + return coord_2d + return None diff --git a/src/bonsai/bonsai/bim/module/structural/load_decoration_data.py b/src/bonsai/bonsai/bim/module/structural/load_decoration_data.py new file mode 100644 index 0000000000..5a9817b7f9 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/structural/load_decoration_data.py @@ -0,0 +1,993 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2020, 2021 Dion Moult +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . + +import bpy +import bmesh +import numpy as np +from math import sin +from mathutils import Vector +import ifcopenshell +import ifcopenshell.api +import ifcopenshell.util.attribute +import ifcopenshell.util.unit as ifcunit +import bonsai.tool as tool +from bonsai.bim.ifc import IfcStore +from bonsai.bim.module.structural.shader import DecorationShader +from typing import Literal, TypedDict, Iterable + +MemberInfo = TypedDict( + "MemberInfo", + {"member": ifcopenshell.entity_instance, "activities": list[tuple[ifcopenshell.entity_instance, float]]}, +) + +LoadConfigItem = TypedDict( + "LoadConfigItem", {"pos": float, "descr": Literal["start", "end", "middle"], "load values": np.ndarray} +) + +DiscreteConfigItem = TypedDict("DiscreteConfigItem", {"pos": float, "values": list[float]}) + +ParsedLoad = TypedDict( + "ParsedLoad", + { + "constant force": list[float], + "quadratic force": list[float], + "sinus force": list[float], + "linear load configuration": list[list[LoadConfigItem]], + "point load configuration": list[list[DiscreteConfigItem]], + }, +) +LoadByDirection = TypedDict( + "LoadByDirection", {"constant": float, "quadratic": float, "sinus": float, "polyline": list[list[float]]} +) + +ProcessedLoad = TypedDict( + "ProcessedLoad", + {"linear loads": LoadByDirection, "max linear load": float, "discrete loads": list[list[DiscreteConfigItem]]}, +) + + +class ShaderInfo: + def __init__(self) -> None: + self.is_empty = True + self.shader = DecorationShader() + self.curve_members: dict[str, MemberInfo] = {} + self.point_members: dict[str, MemberInfo] = {} + self.surface_members: dict[str, MemberInfo] = {} + self.text_info = [] + self.info = [] + self.force_unit = "" + self.moment_unit = "" + self.linear_force_unit = "" + self.linear_moment_unit = "" + self.planar_force_unit = "" + + def update(self) -> None: + self.info = [] + self.text_info = [] + self.curve_members = {} + self.point_members = {} + self.surface_members = {} + self.get_force_units() + self.get_strucutural_elements_and_activities() + self.get_linear_loads() + self.get_point_loads() + self.get_planar_loads() + if len(self.info): + self.is_empty = False + + def get_force_units(self) -> None: + def get_unit_symbol(unit: ifcopenshell.entity_instance) -> str: + prefix_symbols = { + "EXA": "E", + "PETA": "P", + "TERA": "T", + "GIGA": "G", + "MEGA": "M", + "KILO": "k", + "HECTO": "h", + "DECA": "da", + "DECI": "d", + "CENTI": "c", + "MILLI": "m", + "MICRO": "μ", + "NANO": "n", + "PICO": "p", + "FEMTO": "f", + "ATTO": "a", + } + + unit_symbols = { + # si units + "CUBIC_METRE": "m3", + "GRAM": "g", + "SECOND": "s", + "SQUARE_METRE": "m2", + "METRE": "m", + "NEWTON": "N", + "PASCAL": "Pa", + # conversion based units + "pound-force": "lbf", + "pound-force per square inch": "psi", + "thou": "th", + "inch": "in", + "foot": "ft", + "yard": "yd", + "mile": "mi", + "square thou": "th2", + "square inch": "in2", + "square foot": "ft2", + "square yard": "yd2", + "acre": "ac", + "square mile": "mi2", + "cubic thou": "th3", + "cubic inch": "in3", + "cubic foot": "ft3", + "cubic yard": "yd3", + "cubic mile": "mi3", + "litre": "L", + "fluid ounce UK": "fl oz", + "fluid ounce US": "fl oz", + "pint UK": "pt", + "pint US": "pt", + "gallon UK": "gal", + "gallon US": "gal", + "degree": "°", + "ounce": "oz", + "pound": "lb", + "ton UK": "ton", + "ton US": "ton", + "lbf": "lbf", + "kip": "kip", + "psi": "psi", + "ksi": "ksi", + "minute": "min", + "hour": "hr", + "day": "day", + "btu": "btu", + "fahrenheit": "°F", + } + symbol = "" + if unit.is_a("IfcSIUnit"): + symbol += prefix_symbols.get(unit.Prefix, "") + symbol += unit_symbols.get(unit.Name.replace("METER", "METRE"), "?") + return symbol + + length_units = [u for u in tool.Ifc.get().by_type("IfcNamedUnit") if u.UnitType == "LENGTHUNIT"] + force_units = [u for u in tool.Ifc.get().by_type("IfcNamedUnit") if u.UnitType == "FORCEUNIT"] + linear_force_units = [u for u in tool.Ifc.get().by_type("IfcDerivedUnit") if u.UnitType == "LINEARFORCEUNIT"] + linear_moment_units = [u for u in tool.Ifc.get().by_type("IfcDerivedUnit") if u.UnitType == "LINEARMOMENTUNIT"] + planar_force_units = [u for u in tool.Ifc.get().by_type("IfcDerivedUnit") if u.UnitType == "PLANARFORCEUNIT"] + + conversion_force_unit = [u for u in force_units if u.is_a("IfcConversionBasedUnit")] + if len(conversion_force_unit) == 0: + conversion_force_unit.append(force_units[0]) + self.force_unit = ifcunit.get_unit_symbol(conversion_force_unit[0]) + + conversion_length_unit = [u for u in length_units if u.is_a("IfcConversionBasedUnit")] + if len(conversion_length_unit) == 0: + conversion_length_unit.append(length_units[0]) + length_unit = ifcunit.get_unit_symbol(conversion_length_unit[0]) + self.moment_unit = self.force_unit + "." + length_unit + + first = "" + second = "" + for e in linear_force_units[0].Elements: + if e.Unit.UnitType == "FORCEUNIT": + first = ifcunit.get_unit_symbol(e.Unit) + if e.Unit.UnitType == "LENGTHUNIT": + second = ifcunit.get_unit_symbol(e.Unit) + self.linear_force_unit = first + "/" + second + + first = "" + second = "" + for e in linear_moment_units[0].Elements: + if e.Unit.UnitType == "FORCEUNIT": + first = ifcunit.get_unit_symbol(e.Unit) + if e.Unit.UnitType == "LENGTHUNIT": + second = ifcunit.get_unit_symbol(e.Unit) + self.linear_moment_unit = first + "." + second + "/" + second + + first = "" + second = "" + for e in planar_force_units[0].Elements: + if e.Unit.UnitType == "FORCEUNIT": + first = ifcunit.get_unit_symbol(e.Unit) + if e.Unit.UnitType == "LENGTHUNIT": + second = ifcunit.get_unit_symbol(e.Unit) + "2" + if e.Unit.UnitType == "AREAUNIT": + second = ifcunit.get_unit_symbol(e.Unit) + self.planar_force_unit = first + "/" + second + + def get_strucutural_elements_and_activities(self) -> None: + """fills self.point_members, self.curve_members and self.surface_members dictionaries""" + + def populate_members_dict( + dict_name: Literal["point_members", "curve_members", "surface_members"], + element: ifcopenshell.entity_instance, + activity: ifcopenshell.entity_instance, + factor: float, + ) -> None: + """ + fills self.point_members, self.curve_members and self.surface_members dictionaries + thoses dicts will contain the strucutural member global id as key and a second dict as value + the second dict contais two keys, as follow: + { + "member": the strucutral member itself + "activities: list[(activity, factor)] for each activity applied to the member + } + dict_name: "point_members", "curve_members" or "surface_members" + element: IfcStructuralMember + activity: IfcStructuralActivity + factor: float to multiply the loads values in the structural activity + """ + dic = getattr(self, dict_name, None) + if dic is None: + return + member = dic.get(element.GlobalId) + if member is None: + dic.update({element.GlobalId: {"member": element, "activities": [(activity, factor)]}}) + else: + member["activities"].append((activity, factor)) + + def recursive_subgroups( + groups: list[ifcopenshell.entity_instance], + rec_limit: int, + activity_type: Literal["Action", "External Reaction"], + factor: float = 1, + ) -> None: + """ + Recursively fills self.point_members, self.curve_members and self.surface_members dictionaries + with the structural members to wich the activities in the load group and its subgroups are applied + it also creates a list with all the activities applied to that member that are in the same + load group or subgroup (see populate_members_dict description) + + groups: list of Ifc load case, load group or load combination + rec_limit: maximum number of recursions + activity_type: "Action" or "External Reaction" + factor: the factor applied to the group, default = 1 + this factor will be multiplied by the group coefficient and the factor of a + IfcRelAssignsToGroupByFactor relationship of subgroups in load combinations + + """ + if rec_limit == 0 or len(groups) == 0: + return None + for group in groups: + subgorups = [] + activities = [] + relationship = [rel for rel in group.IsGroupedBy] + coef = getattr(group, "Coefficient", 1.0) + group_coef = coef if coef is not None else 1.0 + rel_factor = 1.0 + + for rel in relationship: + if rel.is_a("IfcRelAssignsToGroupByFactor"): + rel_factor = rel.Factor if rel.Factor is not None else 1.0 + objects = rel.RelatedObjects + subgorups = [sg for sg in objects if sg.is_a("IfcStructuralLoadGroup")] + activities = [a for a in objects if a.is_a("IfcStructuralActivity")] + factor = factor * group_coef * rel_factor + for activity in activities: + if len(activity.AssignedToStructuralItem): + element = activity.AssignedToStructuralItem[0].RelatingElement + if element is not None: + if activity_type == "Action": + if element.is_a("IfcStructuralCurveMember"): + populate_members_dict("curve_members", element, activity, factor) + elif element.is_a("IfcStructuralPointConnection"): + populate_members_dict("point_members", element, activity, factor) + elif element.is_a("IfcStructuralSurfaceMember"): + populate_members_dict("surface_members", element, activity, factor) + + elif ( + activity_type == "External Reaction" + and getattr(element, "AppliedCondition", None) is not None + ): + if element.is_a("IfcStructuralCurveMember"): + populate_members_dict("curve_members", element, activity, factor) + elif element.is_a("IfcStructuralPointConnection"): + populate_members_dict("point_members", element, activity, factor) + elif element.is_a("IfcStructuralSurfaceMember"): + populate_members_dict("surface_members", element, activity, factor) + recursive_subgroups(subgorups, rec_limit - 1, activity_type, factor=factor) + + props = bpy.context.scene.BIMStructuralProperties + group_definition_id = int(props.load_group_to_show) + file = IfcStore.get_file() + groups = [file.by_id(group_definition_id)] + recursive_subgroups(groups, 10, props.activity_type) + + def get_planar_loads(self) -> None: + """get the necessary information to render the planar load representation in 3D and its text information""" + + list_of_surfaces = self.surface_members + shader = self.shader.new("PLANAR LOAD") + maximum = 0 + for value in list_of_surfaces.values(): + surf = value["member"] + activity_list = value["activities"] + if len(activity_list) == 0: + continue + rotation = self.get_surface_member_rotation(surf) + values = self.get_planar_loads_values(activity_list, rotation) + if maximum == 0: + maximum = max([abs(float(i)) for i in values]) + if maximum == 0: + continue + props = bpy.context.scene.BIMStructuralProperties + reference_frame = props.reference_frame + orientation = np.eye(3) + if reference_frame == "LOCAL_COORDS": + orientation = rotation + blender_object: bpy.types.Object = IfcStore.get_element(getattr(surf, "GlobalId", None)) + mat = blender_object.matrix_world + mesh: bpy.types.Mesh = blender_object.data + + positions = [] + indices = [] + coord = [] + bm = bmesh.new() + bm.from_mesh(mesh) + bmesh.ops.triangulate(bm, faces=bm.faces) + bm.edges.ensure_lookup_table() + bm.verts.ensure_lookup_table() + bm.faces.ensure_lookup_table() + + for v in bm.verts: + p1 = np.array(mat @ v.co) + positions.append(p1) + p2 = p1 - (orientation @ values) * 0.2 / maximum + positions.append(p2) + coord.append((float(p1[0] + p1[1]), 0, 1)) + coord.append((float(p1[0] + p1[1]), 1, 1)) + for e in bm.edges: + if len(e.link_faces) > 1: + continue + indices.append((2 * e.verts[0].index, 2 * e.verts[0].index + 1, 2 * e.verts[1].index)) + indices.append((2 * e.verts[0].index + 1, 2 * e.verts[1].index, 2 * e.verts[1].index + 1)) + for p in bm.faces: + indices.append((2 * p.verts[0].index + 1, 2 * p.verts[1].index + 1, 2 * p.verts[2].index + 1)) + bmesh.ops.dissolve_limit(bm, angle_limit=0.01, verts=bm.verts, edges=bm.edges) + bm.faces.ensure_lookup_table() + center = bm.faces[0].calc_center_bounds() + + self.text_info.append( + { + "position": mat @ center - Vector((orientation @ values) * 0.2 / maximum), + "text": f"{values[2]:.5f} {self.planar_force_unit}", + } + ) + + self.info.append( + { + "shader": shader, + "args": {"position": positions, "coord": coord}, + "indices": indices, + "uniforms": [["color", (0.2, 0, 1, 1)], ["spacing", 0.2]], + } + ) + + def get_planar_loads_values( + self, activity_list: list[tuple[ifcopenshell.entity_instance, float]], element_rotation_matrix: np.ndarray + ) -> np.ndarray: + """ + returns a numpy array with the sum of the values of structural activities + applied loads in each direction, multiplied by the factors in load combinations + """ + values = np.zeros((3)) + for item in activity_list: + activity = item[0] + factor = item[1] + load = activity.AppliedLoad + temp = np.zeros((3)) + if load is not None and load.is_a("IfcStructuralLoadPlanarForce"): + temp[0] = load.PlanarForceX if load.PlanarForceX is not None else 0 + temp[1] = load.PlanarForceY if load.PlanarForceY is not None else 0 + temp[2] = load.PlanarForceZ if load.PlanarForceZ is not None else 0 + temp = temp * factor + transform = self.get_activity_transform_matrix(activity, element_rotation_matrix) + values += transform @ temp + return values + + def get_surface_member_rotation(self, surface_member: ifcopenshell.entity_instance) -> np.ndarray: + """returns the rotation matrix of a structural surface member""" + representation = ifcopenshell.util.representation.get_representation(surface_member, "Model") + repr_item = representation.Items[0] + placement = ifcopenshell.util.placement.get_axis2placement(repr_item.FaceSurface.Position) + rotation = placement[0:3, 0:3] + return rotation + + def get_point_connection_rotation(self, point_connection: ifcopenshell.entity_instance) -> np.ndarray: + """returns the rotation matrix of a structural point connection""" + if point_connection.ConditionCoordinateSystem is not None: + placement = ifcopenshell.util.placement.get_axis2placement(point_connection.ConditionCoordinateSystem) + else: + placement = np.eye(4) + rotation = placement[0:3, 0:3] + return rotation + + def get_curve_member_rotation(self, curve_member: ifcopenshell.entity_instance) -> np.ndarray: + """returns the rotation matrix of a structural surface member""" + z = curve_member.Axis.DirectionRatios + edge = curve_member.Representation.Representations[0].Items[0] + origin = edge.EdgeStart.VertexGeometry.Coordinates + end = edge.EdgeEnd.VertexGeometry.Coordinates + x = [c2 - c1 for c1, c2 in zip(origin, end)] + placement = ifcopenshell.util.placement.a2p(origin, z, x) + rotation = placement[0:3, 0:3] + return rotation + + def get_activity_transform_matrix( + self, activity: ifcopenshell.entity_instance, element_rotation_matrix: np.ndarray + ) -> np.ndarray: + "provides the transformation matrix to convert between reference frames" + global_or_local = activity.GlobalOrLocal + props = bpy.context.scene.BIMStructuralProperties + reference_frame = props.reference_frame + transform_matrix = np.eye(3) + if reference_frame == "LOCAL_COORDS" and global_or_local != reference_frame: + transform_matrix = np.linalg.inv(element_rotation_matrix) + elif reference_frame == "GLOBAL_COORDS" and global_or_local != reference_frame: + transform_matrix = element_rotation_matrix + return transform_matrix + + def get_point_loads(self) -> None: + """get the necessary information to render the point load representation in 3D and its text information""" + + list_of_point_connections = self.point_members + for value in list_of_point_connections.values(): + conn = value["member"] + activity_list = value["activities"] + if len(activity_list) == 0: + continue + blender_object = IfcStore.get_element(getattr(conn, "GlobalId", None)) + if blender_object.type == "MESH": + conn_location = blender_object.matrix_world @ blender_object.data.vertices[0].co + rotation = self.get_point_connection_rotation(conn) + loads = self.get_point_loads_values(activity_list, rotation) + self.get_point_shader_args(loads, conn_location, rotation) + + def get_point_shader_args(self, loads: Iterable, location: np.ndarray, rotation: np.ndarray) -> None: + """get the args to the point shader""" + location = np.array(location) + indices = [] + direction_dict = { + "fx": (np.array((1, 0, 0)), np.array((0, 1, 0)), np.array((0, 0, 1))), + "fy": (np.array((0, 1, 0)), np.array((1, 0, 0)), np.array((0, 0, 1))), + "fz": (np.array((0, 0, 1)), np.array((0, 1, 0)), np.array((1, 0, 0))), + "mx": (np.array((0, 1, 0)), np.array((0, 0, 1))), + "my": (np.array((1, 0, 0)), np.array((0, 0, 1))), + "mz": (np.array((1, 0, 0)), np.array((0, 1, 0))), + } + keys = ["fx", "fy", "fz", "mx", "my", "mz"] + props = bpy.context.scene.BIMStructuralProperties + reference_frame = props.reference_frame + if reference_frame == "LOCAL_COORDS": + for key in keys: + tup = direction_dict[key] + li = [] + for item in tup: + li.append(rotation @ item) + direction_dict[key] = li + + for i, key in enumerate(keys): + if loads[i] == 0: + continue + color = (1, 0, 0, 1) + if i in [1, 4]: + color = (0, 1, 0, 1) + elif i in [2, 5]: + color = (0, 0, 1, 1) + d1 = -(direction_dict[key][0] * loads[i]) + d1 = d1 / np.linalg.norm(d1) + if i < 3: + d2 = direction_dict[key][1] + d3 = direction_dict[key][2] + p1 = location + p2 = location + d1 + d2 + p3 = location + d1 - d2 + p4 = location + d1 + d3 + p5 = location + d1 - d3 + position = [p1, p2, p3, p4, p5] + indices = [(0, 1, 2), (0, 3, 4)] + c1 = (0, 0, 0) + c2 = (1, 1, 0) + c3 = (-1, 1, 0) + coords_for_shader = [c1, c2, c3, c2, c3] + shader = self.shader.new("SINGLE FORCE") + self.info.append( + { + "shader": shader, + "args": {"position": position, "coord": coords_for_shader}, + "indices": indices, + "uniforms": [["color", color], ["spacing", 0.2]], + } + ) + self.text_info.append({"position": location + d1, "text": f"{loads[i]:.2f} {self.force_unit}"}) + else: + d2 = d2 = direction_dict[key][1] + p1 = location - d2 + p2 = location + d1 + d2 + p3 = location - d1 + d2 + position = [p1, p2, p3] + indices = [(0, 1, 2)] + c1 = (-1, 0, 0) + c2 = (1, 1, 0) + c3 = (1, -1, 0) + coords_for_shader = [c1, c2, c3] + shader = self.shader.new("SINGLE MOMENT") + self.info.append( + { + "shader": shader, + "args": {"position": position, "coord": coords_for_shader}, + "indices": indices, + "uniforms": [["color", color]], + } + ) + self.text_info.append( + {"position": location + 0.25 * (d1 + d2), "text": f"{loads[i]:.2f} {self.moment_unit}"} + ) + + def get_point_loads_values( + self, activity_list: list[tuple[ifcopenshell.entity_instance, float]], element_rotation_matrix: np.ndarray + ) -> np.ndarray: + """returns a numpy array with the sum of the point load values""" + result_list = np.zeros(6) + attr_list = ["ForceX", "ForceY", "ForceZ", "MomentX", "MomentY", "MomentZ"] + for item in activity_list: + activity = item[0] + factor = item[1] + load = activity.AppliedLoad + temp = np.zeros(6) + for i, attr in enumerate(attr_list): + value = 0 if getattr(load, attr, 0) is None else getattr(load, attr, 0) + temp[i] += value * factor + transform_3 = self.get_activity_transform_matrix(activity, element_rotation_matrix) + transform_6 = np.zeros((6, 6)) + transform_6[0:3, 0:3] = transform_3 + transform_6[3:6, 3:6] = transform_3 + result_list += transform_6 @ temp + + return result_list + + def get_linear_loads(self) -> None: + position = [] + indices = [] + sin_quad_lin = [] + coords_for_shader = [] + color = [] + info = [] + maxforce = 0 + + list_of_curve_members = self.curve_members + for value in list_of_curve_members.values(): + member = value["member"] + activity_list = value["activities"] + if len(activity_list) == 0: + continue + + blender_object = IfcStore.get_element(getattr(member, "GlobalId", None)) + + start_co = blender_object.matrix_world @ blender_object.data.vertices[0].co + end_co = blender_object.matrix_world @ blender_object.data.vertices[1].co + x_axis = Vector(end_co - start_co).normalized() + z_direction = getattr(member, "Axis") + # local coordinates + z_axis = Vector(getattr(z_direction, "DirectionRatios", None)).normalized() + y_axis = z_axis.cross(x_axis).normalized() + z_axis = x_axis.cross(y_axis).normalized() + rotation = self.get_curve_member_rotation(member) + + props = bpy.context.scene.BIMStructuralProperties + reference_frame = props.reference_frame + is_local = reference_frame == "LOCAL_COORDS" + x_match = abs(Vector((1, 0, 0)).dot(x_axis)) > 0.99 + y_match = abs(Vector((0, 1, 0)).dot(x_axis)) > 0.99 + z_match = abs(Vector((0, 0, 1)).dot(x_axis)) > 0.99 + direction_dict = { + "fx": y_axis + z_axis if is_local else Vector((1, 0, 0)) if not x_match else Vector((0, 1, 1)), + "fy": y_axis if is_local else Vector((0, 1, 0)) if not y_match else Vector((1, 0, 1)), + "fz": z_axis if is_local else Vector((0, 0, 1)) if not z_match else Vector((1, 1, 0)), + "mx": z_axis - y_axis if is_local or x_match else Vector((1, 0, 0)).cross(x_axis), + "my": ( + z_axis + if is_local + else Vector((-1, 0, 1)) if y_match else Vector((0, 1, 0)).cross(x_axis).normalized() + ), + "mz": ( + y_axis + if is_local + else Vector((-1, 1, 0)) if z_match else Vector((0, 0, 1)).cross(x_axis).normalized() + ), + } + match_dict = {"fx": x_match or is_local, "fy": y_match, "fz": z_match} + member_length = Vector(end_co - start_co).length + processed_loads = self.process_total_linear_loads(activity_list, rotation, member_length) + linear_loads = processed_loads["linear loads"] + maxforce = max(maxforce, processed_loads["max linear load"]) + point_loads = processed_loads["discrete loads"] + + if len(point_loads) > 0: + for item in point_loads: + for sub_item in item: + pos = sub_item["pos"] + values = sub_item["values"] + pos_vector = start_co + x_axis * pos + self.get_point_shader_args(values, pos_vector, rotation) + if linear_loads is None: + continue + keys = ["fx", "fy", "fz", "mx", "my", "mz"] + + for key in keys: + polyline = linear_loads[key]["polyline"] + sinus = linear_loads[key]["sinus"] + quadratic = linear_loads[key]["quadratic"] + constant = linear_loads[key]["constant"] + direction = direction_dict[key] + color_axis = (0, 0, 1, 1) + if "x" in key: + color_axis = (1, 0, 0, 1) + if "y" in key: + color_axis = (0, 1, 0, 1) + + if "f" in key: + unit = self.linear_force_unit + if match_dict[key]: + shader = self.shader.new("PARALLEL DISTRIBUTED FORCE") + else: + shader = self.shader.new("PERPENDICULAR DISTRIBUTED FORCE") + else: + unit = self.linear_moment_unit + shader = self.shader.new("DISTRIBUTED MOMENT") + + counter = 0 + for i in range(len(polyline) - 1): + current = Vector(polyline[i] + [0]) + nextitem = Vector(polyline[i + 1] + [0]) + + if any([current.y, nextitem.y, constant, quadratic, sinus]): + negative = -1 * direction + start_co + x_axis * current.x + positive = direction + start_co + x_axis * current.x + position.append(negative) + coords_for_shader.append((current.x, 1.0, member_length)) + sin_quad_lin.append((sinus, quadratic, current.y + constant)) + color.append(color_axis) + + x = current.x / member_length + func = sin(x * 3.1416) * sinus + (-4.0 * x * x + 4.0 * x) * quadratic + constant + current.y + if func: + self.text_info.append( + { + "position": -1 * direction * func / maxforce + start_co + x_axis * current.x, + "text": f"{func:.2f} {unit}", + } + ) + + position.append(positive) + coords_for_shader.append((current[0], -1.0, member_length)) + sin_quad_lin.append((sinus, quadratic, current.y + constant)) + color.append(color_axis) + + indices.append((0 + counter, 1 + counter, 2 + counter)) + indices.append((3 + counter, 2 + counter, 1 + counter)) + if i == len(polyline) - 2: + negative = -1 * direction + start_co + x_axis * nextitem.x + positive = direction + start_co + x_axis * nextitem.x + position.append(negative) + coords_for_shader.append((nextitem.x, 1.0, member_length)) + sin_quad_lin.append((sinus, quadratic, nextitem.y + constant)) + color.append(color_axis) + + x = nextitem.x / member_length + func = ( + sin(x * 3.1416) * sinus + (-4.0 * x * x + 4.0 * x) * quadratic + constant + nextitem.y + ) + if func: + self.text_info.append( + { + "position": -1 * direction * func / maxforce + start_co + x_axis * nextitem.x, + "text": f"{func:.2f} {unit}", + } + ) + + position.append(positive) + coords_for_shader.append((nextitem.x, -1.0, member_length)) + sin_quad_lin.append((sinus, quadratic, nextitem.y + constant)) + color.append(color_axis) + + counter += 2 + if position: + self.info.append( + { + "shader": shader, + "args": { + "position": position, + "sin_quad_lin_forces": sin_quad_lin, + "coord": coords_for_shader, + }, + "indices": indices, + "uniforms": [["color", color_axis], ["spacing", 0.2], ["maxload", maxforce]], + } + ) + position = [] + sin_quad_lin = [] + coords_for_shader = [] + indices = [] + for info in self.info: + info["uniforms"][2][1] = maxforce + + def process_total_linear_loads( + self, + activity_list: list[tuple[ifcopenshell.entity_instance, float]], + element_rotation_matrix: np.ndarray, + member_length: float, + ) -> ProcessedLoad: + """returns a dict with total values for applied loads in each direction + along with the maximum value for the loads in the member and the discrete loads applied + + """ + loads_dict = self.parse_linear_loads_to_dict(activity_list, element_rotation_matrix) + const = loads_dict["constant force"] + quad = loads_dict["quadratic force"] + sinus = loads_dict["sinus force"] + loads = loads_dict["linear load configuration"] + unique_list = self.getuniquepositionlist(loads) + final_list = [] + distributed_loads = None + max_load = 0 + for pos in unique_list: + value = self.get_before_and_after(pos, loads) + if value["before"] == value["after"]: + final_list.append([pos] + value["before"]) + else: + final_list.append([pos] + value["before"]) + final_list.append([pos] + value["after"]) + + if len(final_list) == 0 and any(const + quad + sinus): + final_list.append([0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]) + final_list.append([member_length, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]) + + elif len(final_list) > 0: + if final_list[0][0] and any( + const + quad + sinus + ): # if first item location is not 0 append an item at the zero + final_list = [[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]] + final_list + else: + del final_list[0] + if abs(final_list[-1][0] - member_length) > 0.01 and any(const + quad + sinus): + final_list.append([member_length, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]) + else: + del final_list[-1] + if len(final_list) > 0: + array = np.array(final_list) # 7xn -> ["pos","fx","fy","fz","mx","my","mz"] + keys = ["fx", "fy", "fz", "mx", "my", "mz"] + polyline = [] + distributed_loads = { + "fx": {"constant": 0, "quadratic": 0, "sinus": 0, "polyline": []}, + "fy": {"constant": 0, "quadratic": 0, "sinus": 0, "polyline": []}, + "fz": {"constant": 0, "quadratic": 0, "sinus": 0, "polyline": []}, + "mx": {"constant": 0, "quadratic": 0, "sinus": 0, "polyline": []}, + "my": {"constant": 0, "quadratic": 0, "sinus": 0, "polyline": []}, + "mz": {"constant": 0, "quadratic": 0, "sinus": 0, "polyline": []}, + } + max_load = 0 + for component, key in enumerate(keys): + if any([sinus[component], quad[component], const[component]]) or any( + item for item in array[:, component + 1] + ): + + for currentitem in final_list: + polyline.append([currentitem[0], currentitem[component + 1]]) + max_load = max( + max_load, + abs(sinus[component] + quad[component] + const[component] + currentitem[component + 1]), + ) + inner_dict = distributed_loads[key] + inner_dict["constant"] = const[component] + inner_dict["quadratic"] = quad[component] + inner_dict["sinus"] = sinus[component] + inner_dict["polyline"] = polyline + distributed_loads[key] = inner_dict + + return { + "linear loads": distributed_loads, + "max linear load": max_load, + "discrete loads": loads_dict["point load configuration"], + } + + def getuniquepositionlist(self, load_config_list: list[list[LoadConfigItem]]) -> list[float]: + """return an ordereded list of unique locations based on the load configuration list + ex: load_config_list = [[{"pos":1.0,...},{"pos":3.0,...}], + [{"pos":2.0,...},{"pos":3.0,...}], + [{"pos":1.5,...},{"pos":2.5,...}]] + return = [1.0, 1.5, 2.0, 2.5, 3.0] + """ + unique = [] + for config in load_config_list: + for info in config: + if info["pos"] in unique: + continue + unique.append(info["pos"]) + unique.sort() + return unique + + def interp1d(self, l1: list[float], l2: list[float], pos: float) -> float: + """1d linear interpolation for the vector components""" + fac = (l2[1] - l1[1]) / (l2[0] - l1[0]) + v = l1[1] + fac * (pos - l1[0]) + return v + + def interpolate(self, pos: float, loadinfo: list[LoadConfigItem], start: int, end: int, key: str) -> np.ndarray: + """interpolate the result vectors between load poits""" + result = np.zeros(6) + for i in range(6): + value1 = [loadinfo[start]["pos"], loadinfo[start][key][i]] # [position, force_component] + value2 = [loadinfo[end]["pos"], loadinfo[end][key][i]] # [position, force_component] + result[i] = self.interp1d(value1, value2, pos) # interpolated [position, force_component] + return result + + def get_before_and_after(self, pos: float, load_config_list: list[list[LoadConfigItem]]) -> dict[str, list[float]]: + """get total values for forces and moments before and after the position + example: + pos = 2.0 + load_config_list = [[{"pos":1.0, "descr":"start, "load values":[1,0,0,0,0,0]}, + {"pos":3.0, "descr":"end, "load values":[3,0,0,0,0,0]}], + [{"pos":2.0, "descr":"start, "load values":[1,0,0,0,0,0]}, + {"pos":3.0, "descr":"end, "load values":[1,0,0,0,0,0]}], + [{"pos":1.5, "descr":"start, "load values":[1,0,0,0,0,0]}, + {"pos":2.5, "descr":"end, "load values":[1,0,0,0,0,0]}], + return = { + "before": [3,0,0,0,0,0], ->(fx, fy, fz, mx, my, mz) + " after": [4,0,0,0,0,0] ->(fx, fy, fz, mx, my, mz) + } + """ + load_before = np.zeros(6) + load_after = np.zeros(6) + + for config in load_config_list: + if pos < config[0]["pos"] or pos > config[-1]["pos"]: + continue + start = 0 + end = len(config) - 1 + while end - start > 0: + if pos < config[start]["pos"] or pos > config[end]["pos"]: + break + if config[start]["pos"] == pos: + if config[start]["descr"] in ["start", "middle"]: + load_after += config[start]["load values"] + + elif config[start]["descr"] in ["end", "middle"]: + load_before += config[start]["load values"] + + elif config[end]["pos"] == pos: + if config[end]["descr"] in ["start", "middle"]: + load_after += config[end]["load values"] + + elif config[end]["descr"] in ["end", "middle"]: + load_before += config[end]["load values"] + + elif end - start == 1: + load_before += self.interpolate(pos, config, start, end, "load values") + load_after += self.interpolate(pos, config, start, end, "load values") + start += 1 + end -= 1 + return_value = {"before": load_before.tolist(), "after": load_after.tolist()} + return return_value + + def parse_linear_loads_to_dict( + self, activity_list: list[tuple[ifcopenshell.entity_instance, float]], element_rotation_matrix: np.ndarray + ) -> ParsedLoad: + """ + get load list + activity_list: list of IfcStructuralCurveAction or IfcStructuralCurveReaction + applied in the structural curve member + global_to_local: transformation matrix from global coordinates to local coordinetes + return: dict{ + "constant force": (fx,fy,fz,mx,my,mz), -> sum of linear loads applied with + constant distribution + "quadratic force": (fx,fy,fz,mx,my,mz), -> sum of linear loads applied with + quadratic distribution + "sinus force": (fx,fy,fz,mx,my,mz), -> sum of linear loads applied with + sinus distribution + "linear load configuration": list -> list of load configurations for linear + and polyline distributions of linear loads + } + description of "linear load configuration": + list[ -> one item (list)for each IfcStructuralCurveAction applied in the member + with IfcStructuralLoadConfiguration as the applied load + list[ -> one item (dict) for each item found in the + Locations attribute of IfcLoadConfiguration + dict{ + "pos": float, -> local position along curve length + "descr": string, -> describe if the item is at the start, middle or end of the list + "load values": Array, -> linear force applied at that point + } + ] + ] + """ + constant = np.zeros(6) + quadratic = np.zeros(6) + sinus = np.zeros(6) + linear_load_configurations = [] + point_load_configurations = [] + + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get(), "LENGTHUNIT") + + def get_load_values(load, transform_matrix, factor=1.0): + result = np.zeros(6) + keys = ["LinearForceX", "LinearForceY", "LinearForceZ", "LinearMomentX", "LinearMomentY", "LinearMomentZ"] + for i, key in enumerate(keys): + value = 0 if getattr(load, key, 0) is None else getattr(load, key, 0) + result[i] += value * factor + return transform_matrix @ result + + for item in activity_list: + activity = item[0] + factor = item[1] + load = activity.AppliedLoad + + transform_3by3 = self.get_activity_transform_matrix(activity, element_rotation_matrix) + transform_6by6 = np.zeros((6, 6)) + transform_6by6[0:3, 0:3] = transform_3by3 + transform_6by6[3:6, 3:6] = transform_3by3 + + # values for linear loads + if load.is_a("IfcStructuralLoadConfiguration"): + locations = getattr(load, "Locations", []) + values = [l for l in getattr(load, "Values", None) if l.is_a() == "IfcStructuralLoadLinearForce"] + config_list = [] + for i, l in enumerate(values): + load_values = get_load_values(l, transform_6by6, factor) + if i == 0: + descr = "start" + elif i == len(values) - 1: + descr = "end" + else: + descr = "middle" + config_list.append( + {"pos": locations[i][0] * unit_scale, "descr": descr, "load values": load_values} + ) + linear_load_configurations.append(config_list) + + # load configurations with point loads + values = [l for l in getattr(load, "Values", None) if l.is_a() == "IfcStructuralLoadSingleForce"] + attr_list = ["ForceX", "ForceY", "ForceZ", "MomentX", "MomentY", "MomentZ"] + config_list = [] + for i, val in enumerate(values): + result_list = [0, 0, 0, 0, 0, 0] + for j, attr in enumerate(attr_list): + value = 0 if getattr(val, attr, 0) is None else getattr(val, attr, 0) + result_list[j] += value * factor + config_list.append({"pos": locations[i][0] * unit_scale, "values": result_list}) + point_load_configurations.append(config_list) + + else: + load_values = get_load_values(load, transform_6by6, factor) + if "CONST" == getattr(activity, "PredefinedType", None) or activity.is_a("IfcStructuralLinearAction"): + constant += load_values + elif "PARABOLA" == getattr(activity, "PredefinedType", None): + quadratic += load_values + elif "SINUS" == getattr(activity, "PredefinedType", None): + sinus += load_values + return_value = { + "constant force": constant.tolist(), + "quadratic force": quadratic.tolist(), + "sinus force": sinus.tolist(), + "linear load configuration": linear_load_configurations, + "point load configuration": point_load_configurations, + } + return return_value diff --git a/src/bonsai/bonsai/bim/module/structural/operator.py b/src/bonsai/bonsai/bim/module/structural/operator.py index cf7a271797..69ce579a2b 100644 --- a/src/bonsai/bonsai/bim/module/structural/operator.py +++ b/src/bonsai/bonsai/bim/module/structural/operator.py @@ -27,6 +27,45 @@ import bonsai.tool as tool from math import degrees from mathutils import Vector, Matrix from bonsai.bim.ifc import IfcStore +from bonsai.bim.module.structural.decorator import LoadsDecorator + + +class ShowLoads(bpy.types.Operator): + """Draw decorations to show strucutural actions in 3d view""" + + bl_idname = "bim.show_loads" + bl_label = "Show loads in 3D View" + + def modal(self, context, event): + if event.type == "F5": + LoadsDecorator.update() + for area in context.screen.areas: + if area.type == "VIEW_3D": + area.tag_redraw() + if event.type == "ESC": + LoadsDecorator.uninstall() + for area in context.screen.areas: + if area.type == "VIEW_3D": + area.tag_redraw() + return {"FINISHED"} + return {"PASS_THROUGH"} + + def invoke(self, context, event): + collection = bpy.data.collections["IfcStructuralItem"] + collection.hide_viewport = False + context.window.cursor_modal_set("WAIT") + try: + LoadsDecorator.install(context) + except Exception as exc: + context.window.cursor_modal_restore() + raise exc + context.window.cursor_modal_restore() + context.window_manager.modal_handler_add(self) + for area in context.screen.areas: + if area.type == "VIEW_3D": + area.tag_redraw() + + return {"RUNNING_MODAL"} class AddStructuralMemberConnection(bpy.types.Operator, tool.Ifc.Operator): diff --git a/src/bonsai/bonsai/bim/module/structural/prop.py b/src/bonsai/bonsai/bim/module/structural/prop.py index 484bb2bb67..64d8c1bed0 100644 --- a/src/bonsai/bonsai/bim/module/structural/prop.py +++ b/src/bonsai/bonsai/bim/module/structural/prop.py @@ -21,7 +21,12 @@ import bpy import bonsai.tool as tool from bonsai.bim.ifc import IfcStore from bonsai.bim.prop import StrProperty, Attribute -from bonsai.bim.module.structural.data import StructuralLoadCasesData, StructuralLoadsData, BoundaryConditionsData +from bonsai.bim.module.structural.data import ( + StructuralLoadCasesData, + StructuralLoadsData, + BoundaryConditionsData, + LoadGroupDecorationData, +) from bpy.types import PropertyGroup from bpy.props import ( PointerProperty, @@ -35,6 +40,16 @@ from bpy.props import ( ) +def get_load_groups_to_show(self, context): + if not LoadGroupDecorationData.is_loaded: + LoadGroupDecorationData.load() + return LoadGroupDecorationData.data["load groups to show"] + + +def update_activity_type(self, context): + LoadGroupDecorationData.is_loaded = False + + def get_applicable_structural_load_types(self, context): if not StructuralLoadCasesData.is_loaded: StructuralLoadCasesData.load() @@ -152,6 +167,27 @@ class BIMStructuralProperties(PropertyGroup): boundary_condition_attributes: CollectionProperty(name="Boundary Condition Attributes", type=Attribute) filtered_boundary_conditions: BoolProperty(name="Filtered Boundary Conditions", default=False) + show_loads: BoolProperty(name="Show Loads", default=False) + update_load_repr: BoolProperty(name="Update Load Representation", default=False) + enable_repr_auto_update: BoolProperty(name="Auto Update", default=False) + reference_frame: EnumProperty( + items=[ + ("GLOBAL_COORDS", "Global", "Show loads in global reference frame"), + ("LOCAL_COORDS", "Local", "Show loads in local reference frame"), + ], + name="Reference Frame", + ) + activity_type: EnumProperty( + items=[ + ("Action", "Actions", "Show actions loads"), + ("External Reaction", "External Reactions", "Show reactions on boundary conditions"), + ("Internal Reactions", "Internal Reactions", "Show internal reactions on members"), + ], + name="Activity Type", + update=update_activity_type, + ) + load_group_to_show: EnumProperty(items=get_load_groups_to_show, name="Load Groups") + class BIMObjectStructuralProperties(PropertyGroup): boundary_condition_attributes: CollectionProperty(name="Boundary Condition Attributes", type=Attribute) diff --git a/src/bonsai/bonsai/bim/module/structural/shader.py b/src/bonsai/bonsai/bim/module/structural/shader.py new file mode 100644 index 0000000000..389ed34839 --- /dev/null +++ b/src/bonsai/bonsai/bim/module/structural/shader.py @@ -0,0 +1,294 @@ +# Bonsai - OpenBIM Blender Add-on +# Copyright (C) 2020, 2021 Dion Moult +# +# This file is part of Bonsai. +# +# Bonsai is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# Bonsai is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with Bonsai. If not, see . + +import gpu +from typing import Literal + + +class DecorationShader: + "shader for the load decorations" + + def __init__(self): + pass + + def new( + self, + pattern: Literal[ + "PERPENDICULAR DISTRIBUTED FORCE", + "PARALLEL DISTRIBUTED FORCE", + "DISTRIBUTED MOMENT", + "SINGLE FORCE", + "SINGLE MOMENT", + "PLANAR LOAD", + ], + ) -> gpu.types.GPUShader: + """pattern: string description of the desired shader + Possible values + PERPENDICULAR DISTRIBUTED FORCE: pattern for distributed force + perpendicular to the curve member axis + PARALLEL DISTRIBUTED FORCE: pattern for distributed force + along the curve member axis + DISTRIBUTED MOMENT: pattern for distributed moment in curve members + SINGLE FORCE: pattern for single forces + SINGLE MOMENT: pattern for single moments + PLANAR LOAD: pattern for planar loads + """ + valid_patterns = { + "PERPENDICULAR DISTRIBUTED FORCE", + "PARALLEL DISTRIBUTED FORCE", + "DISTRIBUTED MOMENT", + "SINGLE FORCE", + "SINGLE MOMENT", + "PLANAR LOAD", + } + if pattern not in valid_patterns: + raise ValueError( + """pattern must be one of: + PERPENDICULAR DISTRIBUTED FORCE + PARALLEL DISTRIBUTED FORCE, + DISTRIBUTED MOMENT, + SINGLE FORCE, + SINGLE MOMENT, + PLANAR LOAD""" + ) + if "DISTRIBUTED" in pattern.upper(): + shader = self.get_linear_shader(pattern) + return shader + elif "SINGLE" in pattern.upper(): + shader = self.get_point_shader(pattern) + return shader + elif "PLANAR" in pattern.upper(): + shader = self.get_planar_shader() + return shader + + def get_linear_shader( + self, pattern: Literal["PERPENDICULAR DISTRIBUTED FORCE", "PARALLEL DISTRIBUTED FORCE", "DISTRIBUTED MOMENT"] + ) -> gpu.types.GPUShader: + """pattern: type of pattern + PERPENDICULAR DISTRIBUTED FORCE + PARALLEL DISTRIBUTED FORCE, + DISTRIBUTED MOMENT, + """ + vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") + vert_out.smooth("VEC3", "forces") + vert_out.smooth("VEC3", "co") + + shader_info = gpu.types.GPUShaderCreateInfo() + shader_info.push_constant("MAT4", "viewProjectionMatrix") + shader_info.push_constant("VEC4", "color") + shader_info.push_constant("FLOAT", "spacing") + shader_info.push_constant("FLOAT", "maxload") + + shader_info.vertex_in(0, "VEC3", "position") + shader_info.vertex_in(1, "VEC3", "sin_quad_lin_forces") + shader_info.vertex_in(2, "VEC3", "coord") + + shader_info.vertex_out(vert_out) + shader_info.fragment_out(0, "VEC4", "FragColor") + + shader_info.vertex_source( + "void main()" + "{" + " gl_Position = viewProjectionMatrix * vec4(position, 1.0f);" + " co = coord;" + " forces = sin_quad_lin_forces;" + "}" + ) + + if pattern == "PERPENDICULAR DISTRIBUTED FORCE": + shader_info.fragment_source( + "void main()" + "{" + "float x = co.x;" + "float y = co.y;" + "float abs_y = abs(y);" + "float a = abs(mod(x,spacing)-0.5*spacing)*5.0;" + "float b = step(a,abs_y)*(step(abs_y,1.2*spacing));" + "float c = step(0.8*spacing,mod(x+0.4*spacing,spacing))*(step(1.2*spacing,abs_y));" + "float sinvalue = forces.x;" + "float quadraticvalue = forces.y;" + "float linearvalue = forces.z;" + "x = co.x/co.z;" + "float f = (sin(x*3.1416)*sinvalue" + "+(-4.*x*x+4.*x)*quadraticvalue" + "+linearvalue)/maxload;" + "float mask = step(0.,y)*step(y,f)+step(y,0.)*step(f,y);" + "float top = step(abs(y-f),0.2*1.2*spacing);" + "float d = clamp(top+b+c,0.0,0.9)*mask;" + "if (d == 0.0) discard;" + "FragColor = vec4(color.xyz,d*color.w);" + "}" + ) + + elif pattern == "PARALLEL DISTRIBUTED FORCE": + shader_info.fragment_source( + "void main()" + "{" + "float y = co.y;" + "float x = step(0.,y)*(co.z-co.x)+step(y,0.)*(co.x);" + "float abs_y = abs(y);" + "float a = abs(mod(abs_y,spacing)-0.5*spacing)*5.0;" + "float a2 = mod(x,3.0*spacing);" + "float b = step(a,a2)*step(a2,1.2*spacing);" + "float c = step(0.8*spacing,mod(abs_y+0.4*spacing,spacing))" + "*(step(1.2*spacing,a2))*step(a2,2.5*spacing);" + "float sinvalue = forces.x;" + "float quadraticvalue = forces.y;" + "float linearvalue = forces.z;" + "x = co.x/co.z;" + "float f = (sin(x*3.1416)*sinvalue" + "+(-4.*x*x+4.*x)*quadraticvalue" + "+linearvalue)/maxload;" + "float mask = step(0.,y)*step(y,f)+step(y,0.)*step(f,y);" + "float top = step(abs(y-f),0.2*1.2*spacing);" + "float d = clamp(top+b+c,0.0,0.9)*mask;" + "if (d == 0.0) discard;" + "FragColor = vec4(color.xyz,d*color.w);" + "}" + ) + + elif pattern == "DISTRIBUTED MOMENT": + shader_info.fragment_source( + "void main()" + "{" + "float x = step(co.y,0.)*(co.x)+step(0.,co.y)*(co.z-co.x);" + "float y = step(co.y,-0.00001)*(co.y)+step(0.,co.y)*(0.-co.y);" + "x = mod((0.5/spacing)*x,1.4)-0.7;" + "y = mod((0.5/spacing)*y,1.4)-0.7;" + "float abs_y = abs(y);" + "vec2 st = vec2(1.9*x,y);" + "vec2 orig = vec2(0.,0.);" + "float circ = step(distance(st,orig),0.33)*step(0.27,distance(st,orig));" + "float tri_mask = step(st.y,st.x)+step(-st.x,st.y);" + "float circ_arrow = step(st.x,4.0*st.y-0.75)*step(0.25*st.y-0.34,st.x)*(1.-tri_mask);" + "float circmask = step(distance(st,orig),0.1)+step(0.5,distance(st,orig))+step(st.x,0.);" + "float body = step(-0.03,st.y)*step(st.y,0.03)*step(-0.3,x)*step(x,0.576);" + "float body_arrow = step(-0.5+3.*st.y,x)*step(-0.5-3.*st.y,x)*step(x,-0.3);" + "float d = clamp(circmask*(body+body_arrow)+circ_arrow+circ*tri_mask,0.,1.);" + "float sinvalue = forces.x;" + "float quadraticvalue = forces.y;" + "float linearvalue = forces.z;" + "x = co.x/co.z;" + "float f = (sin(x*3.1416)*sinvalue" + "+(-4.*x*x+4.*x)*quadraticvalue" + "+linearvalue)/maxload;" + "float mask = step(0.,co.y)*step(co.y,f)+step(co.y,0.)*step(f,co.y);" + "float top = step(abs(co.y-f),0.2*1.2*spacing);" + "d = clamp(top+d,0.0,0.9)*mask;" + "if (d == 0.0) discard;" + "FragColor = vec4(color.xyz,d*color.w);" + "}" + ) + + shader = gpu.shader.create_from_info(shader_info) + del vert_out + del shader_info + return shader + + def get_point_shader(self, pattern: Literal["SINGLE FORCE", "SINGLE MOMENT"]) -> gpu.types.GPUShader: + """param: pattern: type of pattern + SINGLE FORCE, + SINGLE MOMENT""" + vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") + vert_out.smooth("VEC3", "co") + + shader_info = gpu.types.GPUShaderCreateInfo() + shader_info.push_constant("MAT4", "viewProjectionMatrix") + shader_info.push_constant("VEC4", "color") + shader_info.push_constant("FLOAT", "spacing") + + shader_info.vertex_in(0, "VEC3", "position") + shader_info.vertex_in(1, "VEC3", "coord") + + shader_info.vertex_out(vert_out) + shader_info.fragment_out(0, "VEC4", "FragColor") + + shader_info.vertex_source( + "void main()" "{" " gl_Position = viewProjectionMatrix * vec4(position, 1.0f);" " co = coord;" "}" + ) + + if pattern == "SINGLE FORCE": + shader_info.fragment_source( + "void main()" + "{" + "float body = step(abs(co.x),0.2*spacing)*step(2.*spacing,co.y);" + "float arrow = step(3.5*abs(co.x)+0.02,abs(co.y))*step(co.y,2.*spacing)*step(0.,co.y);" + "float d = clamp(body+arrow,0.0,0.5);" + "if (d == 0.0) discard;" + "FragColor = vec4(color.xyz,d*color.w);" + "}" + ) + + elif pattern == "SINGLE MOMENT": + shader_info.fragment_source( + "void main()" + "{" + "float circ = step(distance(co.xy,vec2(0.,0.)),0.33)*step(0.27,distance(co.xy,vec2(0.,0.)));" + "float mask = step(co.y,co.x)+step(-co.x,co.y);" + "float circ_arrow = step(co.x,4.0*co.y-0.75)*step(0.25*co.y-0.34,co.x)*(1.-mask);" + "float d = clamp(circ_arrow+circ*mask,0.0,0.5);" + "if (d == 0.0) discard;" + "FragColor = vec4(color.xyz,d*color.w);" + "}" + ) + + shader = gpu.shader.create_from_info(shader_info) + del vert_out + del shader_info + return shader + + def get_planar_shader(self) -> gpu.types.GPUShader: + """shader for planar loads""" + vert_out = gpu.types.GPUStageInterfaceInfo("my_interface") + vert_out.smooth("VEC3", "co") + + shader_info = gpu.types.GPUShaderCreateInfo() + shader_info.push_constant("MAT4", "viewProjectionMatrix") + shader_info.push_constant("VEC4", "color") + shader_info.push_constant("FLOAT", "spacing") + + shader_info.vertex_in(0, "VEC3", "position") + shader_info.vertex_in(1, "VEC3", "coord") + + shader_info.vertex_out(vert_out) + shader_info.fragment_out(0, "VEC4", "FragColor") + + shader_info.vertex_source( + "void main()" "{" " gl_Position = viewProjectionMatrix * vec4(position, 1.0f);" " co = coord;" "}" + ) + + shader_info.fragment_source( + "void main()" + "{" + "float x = co.x;" + "float y = co.y;" + "float abs_y = abs(y);" + "float a = abs(mod(x,spacing)-0.5*spacing)*5.0;" + "float b = step(a,abs_y)*(step(abs_y,1.2*spacing));" + "float c = step(0.8*spacing,mod(x+0.4*spacing,spacing))*(step(1.2*spacing,abs_y));" + "float mask = step(0.,y)*step(y,0.98)+step(y,0.)*step(0.98,y);" + "float top = step(abs(y-0.98),0.2*1.2*spacing);" + "float d = clamp(0.2*y+(top+b+c)*mask,0.0,0.4);" + "FragColor = vec4(color.xyz,d*color.w);" + "}" + ) + + shader = gpu.shader.create_from_info(shader_info) + del vert_out + del shader_info + return shader diff --git a/src/bonsai/bonsai/bim/module/structural/ui.py b/src/bonsai/bonsai/bim/module/structural/ui.py index 0dbcf84d62..fdea8b074c 100644 --- a/src/bonsai/bonsai/bim/module/structural/ui.py +++ b/src/bonsai/bonsai/bim/module/structural/ui.py @@ -445,6 +445,37 @@ class BIM_UL_structural_activities(UIList): row.label(text=item.applied_load_class) +class BIM_PT_show_structural_activities(Panel): + bl_label = "Show Loads" + bl_idname = "BIM_PT_show_structural_activities" + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + bl_context = "scene" + bl_parent_id = "BIM_PT_tab_structural" + + @classmethod + def poll(cls, context): + file = IfcStore.get_file() + return file and hasattr(file, "schema") and file.schema != "IFC2X3" + + def draw(self, context): + + self.props = context.scene.BIMStructuralProperties + + row = self.layout.row(align=True) + row.operator( + "bim.show_loads", + text="Show loads", + icon="HIDE_OFF", + ) + row = self.layout.row(align=True) + row.prop(self.props, "reference_frame") + row = self.layout.row(align=True) + row.prop(self.props, "activity_type") + row = self.layout.row(align=True) + row.prop(self.props, "load_group_to_show") + + class BIM_PT_structural_loads(Panel): bl_label = "Structural Loads" bl_idname = "BIM_PT_structural_loads" diff --git a/src/bonsai/bonsai/bim/module/style/operator.py b/src/bonsai/bonsai/bim/module/style/operator.py index b1b63ebca2..ba4b306af4 100644 --- a/src/bonsai/bonsai/bim/module/style/operator.py +++ b/src/bonsai/bonsai/bim/module/style/operator.py @@ -80,7 +80,6 @@ class RemoveStyle(bpy.types.Operator, tool.Ifc.Operator): style: bpy.props.IntProperty() def _execute(self, context): - core.disable_editing_style(tool.Style) # So we don't end up soft-locking style edition core.remove_style(tool.Ifc, tool.Style, style=tool.Ifc.get().by_id(self.style), reload_styles_ui=True) diff --git a/src/bonsai/bonsai/bim/module/type/operator.py b/src/bonsai/bonsai/bim/module/type/operator.py index cb5c1b6361..5b67d79957 100644 --- a/src/bonsai/bonsai/bim/module/type/operator.py +++ b/src/bonsai/bonsai/bim/module/type/operator.py @@ -40,7 +40,9 @@ class AssignType(bpy.types.Operator, tool.Ifc.Operator): related_object: bpy.props.StringProperty() def _execute(self, context): - type = tool.Ifc.get().by_id(self.relating_type or int(context.active_object.BIMTypeProperties.relating_type)) + relating_type = tool.Ifc.get().by_id( + self.relating_type or int(context.active_object.BIMTypeProperties.relating_type) + ) related_objects = ( [bpy.data.objects.get(self.related_object)] if self.related_object @@ -49,9 +51,9 @@ class AssignType(bpy.types.Operator, tool.Ifc.Operator): model_props = context.scene.BIMModelProperties for obj in related_objects: element = tool.Ifc.get_entity(obj) - core.assign_type(tool.Ifc, tool.Type, element=element, type=type) + core.assign_type(tool.Ifc, tool.Type, element=element, type=relating_type) if model_props.occurrence_name_style == "TYPE": - obj.name = tool.Model.generate_occurrence_name(type, element.is_a()) + obj.name = tool.Model.generate_occurrence_name(relating_type, element.is_a()) class UnassignType(bpy.types.Operator, tool.Ifc.Operator): diff --git a/src/bonsai/bonsai/bim/ui.py b/src/bonsai/bonsai/bim/ui.py index 69731dfdb9..42742fb1de 100644 --- a/src/bonsai/bonsai/bim/ui.py +++ b/src/bonsai/bonsai/bim/ui.py @@ -237,7 +237,7 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): activate_workspace: BoolProperty( name="Activate BIM Workspace on Startup", default=True, - description="If enabled, this will automatically activate the BIM workspace when opening a project.\It is recommended to keep this `Enabled`", + description="If enabled, this will automatically activate the BIM workspace when opening a project.\nIt is recommended to keep this `Enabled`", ) should_setup_toolbar: BoolProperty( name="Always Show Toolbar In 3D Viewport", @@ -246,9 +246,10 @@ class BIM_ADDON_preferences(bpy.types.AddonPreferences): ) should_play_chaching_sound: BoolProperty(name="Play A Cha-Ching Sound When Project Costs Updates", default=False) spatial_elements_unselectable: BoolProperty( - name="Make Spatial Elements Unselectable By Default", - default=True, - description="If disabled, it will be possible to select spatial elements in the 3D viewport.\nIt is recommended to keep this `Enabled`, as this can have unintended consequences") + name="Make Spatial Elements Unselectable By Default", + default=True, + description="If disabled, it will be possible to select spatial elements in the 3D viewport.\nIt is recommended to keep this `Enabled`, as this can have unintended consequences", + ) decorations_colour: bpy.props.FloatVectorProperty( name="Decorations Color", subtype="COLOR", default=(1, 1, 1, 1), min=0.0, max=1.0, size=4 ) @@ -1191,15 +1192,16 @@ def draw_custom_context_menu(self: bpy.types.Menu, context: bpy.types.Context) - url_op = layout.operator("bim.open_uri", icon="URL", text="Online IFC Documentation") url_op.uri = url + class BIM_PT_decorators_overlay(Panel): - bl_space_type = 'VIEW_3D' - bl_region_type = 'HEADER' - bl_parent_id = 'VIEW3D_PT_overlay' + bl_space_type = "VIEW_3D" + bl_region_type = "HEADER" + bl_parent_id = "VIEW3D_PT_overlay" bl_label = "Bonsai Decorators" @classmethod def poll(cls, context): - return context.mode == 'OBJECT' + return context.mode == "OBJECT" def draw(self, context): layout = self.layout diff --git a/src/bonsai/bonsai/core/aggregate.py b/src/bonsai/bonsai/core/aggregate.py index 30e2c14965..693063b602 100644 --- a/src/bonsai/bonsai/core/aggregate.py +++ b/src/bonsai/bonsai/core/aggregate.py @@ -86,12 +86,17 @@ def add_part_to_object( assign_object(ifc, aggregator, collector, relating_obj=obj, related_obj=part_obj) blender.set_active_object(obj) -def enable_aggregate_mode(aggregator: tool.Aggregate, obj: bpy.types.Object,): + +def enable_aggregate_mode( + aggregator: tool.Aggregate, + obj: bpy.types.Object, +): if aggregator.get_aggregate_mode(): disable_aggregate_mode(aggregator) aggregator.enable_aggregate_mode(obj) + def disable_aggregate_mode(aggregator: tool.Aggregate): aggregator.disable_aggregate_mode() diff --git a/src/bonsai/bonsai/core/drawing.py b/src/bonsai/bonsai/core/drawing.py index 0a1ec7d5eb..9dd4490e98 100644 --- a/src/bonsai/bonsai/core/drawing.py +++ b/src/bonsai/bonsai/core/drawing.py @@ -425,7 +425,7 @@ def add_annotation( ifc_representation_class=drawing_tool.get_ifc_representation_class(object_type), ) ifc.run("group.assign_group", group=drawing_tool.get_drawing_group(drawing), products=[element]) - collector.assign(obj) + collector.assign(obj, should_clean_users_collection=True) drawing_tool.enable_editing(obj) diff --git a/src/bonsai/bonsai/core/root.py b/src/bonsai/bonsai/core/root.py index f124951d53..7e0b9381ba 100644 --- a/src/bonsai/bonsai/core/root.py +++ b/src/bonsai/bonsai/core/root.py @@ -27,7 +27,7 @@ if TYPE_CHECKING: def copy_class( ifc: tool.Ifc, collector: tool.Collector, geometry: tool.Geometry, root: tool.Root, obj: bpy.types.Object -) -> ifcopenshell.entity_instance: +) -> ifcopenshell.entity_instance | None: element = ifc.get_entity(obj) if not element: return @@ -43,14 +43,12 @@ def copy_class( ifc.run("type.map_type_representations", related_object=new, relating_type=relating_type) root.link_object_data(ifc.get_object(relating_type), obj) elif representation: - root.copy_representation(element, new) - new_representation = root.get_element_representation(new, root.get_representation_context(representation)) + copied_entities = root.copy_representation(element, new) data = geometry.duplicate_object_data(obj) if data: + geometry.copy_data_links(data, copied_entities) geometry.change_object_data(obj, data, is_global=True) - geometry.rename_object(data, geometry.get_representation_name(new_representation)) - geometry.link(new_representation, data) - geometry.reload_representation_item_ids(new_representation, data) + geometry.rename_object(data, geometry.get_representation_name(ifc.get_entity(data))) root.assign_body_styles(new, obj) collector.assign(obj) if root.is_element_a(new, "IfcOpeningElement"): diff --git a/src/bonsai/bonsai/core/style.py b/src/bonsai/bonsai/core/style.py index 685cc2cf88..b555a311e2 100644 --- a/src/bonsai/bonsai/core/style.py +++ b/src/bonsai/bonsai/core/style.py @@ -65,6 +65,8 @@ def remove_style( avoid unnecessary reloads. """ obj = ifc.get_object(style) + if style_tool.is_editing_style() and obj == style_tool.get_currently_edited_material(): + style_tool.disable_editing() # Get style_type before removing object as later StylesData might fail to load # due object not yet removed completely. style_type = style_tool.get_active_style_type() diff --git a/src/bonsai/bonsai/core/tool.py b/src/bonsai/bonsai/core/tool.py index 291e0b2890..4e10708786 100644 --- a/src/bonsai/bonsai/core/tool.py +++ b/src/bonsai/bonsai/core/tool.py @@ -387,6 +387,7 @@ class Geometry: def clear_cache(cls, element): pass def clear_modifiers(cls, obj): pass def clear_scale(cls, obj): pass + def copy_data_links(cls, data, copied_entities) -> None: pass def delete_data(cls, data): pass def delete_ifc_object(cls, obj): pass def delete_opening_object_placement(cls, opening): pass @@ -420,7 +421,6 @@ class Geometry: def record_object_position(cls, obj): pass def recreate_object_with_data(cls, obj, data): pass def reimport_element_representations(cls, obj, representation, apply_openings=True): pass - def reload_representation_item_ids(cls, representation, data) -> None: pass def remove_connection(cls, connection): pass def rename_object(cls, obj, name): pass def replace_object_data_globally(cls, old_data, new_data): pass @@ -1013,6 +1013,7 @@ class Style: def get_uv_maps(cls, representation): pass def import_presentation_styles(cls, style_type): pass def import_surface_attributes(cls, style): pass + def is_editing_style(cls): pass def is_editing_styles(cls): pass def reload_material_from_ifc(cls, obj): pass def is_style_side_attribute_edited(cls, style, new_attributes): pass diff --git a/src/bonsai/bonsai/tool/blender.py b/src/bonsai/bonsai/tool/blender.py index f6cb45e08e..051dfa9dc3 100644 --- a/src/bonsai/bonsai/tool/blender.py +++ b/src/bonsai/bonsai/tool/blender.py @@ -144,7 +144,7 @@ class Blender(bonsai.core.tool.Blender): @classmethod def get_active_object(cls) -> bpy.types.Object: - return bpy.context.view_layer.objects.active + return getattr(bpy.context, "active_object", None) or bpy.context.view_layer.objects.active @classmethod def get_selected_objects(cls) -> set[bpy.types.Object]: @@ -185,10 +185,6 @@ class Blender(bonsai.core.tool.Blender): return context.scene.BIMMaterialProperties.materials[ context.scene.BIMMaterialProperties.active_material_index ].ifc_definition_id - elif obj_type == "MaterialSet": - return ifcopenshell.util.element.get_material( - tool.Ifc.get_entity(bpy.data.objects.get(obj)), should_skip_usage=True - ).id() elif obj_type == "MaterialSetItem": return bpy.data.objects.get(obj).BIMObjectMaterialProperties.active_material_set_item_id elif obj_type == "Task": diff --git a/src/bonsai/bonsai/tool/collector.py b/src/bonsai/bonsai/tool/collector.py index 2754f708ad..51e78fd108 100644 --- a/src/bonsai/bonsai/tool/collector.py +++ b/src/bonsai/bonsai/tool/collector.py @@ -31,11 +31,11 @@ class Collector(bonsai.core.tool.Collector): for users_collection in obj.users_collection: if obj.BIMObjectProperties.collection == users_collection: continue - # Users are free to user extra collections for their own + # Users are free to use extra collections for their own # purposes except for the reserved keyword "Ifc" and # "Collection" (which is the default collection that comes with - # a Blender session) - if "Ifc" in users_collection.name or users_collection.name == "Collection": + # a Blender session) and "Unsorted" (our special collection). + if "Ifc" in users_collection.name or users_collection.name in ("Collection", "Unsorted"): users_collection.objects.unlink(obj) element = tool.Ifc.get_entity(obj) @@ -43,6 +43,7 @@ class Collector(bonsai.core.tool.Collector): # Note that tool.Geometry.is_locked is only checked within the if # statements for efficiency as it is a slow check. + tool.Geometry.lock_scale(obj) if element.is_a("IfcGridAxis"): if tool.Geometry.is_locked(element): diff --git a/src/bonsai/bonsai/tool/geometry.py b/src/bonsai/bonsai/tool/geometry.py index 5a49c948cd..c0faa6942a 100644 --- a/src/bonsai/bonsai/tool/geometry.py +++ b/src/bonsai/bonsai/tool/geometry.py @@ -132,7 +132,6 @@ class Geometry(bonsai.core.tool.Geometry): obj.lock_rotation = (True, True, True) obj.lock_rotation_w = True obj.lock_rotations_4d = True - obj.lock_scale = (True, True, True) @classmethod def unlock_object(cls, obj: bpy.types.Object) -> None: @@ -140,7 +139,6 @@ class Geometry(bonsai.core.tool.Geometry): obj.lock_rotation = (False, False, False) obj.lock_rotation_w = False obj.lock_rotations_4d = False - obj.lock_scale = (False, False, False) @classmethod def lock_scale(cls, obj: bpy.types.Object) -> None: @@ -259,10 +257,14 @@ class Geometry(bonsai.core.tool.Geometry): # a cylinder) so dissolving edges should not be allowed. mesh_element = tool.Ifc.get().by_id(obj.data.BIMMeshProperties.ifc_definition_id) if ( - mesh_element.is_a("IfcShapeRepresentation") - and ifcopenshell.util.representation.resolve_representation(mesh_element).RepresentationType - == "AdvancedBrep" - ) or mesh_element.is_a("IfcAdvancedBrep") or not obj.data: + ( + mesh_element.is_a("IfcShapeRepresentation") + and ifcopenshell.util.representation.resolve_representation(mesh_element).RepresentationType + == "AdvancedBrep" + ) + or mesh_element.is_a("IfcAdvancedBrep") + or not obj.data + ): return if hasattr(obj.data, "attributes") and (ios_edges_attribute := obj.data.attributes.get("ios_edges")): # Edges from a forced triangulation are stored as True in a boolean attribute on the mesh @@ -612,7 +614,7 @@ class Geometry(bonsai.core.tool.Geometry): @classmethod def get_representation_name(cls, representation: ifcopenshell.entity_instance) -> str: - return tool.Loader.get_mesh_name(representation.ContextOfItems.id(), representation.id()) + return tool.Loader.get_mesh_name(representation) @classmethod def get_styles( @@ -719,7 +721,7 @@ class Geometry(bonsai.core.tool.Geometry): if not tool.Loader.is_native_swept_disk_solid(element, representation): continue if curve is None: - mesh_name = tool.Loader.get_mesh_name(context.id(), representation.id()) + mesh_name = tool.Loader.get_mesh_name(representation) native_data = { "representation": representation, # TODO: calculate mapped item matrix. @@ -1729,8 +1731,14 @@ class Geometry(bonsai.core.tool.Geometry): return results @classmethod - def reload_representation_item_ids(cls, representation: ifcopenshell.entity_instance, data: bpy.types.Mesh) -> None: - data["ios_item_ids"] = [i["item"].id() for i in ifcopenshell.util.representation.resolve_items(representation)] + def copy_data_links(cls, data: bpy.types.Mesh, copied_entities: dict[int, ifcopenshell.entity_instance]) -> None: + representation = tool.Ifc.get_entity(data) + representation = copied_entities.get(representation.id(), representation) + tool.Ifc.link(representation, data) + if item_ids := data.get("ios_item_ids"): + data["ios_item_ids"] = [copied_entities.get(i, tool.Ifc.get().by_id(i)).id() for i in item_ids] + if item_ids := data.get("ios_edges_item_ids"): + data["ios_edges_item_ids"] = [copied_entities.get(i, tool.Ifc.get().by_id(i)).id() for i in item_ids] @classmethod def export_mesh_to_tessellation( @@ -1795,9 +1803,7 @@ class Geometry(bonsai.core.tool.Geometry): return False @classmethod - def get_bvh_tree(cls, obj:bpy.types.Object) -> BVHTree: + def get_bvh_tree(cls, obj: bpy.types.Object) -> BVHTree: bm = tool.Blender.get_bmesh_for_mesh(obj.data) bm.transform(obj.matrix_world) return BVHTree.FromBMesh(bm) - - \ No newline at end of file diff --git a/src/bonsai/bonsai/tool/ifc.py b/src/bonsai/bonsai/tool/ifc.py index c52c86bee4..502903601d 100644 --- a/src/bonsai/bonsai/tool/ifc.py +++ b/src/bonsai/bonsai/tool/ifc.py @@ -34,7 +34,6 @@ class Ifc(bonsai.core.tool.Ifc): OBJECT_TYPE = Literal[ "Object", "Material", - "MaterialSet", "MaterialSetItem", "Task", "Cost", @@ -102,7 +101,7 @@ class Ifc(bonsai.core.tool.Ifc): Return None if object is not linked to IFC or it's linked to non-existent element. """ ifc = IfcStore.get_file() - if not ifc: + if not ifc or not obj: return props = None @@ -110,6 +109,8 @@ class Ifc(bonsai.core.tool.Ifc): props = obj.BIMObjectProperties elif isinstance(obj, bpy.types.Material): props = obj.BIMStyleProperties + else: + props = obj.BIMMeshProperties if props and (ifc_definition_id := props.ifc_definition_id): try: diff --git a/src/bonsai/bonsai/tool/loader.py b/src/bonsai/bonsai/tool/loader.py index 568bb7899b..b558d75be5 100644 --- a/src/bonsai/bonsai/tool/loader.py +++ b/src/bonsai/bonsai/tool/loader.py @@ -84,13 +84,12 @@ class Loader(bonsai.core.tool.Loader): @classmethod def get_mesh_name_from_shape(cls, geometry: ifcopenshell.geom.ShapeType) -> str: representation_id = cls.get_representation_id_from_shape(geometry) - representation = tool.Ifc.get().by_id(representation_id) - context_id = representation.ContextOfItems.id() if hasattr(representation, "ContextOfItems") else 0 - return cls.get_mesh_name(context_id, representation_id) + return cls.get_mesh_name(tool.Ifc.get().by_id(representation_id)) @classmethod - def get_mesh_name(cls, context_id: int, representation_id: int) -> str: - return "{}/{}".format(context_id, representation_id) + def get_mesh_name(cls, representation: ifcopenshell.entity_instance) -> str: + context_id = representation.ContextOfItems.id() if hasattr(representation, "ContextOfItems") else 0 + return "{}/{}".format(context_id, representation.id()) @classmethod def get_name(cls, element: ifcopenshell.entity_instance) -> str: @@ -983,8 +982,7 @@ class Loader(bonsai.core.tool.Loader): tool.Loader.load_indexed_colour_map(rep, mesh) ios_edges_values = [ - (e.vertices[0], e.vertices[1]) in ios_edges - or (e.vertices[1], e.vertices[0]) in ios_edges + (e.vertices[0], e.vertices[1]) in ios_edges or (e.vertices[1], e.vertices[0]) in ios_edges for e in mesh.edges ] tool.Blender.Attribute.fill_attribute(mesh, "ios_edges", "EDGE", "BOOLEAN", ios_edges_values) diff --git a/src/bonsai/bonsai/tool/model.py b/src/bonsai/bonsai/tool/model.py index 2eb5be5aba..da720b755e 100644 --- a/src/bonsai/bonsai/tool/model.py +++ b/src/bonsai/bonsai/tool/model.py @@ -131,6 +131,12 @@ class Model(bonsai.core.tool.Model): points.append(cls.convert_si_to_unit(list(local_point))) return tool.Ifc.get().createIfcCartesianPointList2D(points) + @classmethod + def export_annotation_fill_area(cls, obj: bpy.types.Object) -> ifcopenshell.entity_instance | None: + result = cls.auto_detect_annotation_fill_area(obj, obj.data) + if isinstance(result, dict) and result["annotation_fill_area"]: + return tool.Ifc.get().add(result["annotation_fill_area"]) + @classmethod def export_profile( cls, obj: bpy.types.Object, position: Optional[Matrix] = None @@ -268,6 +274,12 @@ class Model(bonsai.core.tool.Model): return obj + @classmethod + def import_annotation_fill_area( + cls, annotation_fill_area: ifcopenshell.entity_instance, obj: Optional[bpy.types.Object] = None + ) -> bpy.types.Object: + return cls.import_profile(annotation_fill_area, obj) + @classmethod def import_profile( cls, @@ -299,6 +311,10 @@ class Model(bonsai.core.tool.Model): cls.convert_curve_to_mesh(obj, position, inner_curve) elif profile.is_a() == "IfcRectangleProfileDef": cls.import_rectangle(obj, position, profile) + elif profile.is_a() == "IfcAnnotationFillArea": + cls.convert_curve_to_mesh(obj, position, profile.OuterBoundary) + for inner_boundary in profile.InnerBoundaries or []: + cls.convert_curve_to_mesh(obj, position, inner_boundary) mesh = bpy.data.meshes.new("Profile") mesh.from_pydata(cls.vertices, cls.edges, []) @@ -1247,8 +1263,12 @@ class Model(bonsai.core.tool.Model): if stair_type == "WOOD/STEEL": builder = ShapeBuilder(None) + # full tread rectangle - get_tread_verts = partial(builder.get_rectangle_coords, position=V_(0, -(tread_depth - tread_rise))) + def get_tread_verts(*args, **kwargs): + fn = partial(builder.get_rectangle_coords, position=V_(0, -(tread_depth - tread_rise))) + return [Vector(x) for x in fn(*args, **kwargs)] + default_tread_verts = get_tread_verts(size=V_(tread_run + nosing_overlap, tread_depth)) default_tread_offset = V_(tread_run + nosing_tread_gap, tread_rise) @@ -1575,6 +1595,20 @@ class Model(bonsai.core.tool.Model): ) tool.Model.replace_object_ifc_representation(body, obj, representation) + @classmethod + def auto_detect_annotation_fill_area(cls, obj: bpy.types.Object, mesh: bpy.types.Mesh) -> dict | None: + result = cls.auto_detect_profiles(obj, mesh) + fill_area = None + if isinstance(result, dict) and (profile_def := result["profile_def"]): + if profile_def.is_a("IfcArbitraryClosedProfileDef"): + fill_area = result["ifc_file"].createIfcAnnotationFillArea(profile_def.OuterCurve) + elif profile_def.is_a("IfcArbitraryProfileDefWithVoids"): + fill_area = result["ifc_file"].createIfcAnnotationFillArea( + profile_def.OuterCurve, profile_def.InnerCurves + ) + if fill_area: + return {"ifc_file": result["ifc_file"], "annotation_fill_area": fill_area} + @classmethod def auto_detect_profiles( cls, obj: bpy.types.Object, mesh: bpy.types.Mesh, position: Matrix | None = None @@ -1979,3 +2013,18 @@ class Model(bonsai.core.tool.Model): @classmethod def get_booleaned_obj(cls, boolean_obj: bpy.types.Object) -> bpy.types.Object: return boolean_obj.data.BIMMeshProperties.obj + + @classmethod + def bm_sort_out_geom( + cls, geom_data: list[Union[bmesh.types.BMVert, bmesh.types.BMEdge, bmesh.types.BMFace]] + ) -> dict[str, Any]: + geom_dict = {"verts": [], "edges": [], "faces": []} + + for el in geom_data: + if isinstance(el, bmesh.types.BMVert): + geom_dict["verts"].append(el) + elif isinstance(el, bmesh.types.BMFace): + geom_dict["faces"].append(el) + else: + geom_dict["edges"].append(el) + return geom_dict diff --git a/src/bonsai/bonsai/tool/patch.py b/src/bonsai/bonsai/tool/patch.py index a178f99e2b..356a5001de 100644 --- a/src/bonsai/bonsai/tool/patch.py +++ b/src/bonsai/bonsai/tool/patch.py @@ -33,10 +33,8 @@ class Patch(bonsai.core.tool.Patch): @classmethod def is_filepath_argument(cls, recipe: str, arg_name: str) -> bool: - # TODO: Temporary hack to identify filepath arguments. - # Should mark them as such in the patches documentation - # and process it later. - return recipe == "SplitByBuildingStorey" and arg_name == "output_dir" + # There is probably a more explicit way to do this + return "filepath" in arg_name @classmethod def does_patch_has_output(cls, recipe: str) -> bool: diff --git a/src/bonsai/bonsai/tool/pset.py b/src/bonsai/bonsai/tool/pset.py index 9d37115aa4..7c07010fa8 100644 --- a/src/bonsai/bonsai/tool/pset.py +++ b/src/bonsai/bonsai/tool/pset.py @@ -45,8 +45,6 @@ class Pset(bonsai.core.tool.Pset): return bpy.data.objects.get(obj).PsetProperties elif obj_type == "Material": return bpy.context.scene.MaterialPsetProperties - elif obj_type == "MaterialSet": - return bpy.data.objects.get(obj).MaterialSetPsetProperties elif obj_type == "MaterialSetItem": return bpy.data.objects.get(obj).MaterialSetItemPsetProperties elif obj_type == "Task": diff --git a/src/bonsai/bonsai/tool/raycast.py b/src/bonsai/bonsai/tool/raycast.py index 852ae2f8c1..4b2d16c4ea 100644 --- a/src/bonsai/bonsai/tool/raycast.py +++ b/src/bonsai/bonsai/tool/raycast.py @@ -206,7 +206,7 @@ class Raycast(bonsai.core.tool.Raycast): bm.free() snapping_points = [] - sorted_points = sorted(points) + sorted_points = sorted(points, key=lambda x: x[0]) for p in sorted_points: point = copy.deepcopy(p) snapping_points.append(point[1]) diff --git a/src/bonsai/bonsai/tool/root.py b/src/bonsai/bonsai/tool/root.py index a19bb227f5..b2af1b50d5 100644 --- a/src/bonsai/bonsai/tool/root.py +++ b/src/bonsai/bonsai/tool/root.py @@ -55,28 +55,38 @@ class Root(bonsai.core.tool.Root): ) @classmethod - def copy_representation(cls, source: ifcopenshell.entity_instance, dest: ifcopenshell.entity_instance) -> None: + def copy_representation( + cls, source: ifcopenshell.entity_instance, dest: ifcopenshell.entity_instance + ) -> dict[int, ifcopenshell.entity_instance]: def exclude_callback(attribute): return attribute.is_a("IfcProfileDef") and attribute.ProfileName + copied_entities: dict[int, ifcopenshell.entity_instance] = {} + if dest.is_a("IfcProduct"): if not source.Representation: - return + return copied_entities dest.Representation = ifcopenshell.util.element.copy_deep( tool.Ifc.get(), source.Representation, exclude=["IfcGeometricRepresentationContext"], exclude_callback=exclude_callback, + copied_entities=copied_entities, ) elif dest.is_a("IfcTypeProduct"): if not source.RepresentationMaps: - return + return copied_entities dest.RepresentationMaps = [ ifcopenshell.util.element.copy_deep( - tool.Ifc.get(), m, exclude=["IfcGeometricRepresentationContext"], exclude_callback=exclude_callback + tool.Ifc.get(), + m, + exclude=["IfcGeometricRepresentationContext"], + exclude_callback=exclude_callback, + copied_entities=copied_entities, ) for m in source.RepresentationMaps ] + return copied_entities @classmethod def does_type_have_representations(cls, element: ifcopenshell.entity_instance) -> bool: @@ -354,7 +364,6 @@ class Root(bonsai.core.tool.Root): tool.Aggregate.constrain_all_parts_to_aggregate(tool.Ifc.get_object(new_aggregate[0])) tool.Blender.select_and_activate_single_object(bpy.context, tool.Ifc.get_object(new_aggregate[0])) - @classmethod def run_geometry_add_representation( cls, diff --git a/src/bonsai/bonsai/tool/style.py b/src/bonsai/bonsai/tool/style.py index 359386b25a..7c6b7fd602 100644 --- a/src/bonsai/bonsai/tool/style.py +++ b/src/bonsai/bonsai/tool/style.py @@ -550,6 +550,10 @@ class Style(bonsai.core.tool.Style): def is_editing_styles(cls) -> bool: return bpy.context.scene.BIMStylesProperties.is_editing + @classmethod + def is_editing_style(cls) -> bool: + return bpy.context.scene.BIMStylesProperties.is_editing_style + @classmethod def select_elements(cls, elements: list[ifcopenshell.entity_instance]) -> None: for element in elements: diff --git a/src/bonsai/docs/guides/development/debugging.rst b/src/bonsai/docs/guides/development/debugging.rst new file mode 100644 index 0000000000..69bb2c6244 --- /dev/null +++ b/src/bonsai/docs/guides/development/debugging.rst @@ -0,0 +1,110 @@ +Debugging Bonsai +================ + +This is a mini-guide to setting up an IDE and configuring it to debug Bonsai more +easily. It is currently specific to this writers own system (Ubuntu) but this +can be expanded by others. + +1. **Install VSCodium**: This will be system specific. I used the available + snap package. + + .. code-block:: bash + + sudo snap install --classic codium + + I chose VSCodium to avoid Microsoft telemetry and data harvesting, but + VSCode is going to be similar, and even a bit easier (i.e. steps 3 & 4). + +2. **Activate Python language support**: Start VSCodium, open the Extensions, find the Python + language support and activate it. + +3. **Install Blender Addon**: It seems VSCodium is not allowed to directly access + the Marketplace, and this addon (and the next) is not available in + VSCodium's equivalent. (Or I'm an idiot... entirely possible). This means a + few more steps are needed. + + Download the Blender addon's ``.vsix`` file from marketplace (Under "Resources") + using your browser from `this `_ + page. + + Install it with "**...**" -> "**Install from VSIX...**" + +4. **Install ms-vscode.cpptools**: When trying to use the Blender addon I got + an error about a missing dependancy. I read somewhere that this addon was + not needed, but I can't currently find the reference. At least for me, it + needs to be added the same way as step 3, or the Blender addon fails to + start. + + Download the Cpp Tools addon's ``.vsix`` file from marketplace (Under "Resources") + using your browser from `this `_ + page. You will need to select the correct download for your system OS and + architecture. + + Install it with "**...**" -> "**Install from VSIX...**" + +5. **Set the Blender config directory**: In **Settings** -> **Extensions** -> **Blender** -> "**Environment Variables**" + edit the json file to set the ``BLENDER_USER_RESOURCE`` to the config folder + you installed Bonsai to. For me: + + .. code-block:: json + + { + "blender.environmentVariables": { + "BLENDER_USER_RESOURCES": "/home/steve/.config/blender/4.2bonsai" + } + } + + .. note:: + You `must` change that path to the correct value for your system. + +6. **Unset the Just My Code flag**: In **Settings** -> **Extensions** -> **Blender** -> "**Just My Code**" + by unchecking the box. + + Again, this was advice found searching around in a github issue. If not + done, the breakpoints do not work for me. + +7. **Open the folder in VSCodium**: This is the same folder set in step 5. + + ``/home/steve/.config/blender/4.2bonsai`` + + This was a key point for me. Do `not` go deeper in the folder structure - + that just doesn't work. (Thanks @theoryshaw) + +8. **Start Blender**: In VSCodium press ``Ctrl+Shift+P``, and search/select + **Blender: Start**. + +9. **Set the Blender binary path**: The first time you try to start Blender the + addon will ask for the path of the binary. Navigate to your Blender binary + and select it. For me: + + ``/home/steve/Software/blender-git/build_linux/bin/blender`` + + .. warning:: + Do `not` try to use a binary installed using snap - it will `not` work. + Either install a debian package of Blender, or build it from source, and + use that. + + All being well, Blender should start and show the usual Bonsai UI. + +10. **Ensure Blender is behaving**: Exercise the interface a bit to be sure + Bonsai is working normally. Add a demo project, add some walls, etc. + + One issue I ran in to was opening the wrong folder in step 7. Due to the + way VSCodium works, I was getting two Bonsai plugins conflicting, and + failing in interesting ways. One symptom of this was the modal user + interface when adding walls was completely missing. Oh, and the debugger + completely failed to work. + + If you are happy, quit Blender, and try setting some breakpoints in + VSCodium then run Blender again, and try to trigger them. I set one on + the ``bim.add_sheet`` operator at: + ``extensions/.local/lib/python3.11/site-packages/bonsai/bim/module/drawing/operator.py:1456`` + + .. note:: + I made the mistake of setting the breakpoint on the ``_execute`` + methods ``def`` line, which did not work. This probably helped confuse + me when trying to get the addon to work. + +If you get to this point, congratulations! You will now be 1000% more effective +when troubleshooting issues, and able to make many more contributions, fixes +and patches. \ No newline at end of file diff --git a/src/bonsai/docs/guides/development/index.rst b/src/bonsai/docs/guides/development/index.rst index d0a0e46bf5..009fc835be 100644 --- a/src/bonsai/docs/guides/development/index.rst +++ b/src/bonsai/docs/guides/development/index.rst @@ -15,3 +15,4 @@ This chapter covers how you can help contribute to Bonsai. translations undo_system writing_docs + debugging diff --git a/src/bonsai/docs/index.rst b/src/bonsai/docs/index.rst index 1775f95e35..f9a8c490ef 100644 --- a/src/bonsai/docs/index.rst +++ b/src/bonsai/docs/index.rst @@ -58,6 +58,7 @@ and data-rich OpenBIM with Blender :) guides/development/index guides/authoring/other_addons guides/troubleshooting + guides/debugging .. toctree:: :hidden: diff --git a/src/ifccsv/ifccsv.py b/src/ifccsv/ifccsv.py index 0030bc84d1..34e4b37b32 100755 --- a/src/ifccsv/ifccsv.py +++ b/src/ifccsv/ifccsv.py @@ -296,12 +296,12 @@ class IfcCsv: writer.writerow(self.headers) for row in self.results: writer.writerow(row) - if any([s for s in self.summaries if s is not None]): + if self.has_summaries(): writer.writerow(self.summaries) def export_ods(self, output, should_preserve_existing=False): df = self.export_pd() - if self.summaries: + if self.has_summaries(): df.loc[df.shape[0]] = self.summaries if os.path.exists(output) and should_preserve_existing: @@ -324,10 +324,9 @@ class IfcCsv: # If the DataFrame has fewer rows than the table, blank out the extra rows num_rows_table = len(first_table.getElementsByType(TableRow)) - 1 # Exclude header row if len(df) < num_rows_table: - for i in range(len(df) + 1, num_rows_table + 1): # +1 to account for header - for cell in first_table.getElementsByType(TableRow)[i].getElementsByType(TableCell): - for item in cell.childNodes: - cell.removeChild(item) + rows = first_table.getElementsByType(TableRow) + for i in reversed(range(len(df) + 1, num_rows_table + 1)): # +1 to account for header + first_table.removeChild(rows[i]) ods_document.save(output) else: @@ -362,9 +361,12 @@ class IfcCsv: row.addElement(new_cell) return new_cell + def has_summaries(self): + return any([s for s in self.summaries if s is not None]) + def export_xlsx(self, output, should_preserve_existing=False): df = self.export_pd() - if self.summaries: + if self.has_summaries(): df.loc[df.shape[0]] = self.summaries if os.path.exists(output): diff --git a/src/ifcopenshell-python/ifcopenshell/api/geometry/edit_object_placement.py b/src/ifcopenshell-python/ifcopenshell/api/geometry/edit_object_placement.py index e0c58c3dea..3351ed3da4 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/geometry/edit_object_placement.py +++ b/src/ifcopenshell-python/ifcopenshell/api/geometry/edit_object_placement.py @@ -130,7 +130,15 @@ class Usecase: continue elif obj.is_a("IfcFeatureElement"): # Feature elements affect the geometry of their parent, and - # so logically should always move with the parent. + # so logically should always move with the parent. However, + # subchildren shouldn't move. + placement2 = obj.ObjectPlacement + for referenced_placement2 in placement2.ReferencedByPlacements: + matrix2 = ifcopenshell.util.placement.get_local_placement(referenced_placement2) + for obj2 in referenced_placement2.PlacesObject: + results.append( + {"product": obj2, "matrix": matrix2, "is_si": False, "should_transform_children": True} + ) continue results.append({"product": obj, "matrix": matrix, "is_si": False, "should_transform_children": True}) return results diff --git a/src/ifcopenshell-python/ifcopenshell/api/unit/__init__.py b/src/ifcopenshell-python/ifcopenshell/api/unit/__init__.py index 890af4fbde..8b17d0ecab 100644 --- a/src/ifcopenshell-python/ifcopenshell/api/unit/__init__.py +++ b/src/ifcopenshell-python/ifcopenshell/api/unit/__init__.py @@ -28,6 +28,7 @@ from .add_context_dependent_unit import add_context_dependent_unit from .add_conversion_based_unit import add_conversion_based_unit from .add_monetary_unit import add_monetary_unit from .add_si_unit import add_si_unit +from .add_derived_unit import add_derived_unit from .assign_unit import assign_unit from .edit_derived_unit import edit_derived_unit from .edit_monetary_unit import edit_monetary_unit @@ -42,6 +43,7 @@ __all__ = [ "add_conversion_based_unit", "add_monetary_unit", "add_si_unit", + "add_derived_unit", "assign_unit", "edit_derived_unit", "edit_monetary_unit", diff --git a/src/ifcopenshell-python/ifcopenshell/api/unit/add_derived_unit.py b/src/ifcopenshell-python/ifcopenshell/api/unit/add_derived_unit.py new file mode 100644 index 0000000000..8a68e5727f --- /dev/null +++ b/src/ifcopenshell-python/ifcopenshell/api/unit/add_derived_unit.py @@ -0,0 +1,83 @@ +# IfcOpenShell - IFC toolkit and geometry engine +# Copyright (C) 2021 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 ifcopenshell.util.unit +from typing import Any, Optional + + +def add_derived_unit( + file: ifcopenshell.file, unit_type: str, userdefinedtype: str, attributes: dict[ifcopenshell.entity_instance, int] +) -> ifcopenshell.entity_instance: + """Add a new Derive unit + + The supported types are ANGULARVELOCITYUNIT, AREADENSITYUNIT, COMPOUNDPLANEANGLEUNIT, + DYNAMICVISCOSITYUNIT, HEATFLUXDENSITYUNIT, INTEGERCOUNTRATEUNIT, ISOTHERMALMOISTURECAPACITYUNIT, + KINEMATICVISCOSITYUNIT, LINEARVELOCITYUNIT, MASSDENSITYUNIT, MASSFLOWRATEUNIT, MOISTUREDIFFUSIVITYUNIT, + MOLECULARWEIGHTUNIT, SPECIFICHEATCAPACITYUNIT, THERMALADMITTANCEUNIT, THERMALCONDUCTANCEUNIT, + THERMALRESISTANCEUNIT, THERMALTRANSMITTANCEUNIT, VAPORPERMEABILITYUNIT, VOLUMETRICFLOWRATEUNIT, + ROTATIONALFREQUENCYUNIT, TORQUEUNIT, MOMENTOFINERTIAUNIT, LINEARMOMENTUNIT, LINEARFORCEUNIT, + PLANARFORCEUNIT, MODULUSOFELASTICITYUNIT, SHEARMODULUSUNIT, LINEARSTIFFNESSUNIT, ROTATIONALSTIFFNESSUNIT, + MODULUSOFSUBGRADEREACTIONUNIT, ACCELERATIONUNIT, CURVATUREUNIT, HEATINGVALUEUNIT, IONCONCENTRATIONUNIT, + LUMINOUSINTENSITYDISTRIBUTIONUNIT, MASSPERLENGTHUNIT, MODULUSOFLINEARSUBGRADEREACTIONUNIT, + MODULUSOFROTATIONALSUBGRADEREACTIONUNIT, PHUNIT, ROTATIONALMASSUNIT, SECTIONAREAINTEGRALUNIT, + SECTIONMODULUSUNIT, SOUNDPOWERLEVELUNIT, SOUNDPOWERUNIT, SOUNDPRESSURELEVELUNIT, SOUNDPRESSUREUNIT, + TEMPERATUREGRADIENTUNIT, TEMPERATURERATEOFCHANGEUNIT, THERMALEXPANSIONCOEFFICIENTUNIT, WARPINGCONSTANTUNIT, + WARPINGMOMENTUNIT, USERDEFINED. + + In case of choosing USERDEFINED, the UserDefinedType parameter needs to be provided + + :param unit_type: A type of unit chosen from the list above. For + example, choosing THERMALCONDUCTANCEUNIT will give you a Thermal conductance. + :type unit_type: str + :param userdefinedtype: The user defined type in case of choosing USERDEFINED, or None for no + user defined type. + :type userdefinedtype: str or None + :param attributes: a dictionary of attribute names and values. + :type attributes: dict + :return: The newly created IfcDerivedUnit + :rtype: ifcopenshell.entity_instance + + Example: + + .. code:: python + + # Linear velocity in m/s + length = ifcopenshell.api.unit.add_si_unit(model, unit_type="LENGTHUNIT") + #2=IfcSIUnit(*,.LENGTHUNIT.,$,.METRE.) + + time = ifcopenshell.api.unit.add_si_unit(model, unit_type="TIMEUNIT") + #4=IfcSIUnit(*,.TIMEUNIT.,$,.SECOND.) + + linear_velocity = ifcopenshell.api.unit.add_derived_unit(model, 'LINEARVELOCITY', None, {length : 1, time : -1}) + #10=IfcDerivedUnitElement(#2, 1) + #11=IfcDerivedUnitElement(#4, -1) + #12=IfcDerivedUnit((#10,#11),.LINEARVELOCITY.,$) + + """ + settings = {"unit_type": unit_type, "attributes": attributes} + + derive_unit_elements = [] + + for named_unit in settings["attributes"]: + derive_unit_elements.append( + file.create_entity("IfcDerivedUnitElement", Unit=named_unit, Exponent=settings["attributes"][named_unit]) + ) + + return file.create_entity( + "IfcDerivedUnit", Elements=derive_unit_elements, UnitType=settings["unit_type"], UserDefinedType=userdefinedtype + ) diff --git a/src/ifcopenshell-python/ifcopenshell/geom/main.py b/src/ifcopenshell-python/ifcopenshell/geom/main.py index 172a8536d8..08f443c6b9 100644 --- a/src/ifcopenshell-python/ifcopenshell/geom/main.py +++ b/src/ifcopenshell-python/ifcopenshell/geom/main.py @@ -74,7 +74,7 @@ SETTING = Literal[ "no-wire-intersection-check", "no-wire-intersection-tolerance", "precision-factor", - "debug", + "debug-boolean", "boolean-attempt-2d", "weld-vertices", "use-world-coords", diff --git a/src/ifcopenshell-python/ifcopenshell/util/selector.py b/src/ifcopenshell-python/ifcopenshell/util/selector.py index 1ac8809d11..37919b9408 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/selector.py +++ b/src/ifcopenshell-python/ifcopenshell/util/selector.py @@ -763,37 +763,33 @@ class FacetTransformer(lark.Transformer): else: self.base_elements = elements.copy() self.elements = set() + self.has_additive_facet_in_current_list = False self.container_trees = {} + def add_default_elements(self): + if self.has_additive_facet_in_current_list: + return + self.has_additive_facet_in_current_list = True + if self.base_elements: + self.elements.update(self.base_elements) + else: + self.elements.update(self.file.by_type("IfcProduct")) + self.elements.update(self.file.by_type("IfcTypeProduct")) + def get_results(self) -> set[ifcopenshell.entity_instance]: results: set[ifcopenshell.entity_instance] = set() for r in self.results: results |= r return results - def transform(self, tree: lark.ParseTree): - def has_elements_token(tree: lark.tree.ParseTree) -> bool: - for child in tree.iter_subtrees_topdown(): - data = child.data - # Tokens that add elements to filter. - if data in ("entity", "instance"): - return True - return False - - # Only elements tokens can add elements to the filter. - # Fallback to default elements if they are not provided. - if self.base_elements is None and not has_elements_token(tree): - self.elements.update(self.file.by_type("IfcProduct")) - self.elements.update(self.file.by_type("IfcTypeProduct")) - - return super().transform(tree) - def facet_list(self, args): if self.elements: self.results.append(self.elements) self.elements = set() + self.has_additive_facet_in_current_list = False def instance(self, args): + self.has_additive_facet_in_current_list = True if self.base_elements is None: if args[0].data == "globalid": try: @@ -816,6 +812,7 @@ class FacetTransformer(lark.Transformer): } def entity(self, args): + self.has_additive_facet_in_current_list = True if self.base_elements is None: if args[0].data == "ifc_class": try: @@ -844,6 +841,7 @@ class FacetTransformer(lark.Transformer): element_value = getattr(element, name, None) return self.compare(element_value, comparison, value) + self.add_default_elements() self.elements = set(filter(filter_function, self.elements)) def type(self, args): @@ -853,6 +851,7 @@ class FacetTransformer(lark.Transformer): element_value = getattr(ifcopenshell.util.element.get_type(element), "Name", None) return self.compare(element_value, comparison, value) + self.add_default_elements() self.elements = set(filter(filter_function, self.elements)) def material(self, args): @@ -870,6 +869,7 @@ class FacetTransformer(lark.Transformer): return result if comparison == "=" else not result return self.compare(None, comparison, value) + self.add_default_elements() self.elements = set(filter(filter_function, self.elements)) def property(self, args): @@ -899,6 +899,7 @@ class FacetTransformer(lark.Transformer): return self.compare(element_value, comparison, value) return self.compare(None, comparison, value) + self.add_default_elements() self.elements = set(filter(filter_function, self.elements)) def classification(self, args): @@ -918,6 +919,7 @@ class FacetTransformer(lark.Transformer): return result if comparison == "=" else not result return self.compare(None, comparison, value) + self.add_default_elements() self.elements = set(filter(filter_function, self.elements)) def location(self, args): @@ -936,6 +938,7 @@ class FacetTransformer(lark.Transformer): return result if comparison == "=" else not result return self.compare(None, comparison, value) + self.add_default_elements() self.elements = set(filter(filter_function, self.elements)) def group(self, args): @@ -949,6 +952,7 @@ class FacetTransformer(lark.Transformer): result = True return result if comparison == "=" else not result + self.add_default_elements() self.elements = set(filter(filter_function, self.elements)) def parent(self, args): @@ -989,6 +993,7 @@ class FacetTransformer(lark.Transformer): for parent in parents: children |= set(ifcopenshell.util.element.get_decomposition(parent)) + self.add_default_elements() if comparison == "=": self.elements = self.elements & children else: @@ -1000,6 +1005,7 @@ class FacetTransformer(lark.Transformer): def filter_function(element: ifcopenshell.entity_instance) -> bool: return self.compare(get_element_value(element, keys), comparison, value) + self.add_default_elements() self.elements = set(filter(filter_function, self.elements)) def get_container_tree(self, container: ifcopenshell.entity_instance) -> list[ifcopenshell.entity_instance]: diff --git a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py index a2595c783b..c7dd729dd3 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py +++ b/src/ifcopenshell-python/ifcopenshell/util/shape_builder.py @@ -30,7 +30,6 @@ import ifcopenshell.util.unit from math import cos, sin, pi, tan, radians, degrees, atan, sqrt from typing import Union, Optional, Literal, Any, Sequence, TYPE_CHECKING from itertools import chain -from mathutils import Vector PRECISION = 1.0e-5 diff --git a/src/ifcopenshell-python/ifcopenshell/util/unit.py b/src/ifcopenshell-python/ifcopenshell/util/unit.py index 329ccff281..a19594f1b5 100644 --- a/src/ifcopenshell-python/ifcopenshell/util/unit.py +++ b/src/ifcopenshell-python/ifcopenshell/util/unit.py @@ -289,15 +289,11 @@ unit_symbols = { "SECOND": "s", "SQUARE_METRE": "m2", "METRE": "m", - # non si units - "cubic inch": "in3", - "cubic foot": "ft3", - "cubic yard": "yd3", - "square inch": "in2", - "square foot": "ft2", - "square yard": "yd2", - "square mile": "mi2", + "NEWTON": "N", + "PASCAL": "Pa", # conversion based units + "pound-force": "lbf", + "pound-force per square inch": "psi", "thou": "th", "inch": "in", "foot": "ft", diff --git a/src/ifcopenshell-python/test/api/geometry/test_edit_object_placement.py b/src/ifcopenshell-python/test/api/geometry/test_edit_object_placement.py index 85b32435a3..7c6bb46b60 100644 --- a/src/ifcopenshell-python/test/api/geometry/test_edit_object_placement.py +++ b/src/ifcopenshell-python/test/api/geometry/test_edit_object_placement.py @@ -545,11 +545,12 @@ class TestEditObjectPlacement(test.bootstrap.IFC4): with pytest.raises(RuntimeError): self.file.by_id(previous_placement_id) - def test_changing_placements_always_affecting_child_features_as_a_special_case(self): + def test_changing_placements_always_affecting_child_features_but_not_subchildren_as_a_special_case(self): ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcProject") ifcopenshell.api.unit.assign_unit(self.file) element = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcWall") subelement = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcOpeningElement") + subsubelement = ifcopenshell.api.root.create_entity(self.file, ifc_class="IfcDoor") matrix = numpy.eye(4) matrix[:3, 3] = (1, 1, 1) @@ -557,16 +558,23 @@ class TestEditObjectPlacement(test.bootstrap.IFC4): submatrix = numpy.eye(4) submatrix[:3, 3] = (1, 2, 3) + subsubmatrix = numpy.eye(4) + subsubmatrix[:3, 3] = (7, 8, 9) + shifted_submatrix = numpy.eye(4) shifted_submatrix[:3, 3] = (1, 3, 5) ifcopenshell.api.void.add_opening(self.file, opening=subelement, element=element) + ifcopenshell.api.void.add_filling(self.file, opening=subelement, element=subsubelement) previous_placement_id = ifcopenshell.api.geometry.edit_object_placement( self.file, product=element, matrix=matrix.copy(), is_si=False ).id() ifcopenshell.api.geometry.edit_object_placement( self.file, product=subelement, matrix=submatrix.copy(), is_si=False ) + ifcopenshell.api.geometry.edit_object_placement( + self.file, product=subsubelement, matrix=subsubmatrix.copy(), is_si=False + ) ifcopenshell.api.geometry.edit_object_placement( self.file, product=element, @@ -578,7 +586,11 @@ class TestEditObjectPlacement(test.bootstrap.IFC4): assert numpy.array_equal( ifcopenshell.util.placement.get_local_placement(subelement.ObjectPlacement), shifted_submatrix ) + assert numpy.array_equal( + ifcopenshell.util.placement.get_local_placement(subsubelement.ObjectPlacement), subsubmatrix + ) assert subelement.ObjectPlacement.PlacementRelTo == element.ObjectPlacement + assert subsubelement.ObjectPlacement.PlacementRelTo == subelement.ObjectPlacement # old placement should be removed to avoid orphaned entities with pytest.raises(RuntimeError): self.file.by_id(previous_placement_id) diff --git a/src/ifcpatch/ifcpatch/__init__.py b/src/ifcpatch/ifcpatch/__init__.py index 907ab669fb..3112483785 100644 --- a/src/ifcpatch/ifcpatch/__init__.py +++ b/src/ifcpatch/ifcpatch/__init__.py @@ -95,30 +95,16 @@ def execute(args: ArgumentsDict) -> Union[ifcopenshell.file, str]: else: recipe = importlib.import_module(f"ifcpatch.recipes.{args['recipe']}") - # Ensure file or input is provided. - input_argument = get_patch_input_argument_use(args["recipe"]) - if input_argument == "REQUIRED": - if not args.get("input"): - raise ValueError(f"Recipe {args['recipe']} is requiring 'input' argument to be provided.") - elif "file" not in args: # SUPPORTED, IGNORED. - raise ValueError(f"Recipe {args['recipe']} is requiring 'file' argument to be provided.") - arguments = args.get("arguments", None) or [] if recipe.Patcher.__init__.__doc__ is not None: - patcher = recipe.Patcher(args.get("input"), args.get("file"), logger, *arguments) + patcher = recipe.Patcher(args.get("file"), logger, *arguments) else: - patcher = recipe.Patcher(args.get("input"), args.get("file"), logger, arguments) + patcher = recipe.Patcher(args.get("file"), logger, arguments) patcher.patch() output = getattr(patcher, "file_patched", patcher.file) return output -def get_patch_input_argument_use(recipe: str) -> Literal["REQUIRED", "SUPPORTED", "IGNORED"]: - # try out of tree and subpackage imports - recipe_module = sys.modules.get(f"ifcpatch.recipes.{recipe}") or sys.modules.get(recipe) - return getattr(recipe_module.Patcher, "input_argument", "IGNORED") - - def write(output: Union[ifcopenshell.file, str], filepath: str) -> None: """Write the output of an IFC patch to a file diff --git a/src/ifcpatch/ifcpatch/recipes/ConvertLengthUnit.py b/src/ifcpatch/ifcpatch/recipes/ConvertLengthUnit.py index c555a2035e..297cdfbda6 100644 --- a/src/ifcpatch/ifcpatch/recipes/ConvertLengthUnit.py +++ b/src/ifcpatch/ifcpatch/recipes/ConvertLengthUnit.py @@ -53,7 +53,6 @@ LengthUnit = typing.Literal[ class Patcher: def __init__( self, - src: str, file: ifcopenshell.file, logger: Logger, unit: LengthUnit = "METER", @@ -76,7 +75,6 @@ class Patcher: # Convert to feet model = ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "ConvertLengthUnit", "arguments": ["FOOT"]}) """ - self.src = src self.file = file self.logger = logger self.unit = unit diff --git a/src/ifcpatch/ifcpatch/recipes/ConvertNestToAggregate.py b/src/ifcpatch/ifcpatch/recipes/ConvertNestToAggregate.py index d9df05666d..8471ed28f3 100644 --- a/src/ifcpatch/ifcpatch/recipes/ConvertNestToAggregate.py +++ b/src/ifcpatch/ifcpatch/recipes/ConvertNestToAggregate.py @@ -21,7 +21,7 @@ import ifcopenshell.guid class Patcher: - def __init__(self, src, file, logger): + def __init__(self, file, logger): """Convert nesting relationships to aggregate relationships Some software like Revit won't load nested children elements because @@ -39,7 +39,6 @@ class Patcher: ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "ConvertNestToAggregate", "arguments": []}) """ - self.src = src self.file = file self.logger = logger diff --git a/src/ifcpatch/ifcpatch/recipes/ConvertPropertiesToQuantities.py b/src/ifcpatch/ifcpatch/recipes/ConvertPropertiesToQuantities.py index 48482821e2..969461ecc2 100644 --- a/src/ifcpatch/ifcpatch/recipes/ConvertPropertiesToQuantities.py +++ b/src/ifcpatch/ifcpatch/recipes/ConvertPropertiesToQuantities.py @@ -26,7 +26,7 @@ from typing import Union class Patcher: - def __init__(self, src: str, file: ifcopenshell.file, logger: Logger, property_name: str, quantity_name: str): + def __init__(self, file: ifcopenshell.file, logger: Logger, property_name: str, quantity_name: str): """Converts a property to a standardised quantity IFC can store arbitrary key value metadata associated with a elements @@ -59,7 +59,6 @@ class Patcher: # "NetSideArea", if that standardised quantity exists. ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "ConvertPropertiesToQuantities", "arguments": ["Area", "NetSideArea"]}) """ - self.src = src self.file = file self.logger = logger self.source_property_name = property_name diff --git a/src/ifcpatch/ifcpatch/recipes/DowngradeIndexedPolyCurve.py b/src/ifcpatch/ifcpatch/recipes/DowngradeIndexedPolyCurve.py index f9afbe6835..ea169f2967 100644 --- a/src/ifcpatch/ifcpatch/recipes/DowngradeIndexedPolyCurve.py +++ b/src/ifcpatch/ifcpatch/recipes/DowngradeIndexedPolyCurve.py @@ -21,7 +21,7 @@ import ifcopenshell.util.element class Patcher: - def __init__(self, src, file, logger): + def __init__(self, file, logger): """Downgrade indexed polycurves to simple polylines Low quality IFC viewers like Navisworks do not support various IFC4 @@ -39,7 +39,6 @@ class Patcher: ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "DowngradeIndexedPolyCurve", "arguments": []}) """ - self.src = src self.file = file self.logger = logger diff --git a/src/ifcpatch/ifcpatch/recipes/ExtractElements.py b/src/ifcpatch/ifcpatch/recipes/ExtractElements.py index 3947dd919d..e49f1b0af4 100644 --- a/src/ifcpatch/ifcpatch/recipes/ExtractElements.py +++ b/src/ifcpatch/ifcpatch/recipes/ExtractElements.py @@ -25,7 +25,7 @@ from logging import Logger class Patcher: - def __init__(self, src: str, file: ifcopenshell.file, logger: Logger, query: str = "IfcWall"): + def __init__(self, file: ifcopenshell.file, logger: Logger, query: str = "IfcWall"): """Extract certain elements into a new model Extract a subset of elements from an existing IFC data set and save it @@ -48,7 +48,6 @@ class Patcher: # Extract all walls and slabs ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "ExtractElements", "arguments": ["IfcWall, IfcSlab"]}) """ - self.src = src self.file = file self.logger = logger self.query = query diff --git a/src/ifcpatch/ifcpatch/recipes/ExtractPropertiesToSQLite.py b/src/ifcpatch/ifcpatch/recipes/ExtractPropertiesToSQLite.py index f9fdc9727a..226052ae19 100644 --- a/src/ifcpatch/ifcpatch/recipes/ExtractPropertiesToSQLite.py +++ b/src/ifcpatch/ifcpatch/recipes/ExtractPropertiesToSQLite.py @@ -34,7 +34,6 @@ except: class Patcher: def __init__( self, - src, file, logger, ): @@ -50,7 +49,6 @@ class Patcher: result = ifcpatch.execute({"input": fn, "file": model, "recipe": "ExtractPropertiesToSQLite"}) ifcpatch.write(result, "output.sqlite") """ - self.src = src self.file = file self.logger = logger diff --git a/src/ifcpatch/ifcpatch/recipes/FixArchiCADToRevitDoorSwings.py b/src/ifcpatch/ifcpatch/recipes/FixArchiCADToRevitDoorSwings.py index b087b4e6b8..54e3e32fec 100644 --- a/src/ifcpatch/ifcpatch/recipes/FixArchiCADToRevitDoorSwings.py +++ b/src/ifcpatch/ifcpatch/recipes/FixArchiCADToRevitDoorSwings.py @@ -27,7 +27,7 @@ import ifcopenshell.util.element class Patcher: - def __init__(self, src, file, logger): + def __init__(self, file, logger): """Fix missing door swings in Revit when viewing ArchiCAD IFCs ArchiCAD has the ability to store 2D data with objects like doors for @@ -56,7 +56,6 @@ class Patcher: ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "FixArchiCADToRevitDoorSwings", "arguments": []}) """ - self.src = src self.file = file self.logger = logger diff --git a/src/ifcpatch/ifcpatch/recipes/FixArchiCADToRevitSpaces.py b/src/ifcpatch/ifcpatch/recipes/FixArchiCADToRevitSpaces.py index 49b6507a7e..84941d2bd6 100644 --- a/src/ifcpatch/ifcpatch/recipes/FixArchiCADToRevitSpaces.py +++ b/src/ifcpatch/ifcpatch/recipes/FixArchiCADToRevitSpaces.py @@ -21,9 +21,7 @@ import ifcopenshell class Patcher: - input_argument = "REQUIRED" - - def __init__(self, src: str, file: None, logger: logging.Logger): + def __init__(self, file: None, logger: logging.Logger, filepath: str): """Allow ArchiCAD IFC spaces to open as Revit rooms The underlying problem is that Revit does not bring in IFC spaces as @@ -51,16 +49,21 @@ class Patcher: requires you to run it using Blender, as the geometric modification uses the Blender geometry engine. - `input` argument is required for this recipe, `file` argument is ignored. + `filepath` argument is required for this recipe, `file` argument is + ignored. + + :param filepath: The filepath of the IFC model. This is required to + load into Bonsai. + :filter_glob filepath: *.ifc;*.ifczip;*.ifcxml Example: .. code:: python ifcpatch.execute({"input": "input.ifc", "recipe": "FixArchiCADToRevitSpaces", "arguments": []}) """ - self.src = src self.file = file self.logger = logger + self.filepath = filepath def patch(self) -> None: import bpy @@ -74,7 +77,7 @@ class Patcher: bpy.data.batch_remove(bpy.data.objects) bpy.ops.outliner.orphans_purge(do_local_ids=True, do_linked_ids=True, do_recursive=True) - bpy.ops.bim.load_project(filepath=self.src) + bpy.ops.bim.load_project(filepath=self.filepath) def recalculate_origin(wall: bpy.types.Object) -> None: new_origin = wall.matrix_world @ Vector(wall.bound_box[0]) diff --git a/src/ifcpatch/ifcpatch/recipes/FixRevitClassificationCodeTypes.py b/src/ifcpatch/ifcpatch/recipes/FixRevitClassificationCodeTypes.py index 21a613c5f0..1ec5b331de 100644 --- a/src/ifcpatch/ifcpatch/recipes/FixRevitClassificationCodeTypes.py +++ b/src/ifcpatch/ifcpatch/recipes/FixRevitClassificationCodeTypes.py @@ -21,7 +21,7 @@ import ifcopenshell.util.element class Patcher: - def __init__(self, src, file, logger): + def __init__(self, file, logger): """Reassigns occurrence classifications to types Revit has a bug (see https://github.com/Autodesk/revit-ifc/issues/691) @@ -35,7 +35,6 @@ class Patcher: ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "FixRevitClassificationCodeTypes"}) """ - self.src = src self.file = file self.logger = logger diff --git a/src/ifcpatch/ifcpatch/recipes/FixRevitTINs.py b/src/ifcpatch/ifcpatch/recipes/FixRevitTINs.py index 60a08dd876..07184d1cb2 100644 --- a/src/ifcpatch/ifcpatch/recipes/FixRevitTINs.py +++ b/src/ifcpatch/ifcpatch/recipes/FixRevitTINs.py @@ -22,9 +22,7 @@ import logging class Patcher: - input_argument = "REQUIRED" - - def __init__(self, src: str, file: None, logger: logging.Logger, is_solid: bool = True): + def __init__(self, file: None, logger: logging.Logger, filepath: str, is_solid: bool = True): """Fix missing or spot-coordinate bugged TINs loading in Revit TINs exported from 12D or Civil 3D may contain dense or highly obtuse @@ -62,7 +60,12 @@ class Patcher: from civil software. It also requires you to run it using Blender, as the geometric modification uses the Blender geometry engine. - `input` argument is required for this recipe, `file` argument is ignored. + `filepath` argument is required for this recipe, `file` argument is + ignored. + + :param filepath: The filepath of the IFC model. This is required to + load into Bonsai. + :filter_glob filepath: *.ifc;*.ifczip;*.ifcxml Example: @@ -70,8 +73,8 @@ class Patcher: ifcpatch.execute({"input": "input.ifc", "recipe": "FixRevitTINs", "arguments": []}) """ - self.src = src self.file = file + self.filepath = filepath self.logger = logger self.is_solid = is_solid @@ -82,7 +85,7 @@ class Patcher: from math import degrees bpy.context.scene.BIMProjectProperties.should_use_native_meshes = True - bpy.ops.bim.load_project(filepath=self.src) + bpy.ops.bim.load_project(filepath=self.filepath) old_history_size = tool.Ifc.get().history_size old_undo_steps = bpy.context.preferences.edit.undo_steps diff --git a/src/ifcpatch/ifcpatch/recipes/Ifc2Sql.py b/src/ifcpatch/ifcpatch/recipes/Ifc2Sql.py index 3114d8620a..f382bc6a18 100644 --- a/src/ifcpatch/ifcpatch/recipes/Ifc2Sql.py +++ b/src/ifcpatch/ifcpatch/recipes/Ifc2Sql.py @@ -54,7 +54,6 @@ except: class Patcher: def __init__( self, - src, file, logger, sql_type: SQLTypes = "SQLite", @@ -98,7 +97,6 @@ class Patcher: {"input": "input.ifc", "file": model, "recipe": "Ifc2Sql", "arguments": ["sqlite"]} ) """ - self.src = src self.file = file self.logger = logger self.sql_type = sql_type.lower() diff --git a/src/ifcpatch/ifcpatch/recipes/MergeDuplicateTypes.py b/src/ifcpatch/ifcpatch/recipes/MergeDuplicateTypes.py index c26fa918c4..6b40b26fbc 100644 --- a/src/ifcpatch/ifcpatch/recipes/MergeDuplicateTypes.py +++ b/src/ifcpatch/ifcpatch/recipes/MergeDuplicateTypes.py @@ -23,7 +23,7 @@ from logging import Logger class Patcher: - def __init__(self, src: str, file: ifcopenshell.file, logger: Logger, attribute: str = "Tag"): + def __init__(self, file: ifcopenshell.file, logger: Logger, attribute: str = "Tag"): """Merge duplicate element types via the Tag or another attribute Revit is notorious for creating many duplicate element types. Element @@ -60,7 +60,6 @@ class Patcher: # Explicitly say we want to merge based on the Name attribute ifcpatch.execute({"file": model, "recipe": "MergeDuplicateTypes", "arguments": ["Name"]}) """ - self.src = src self.file = file self.logger = logger self.attribute = attribute diff --git a/src/ifcpatch/ifcpatch/recipes/MergeProjects.py b/src/ifcpatch/ifcpatch/recipes/MergeProjects.py index 7581f7259a..4e06b01274 100644 --- a/src/ifcpatch/ifcpatch/recipes/MergeProjects.py +++ b/src/ifcpatch/ifcpatch/recipes/MergeProjects.py @@ -27,9 +27,7 @@ from logging import Logger class Patcher: - def __init__( - self, src: str, file: ifcopenshell.file, logger: Logger, filepaths: list[Union[str, ifcopenshell.file]] - ): + def __init__(self, file: ifcopenshell.file, logger: Logger, filepaths: list[Union[str, ifcopenshell.file]]): """Merge two or more IFC models into one Note that other than combining the two (or more) IfcProject elements into @@ -50,7 +48,6 @@ class Patcher: ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "MergeProjects", "arguments": ["/path/to/model2.ifc"]}) """ - self.src = src self.file = file self.logger = logger self.filepaths = filepaths diff --git a/src/ifcpatch/ifcpatch/recipes/Migrate.py b/src/ifcpatch/ifcpatch/recipes/Migrate.py index 9938ddd754..cf90464678 100644 --- a/src/ifcpatch/ifcpatch/recipes/Migrate.py +++ b/src/ifcpatch/ifcpatch/recipes/Migrate.py @@ -25,7 +25,6 @@ from logging import Logger class Patcher: def __init__( self, - src: str, file: ifcopenshell.file, logger: Logger, schema: ifcopenshell.util.schema.IFC_SCHEMA = "IFC4", @@ -45,7 +44,6 @@ class Patcher: # Upgrade an IFC2X3 model to IFC4 ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "Migrate", "arguments": ["IFC4"]}) """ - self.src = src self.file = file self.logger = logger self.schema = schema diff --git a/src/ifcpatch/ifcpatch/recipes/OffsetObjectPlacements.py b/src/ifcpatch/ifcpatch/recipes/OffsetObjectPlacements.py index 55059e5283..765cef7336 100644 --- a/src/ifcpatch/ifcpatch/recipes/OffsetObjectPlacements.py +++ b/src/ifcpatch/ifcpatch/recipes/OffsetObjectPlacements.py @@ -26,7 +26,6 @@ import typing class Patcher: def __init__( self, - src, file, logger, x: typing.Union[str, float] = "0", @@ -89,7 +88,6 @@ class Patcher: # Some crazy 3D rotation and offset ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "OffsetObjectPlacements", "arguments": [12.5,5,2,False,90,90,45]}) """ - self.src = src self.file = file self.logger = logger self.x = float(x) diff --git a/src/ifcpatch/ifcpatch/recipes/OffsetStoreyElevations.py b/src/ifcpatch/ifcpatch/recipes/OffsetStoreyElevations.py index 5611b4a126..4b3efd7c71 100644 --- a/src/ifcpatch/ifcpatch/recipes/OffsetStoreyElevations.py +++ b/src/ifcpatch/ifcpatch/recipes/OffsetStoreyElevations.py @@ -20,7 +20,7 @@ import typing class Patcher: - def __init__(self, src, file, logger, z: typing.Union[str, float] = "0"): + def __init__(self, file, logger, z: typing.Union[str, float] = "0"): """Offset building storeys by a particular Z value All objects placed relative to the storeys will also be shifted. @@ -35,7 +35,6 @@ class Patcher: # Shift all storeys up by 42 units ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "OffsetStoreyElevations", "arguments": [42]}) """ - self.src = src self.file = file self.logger = logger self.z = float(z) diff --git a/src/ifcpatch/ifcpatch/recipes/Optimise.py b/src/ifcpatch/ifcpatch/recipes/Optimise.py index bca0c12ef7..79699ae2e6 100644 --- a/src/ifcpatch/ifcpatch/recipes/Optimise.py +++ b/src/ifcpatch/ifcpatch/recipes/Optimise.py @@ -21,7 +21,7 @@ import ifcopenshell.util.element class Patcher: - def __init__(self, src, file, logger): + def __init__(self, file, logger): """Optimise the filesize of an IFC model It is possible to non-losslessly optimise the filesize of an IFC model. @@ -46,7 +46,6 @@ class Patcher: ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "Optimise", "arguments": []}) """ - self.src = src self.file = file self.logger = logger self.optimized_file = ifcopenshell.file(schema=self.file.schema) diff --git a/src/ifcpatch/ifcpatch/recipes/PurgeData.py b/src/ifcpatch/ifcpatch/recipes/PurgeData.py index 9f55b47af9..871120840f 100644 --- a/src/ifcpatch/ifcpatch/recipes/PurgeData.py +++ b/src/ifcpatch/ifcpatch/recipes/PurgeData.py @@ -22,7 +22,7 @@ from logging import Logger class Patcher: - def __init__(self, src: str, file: ifcopenshell.file, logger: Logger): + def __init__(self, file: ifcopenshell.file, logger: Logger): """Purge IFC properties, relationships, and other data In some rare cases (i.e. "resetting" a model or for security purposes) @@ -51,7 +51,6 @@ class Patcher: # Watch the world burn ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "PurgeData", "arguments": []}) """ - self.src = src self.file = file self.logger = logger diff --git a/src/ifcpatch/ifcpatch/recipes/RecycleNonRootedElements.py b/src/ifcpatch/ifcpatch/recipes/RecycleNonRootedElements.py index c94ddfeb30..93ab762778 100644 --- a/src/ifcpatch/ifcpatch/recipes/RecycleNonRootedElements.py +++ b/src/ifcpatch/ifcpatch/recipes/RecycleNonRootedElements.py @@ -22,7 +22,7 @@ import ifcopenshell.util.element class Patcher: - def __init__(self, src: str, file: ifcopenshell.file, logger: logging.Logger): + def __init__(self, file: ifcopenshell.file, logger: logging.Logger): """Optimise the filesize of an IFC model by reusing non-rooted elements It is possible to non-losslessly optimise the filesize of an IFC model. @@ -46,7 +46,6 @@ class Patcher: ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "RecycleNonRootedElements", "arguments": []}) """ - self.src = src self.file = file self.logger = logger diff --git a/src/ifcpatch/ifcpatch/recipes/RegenerateGlobalIds.py b/src/ifcpatch/ifcpatch/recipes/RegenerateGlobalIds.py index 6a00e54e95..63c595ca9e 100644 --- a/src/ifcpatch/ifcpatch/recipes/RegenerateGlobalIds.py +++ b/src/ifcpatch/ifcpatch/recipes/RegenerateGlobalIds.py @@ -22,7 +22,7 @@ from logging import Logger class Patcher: - def __init__(self, src: str, file: ifcopenshell.file, logger: Logger, only_duplicates=False): + def __init__(self, file: ifcopenshell.file, logger: Logger, only_duplicates: bool = False): """Regenerate GlobalIds in an IFC model All root elements in an IFC model must be identified by a unique Global @@ -48,7 +48,6 @@ class Patcher: # Regenerate only duplicate GlobalIds ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "RegenerateGlobalIds", "arguments": [True]}) """ - self.src = src self.file = file self.logger = logger self.only_duplicates = only_duplicates diff --git a/src/ifcpatch/ifcpatch/recipes/RemoveRevitUniformatClassification.py b/src/ifcpatch/ifcpatch/recipes/RemoveRevitUniformatClassification.py index 0a73508139..0d61bbd6c0 100644 --- a/src/ifcpatch/ifcpatch/recipes/RemoveRevitUniformatClassification.py +++ b/src/ifcpatch/ifcpatch/recipes/RemoveRevitUniformatClassification.py @@ -18,7 +18,7 @@ class Patcher: - def __init__(self, src, file, logger): + def __init__(self, file, logger): """Removes the built-in Revit Uniformat classification. Revit has a bug (see https://github.com/Autodesk/revit-ifc/issues/486) @@ -31,7 +31,6 @@ class Patcher: ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "RemoveRevitUniformatClassification"}) """ - self.src = src self.file = file self.logger = logger diff --git a/src/ifcpatch/ifcpatch/recipes/RemoveSiteRepresentation.py b/src/ifcpatch/ifcpatch/recipes/RemoveSiteRepresentation.py index 39b326a4c9..c5daea9868 100644 --- a/src/ifcpatch/ifcpatch/recipes/RemoveSiteRepresentation.py +++ b/src/ifcpatch/ifcpatch/recipes/RemoveSiteRepresentation.py @@ -18,7 +18,7 @@ class Patcher: - def __init__(self, src, file, logger): + def __init__(self, file, logger): """Removes any 3D geometry associated with a site or multiple sites If no sites or no site geometry is present, nothing happens. @@ -29,7 +29,6 @@ class Patcher: ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "RemoveSiteRepresentation", "arguments": []}) """ - self.src = src self.file = file self.logger = logger diff --git a/src/ifcpatch/ifcpatch/recipes/ResetAbsoluteCoordinates.py b/src/ifcpatch/ifcpatch/recipes/ResetAbsoluteCoordinates.py index 7f58e39694..ee0b4c1568 100644 --- a/src/ifcpatch/ifcpatch/recipes/ResetAbsoluteCoordinates.py +++ b/src/ifcpatch/ifcpatch/recipes/ResetAbsoluteCoordinates.py @@ -26,7 +26,6 @@ from typing import Literal, Optional, Union class Patcher: def __init__( self, - src: str, file: ifcopenshell.file, logger: logging.Logger, mode: Literal[ @@ -99,7 +98,6 @@ class Patcher: # Reset all coordinates with an ordinate larger than 1000 by -500,-200,0 ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "ResetAbsoluteCoordinates", "arguments": [False, 1000, -500,-200,0]}) """ - self.src = src self.file = file self.logger = logger self.mode = mode.lower() diff --git a/src/ifcpatch/ifcpatch/recipes/ResetSpatialElementLocations.py b/src/ifcpatch/ifcpatch/recipes/ResetSpatialElementLocations.py index f77516eb4b..aa335320ba 100644 --- a/src/ifcpatch/ifcpatch/recipes/ResetSpatialElementLocations.py +++ b/src/ifcpatch/ifcpatch/recipes/ResetSpatialElementLocations.py @@ -18,7 +18,7 @@ class Patcher: - def __init__(self, src, file, logger, ifc_class="IfcSite"): + def __init__(self, file, logger, ifc_class="IfcSite"): """Resets the location of a spatial element to 0,0,0 Another more specialised patch to fix incorrect coordinate usage is to @@ -35,7 +35,6 @@ class Patcher: # All IfcSites will shift back to 0,0,0. ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "ResetSpatialElementLocations", "arguments": ["IfcSite"]}) """ - self.src = src self.file = file self.logger = logger self.ifc_class = ifc_class diff --git a/src/ifcpatch/ifcpatch/recipes/SetFalseOrigin.py b/src/ifcpatch/ifcpatch/recipes/SetFalseOrigin.py index 8657890418..73008c8fdf 100644 --- a/src/ifcpatch/ifcpatch/recipes/SetFalseOrigin.py +++ b/src/ifcpatch/ifcpatch/recipes/SetFalseOrigin.py @@ -25,7 +25,6 @@ import typing class Patcher: def __init__( self, - src, file, logger, name: str = "EPSG:1234", @@ -59,7 +58,6 @@ class Patcher: # Set the current origin 0,0,0 to correlate to map coordinates 1000,1000,0 and a grid north of 15. ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "SetFalseOrigin", "arguments": ["EPSG:1234", 0, 0, 0, 1000, 1000, 0, 15, 0]}) """ - self.src = src self.file = file self.logger = logger self.name = name @@ -73,7 +71,7 @@ class Patcher: self.rotate_angle = float(rotate_angle) def patch(self): - SetWorldCoordinateSystem.Patcher(self.src, self.file, self.logger, x=0, y=0, z=0, ax=0, ay=0, az=0).patch() + SetWorldCoordinateSystem.Patcher(self.file, self.logger, x=0, y=0, z=0, ax=0, ay=0, az=0).patch() coordinate_operation = { "Eastings": self.e, "Northings": self.n, @@ -90,7 +88,6 @@ class Patcher: self.file, projected_crs={"Name": self.name}, coordinate_operation=coordinate_operation ) OffsetObjectPlacements.Patcher( - self.src, self.file, self.logger, x=-self.x, diff --git a/src/ifcpatch/ifcpatch/recipes/SetRefElevation.py b/src/ifcpatch/ifcpatch/recipes/SetRefElevation.py index cc1a092aff..a39b564cd9 100644 --- a/src/ifcpatch/ifcpatch/recipes/SetRefElevation.py +++ b/src/ifcpatch/ifcpatch/recipes/SetRefElevation.py @@ -20,7 +20,7 @@ import typing class Patcher: - def __init__(self, src, file, logger, elevation: typing.Union[str, float] = "0"): + def __init__(self, file, logger, elevation: typing.Union[str, float] = "0"): """Sets the reference elevation of all IfcSites To completely reference model coordinates, a reference elevation should @@ -41,7 +41,6 @@ class Patcher: # All IfcSites will have their reference elevation set to 42. ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "SetRefElevation", "arguments": [42]}) """ - self.src = src self.file = file self.logger = logger self.elevation = float(elevation) diff --git a/src/ifcpatch/ifcpatch/recipes/SetWorldCoordinateSystem.py b/src/ifcpatch/ifcpatch/recipes/SetWorldCoordinateSystem.py index ff8a4a4043..ba83d9a556 100644 --- a/src/ifcpatch/ifcpatch/recipes/SetWorldCoordinateSystem.py +++ b/src/ifcpatch/ifcpatch/recipes/SetWorldCoordinateSystem.py @@ -24,7 +24,6 @@ import typing class Patcher: def __init__( self, - src, file, logger, x: typing.Union[str, float] = "0", @@ -61,7 +60,6 @@ class Patcher: # Set the world coordinate system back to 0, 0, 0 ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "SetWorldCoordinateSystem", "arguments": [0,0,0,0,0,0]}) """ - self.src = src self.file = file self.logger = logger self.x = float(x) diff --git a/src/ifcpatch/ifcpatch/recipes/SplitByBuildingStorey.py b/src/ifcpatch/ifcpatch/recipes/SplitByBuildingStorey.py index 82f4e32fc4..a2cee58a08 100644 --- a/src/ifcpatch/ifcpatch/recipes/SplitByBuildingStorey.py +++ b/src/ifcpatch/ifcpatch/recipes/SplitByBuildingStorey.py @@ -24,18 +24,13 @@ from typing import Union class Patcher: - input_argument = "SUPPORTED" - - def __init__(self, src: str, file: ifcopenshell.file, logger: logging.Logger, output_dir: Union[str, None] = None): + def __init__(self, file: ifcopenshell.file, logger: logging.Logger, output_dir: Union[str, None] = None): """Split an IFC model into multiple models based on building storey The new IFC model names will be named after the storey name in the format of {i}-{name}.ifc, where {i} is an ascending number starting from 0 and {name} is the name of the storey. - `input` argument might be provided to ifcpatch - it will be used load file from disk - (otherwise `file` will be saved to a temporary file). - :param output_dir: Specifies an output directory where the new IFC models will be saved. Example: @@ -44,7 +39,6 @@ class Patcher: ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "SplitByBuildingStorey", "arguments": ["C:/.../output_files"]}) """ - self.src = src self.file = file self.logger = logger self.output_dir = output_dir @@ -60,17 +54,14 @@ class Patcher: output_dir = Path(self.output_dir) output_dir.mkdir(parents=True, exist_ok=True) - temp_file = None - if not self.src: - temp_file = tempfile.NamedTemporaryFile(suffix=".ifc", delete=False) - self.src = temp_file.name - self.file.write(self.src) + temp_file = tempfile.NamedTemporaryFile(suffix=".ifc", delete=False) + self.file.write(temp_file.name) storeys = self.file.by_type("IfcBuildingStorey") for i, storey in enumerate(storeys): filename = f"{i}-{storey.Name}.ifc" dest = filename if output_dir == None else output_dir / filename - copyfile(self.src, dest) + copyfile(temp_file.name, dest) old_ifc: ifcopenshell.file = ifcopenshell.open(dest) new_ifc = ifcopenshell.file(schema=self.file.schema) @@ -98,9 +89,8 @@ class Patcher: new_ifc.remove(element) new_ifc.write(dest) - if temp_file is not None: - temp_file.close() - os.unlink(temp_file.name) + temp_file.close() + os.unlink(temp_file.name) def is_in_storey(self, element: ifcopenshell.entity_instance, storey: ifcopenshell.entity_instance) -> bool: return ( diff --git a/src/ifcpatch/ifcpatch/recipes/TessellateElements.py b/src/ifcpatch/ifcpatch/recipes/TessellateElements.py index 06929cea59..5becec4264 100644 --- a/src/ifcpatch/ifcpatch/recipes/TessellateElements.py +++ b/src/ifcpatch/ifcpatch/recipes/TessellateElements.py @@ -30,7 +30,6 @@ from typing import Union class Patcher: def __init__( self, - src: str, file: ifcopenshell.file, logger: Logger, query: str = "IfcBeam", @@ -58,7 +57,6 @@ class Patcher: ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "TessellateElements", "arguments": ["IfcBeam", False]}) """ - self.src = src self.file = file self.logger = logger self.query = query diff --git a/src/ifcpatch/ifcpatch/recipes/UnsharePsets.py b/src/ifcpatch/ifcpatch/recipes/UnsharePsets.py index 7020a86504..fa7f1b083c 100644 --- a/src/ifcpatch/ifcpatch/recipes/UnsharePsets.py +++ b/src/ifcpatch/ifcpatch/recipes/UnsharePsets.py @@ -25,18 +25,22 @@ from logging import Logger class Patcher: - def __init__(self, src: str, file: ifcopenshell.file, logger: Logger, query: str = ""): + def __init__(self, file: ifcopenshell.file, logger: Logger, query: str = ""): """Create independent copies for shared psets in IFC file. - In IFC it's possible that same property set is shared by multiple elements, - so editing it's properties will automatically change their values for all those elements. + In IFC it's possible that same property set is shared by multiple + elements, so editing it's properties will automatically change their + values for all those elements. - Sometimes it's intended but sometimes it's not and it's just the way some other - software exports IFC (e.g. there is a known case when Tekla exports shared psets for all the occurrences). - While it is more optimized way to store data, it may lead to unexpected results when editing properties. + Sometimes it's intended but sometimes it's not and it's just the way + some other software exports IFC (e.g. there is a known case when Tekla + exports shared psets for all the occurrences). While it is more + optimized way to store data, it may lead to unexpected results when + editing properties. - This recipe creates independent copies of all shared psets (may be limited by the query) - and assigns them to the elements, so they can be edited without affecting any other elements. + This recipe creates independent copies of all shared psets (may be + limited by the query) and assigns them to the elements, so they can be + edited without affecting any other elements. :param query: A query to select the subset of IFC elements, optional. If not provided, patch will be applied to all shared property sets in the model. @@ -51,7 +55,6 @@ class Patcher: # Unshare psets on all IfcWalls. ifcpatch.execute({"input": "input.ifc", "file": model, "recipe": "UnsharePsets", "arguments": ["IfcWall"]}) """ - self.src = src self.file = file self.logger = logger self.query = query diff --git a/src/ifctester/ifctester/facet.py b/src/ifctester/ifctester/facet.py index f84f4821af..15562454f1 100644 --- a/src/ifctester/ifctester/facet.py +++ b/src/ifctester/ifctester/facet.py @@ -279,16 +279,13 @@ class Attribute(Facet): return results def __call__(self, inst: ifcopenshell.entity_instance, logger: Optional[Logger] = None) -> AttributeResult: - if self.cardinality == "optional": - return AttributeResult(True) - if isinstance(self.name, str): names = [self.name] attribute_type = inst.wrapped_data.get_attribute_category(self.name) if attribute_type == 1: # Forward attribute values = [getattr(inst, self.name, None)] else: - values = [None] + values = [] else: info = inst.get_info() names = [] @@ -304,6 +301,8 @@ class Attribute(Facet): reason = None if not is_pass: + if self.cardinality == "optional": + return AttributeResult(True) reason = {"type": "NOVALUE"} if is_pass: @@ -394,9 +393,6 @@ class Classification(Facet): return ifc_file.by_type("IfcObjectDefinition") def __call__(self, inst: ifcopenshell.entity_instance, logger: Optional[Logger] = None) -> ClassificationResult: - if self.cardinality == "optional": - return ClassificationResult(True) # Is this really the correct behaviour? - leaf_references = ifcopenshell.util.classification.get_references(inst) references = leaf_references.copy() @@ -407,6 +403,8 @@ class Classification(Facet): reason = None if not is_pass: + if self.cardinality == "optional": + return ClassificationResult(True) reason = {"type": "NOVALUE"} if is_pass and self.value: @@ -657,9 +655,6 @@ class Property(Facet): ) def __call__(self, inst: ifcopenshell.entity_instance, logger: Optional[Logger] = None) -> PropertyResult: - if self.cardinality == "optional": - return PropertyResult(True) - if isinstance(self.propertySet, str): pset = get_pset(inst, self.propertySet) psets = {self.propertySet: pset} if pset else {} @@ -671,6 +666,8 @@ class Property(Facet): reason = None if not is_pass: + if self.cardinality == "optional": + return PropertyResult(True) reason = {"type": "NOPSET"} if is_pass: @@ -691,6 +688,8 @@ class Property(Facet): props[pset_name] = {k: v for k, v in pset_props.items() if k == self.baseName} if not bool(props[pset_name]): + if self.cardinality == "optional": + return PropertyResult(True) is_pass = False reason = {"type": "NOVALUE"} break @@ -918,15 +917,14 @@ class Material(Facet): return ifc_file.by_type("IfcObjectDefinition") def __call__(self, inst: ifcopenshell.entity_instance, logger: Optional[Logger] = None) -> MaterialResult: - if self.cardinality == "optional": - return MaterialResult(True) - material = ifcopenshell.util.element.get_material(inst, should_skip_usage=True) is_pass = material is not None reason = None if not is_pass: + if self.cardinality == "optional": + return MaterialResult(True) reason = {"type": "NOVALUE"} if is_pass and self.value: @@ -1000,7 +998,17 @@ class Restriction: for constraint, value in self.options.items(): value = [value] if not isinstance(value, list) else value for v in value: - if constraint in ["length", "minLength", "maxLength"]: + if constraint in [ + "length", + "minLength", + "maxLength", + "maxExclusive", + "maxInclusive", + "minExclusive", + "minInclusive", + "totalDigits", + "fractionDigits", + ]: value_dict = {"@value": v} else: value_dict = {"@value": str(v)} diff --git a/src/ifctester/ifctester/templates/report.html b/src/ifctester/ifctester/templates/report.html index 7a3c582a02..71f89a3d08 100644 --- a/src/ifctester/ifctester/templates/report.html +++ b/src/ifctester/ifctester/templates/report.html @@ -107,10 +107,10 @@ {{/instructions}}
-
{{percent_checks_pass}}%
+
{{^total_checks}}Skipped{{/total_checks}}{{#total_checks}}{{percent_checks_pass}}%{{/total_checks}}

- {{#status}}Pass{{/status}}{{^status}}Fail{{/status}} + {{^total_checks}}Skipped{{/total_checks}}{{#total_checks}}{{#status}}Pass{{/status}}{{^status}}Fail{{/status}}{{/total_checks}} Checks passed: {{total_checks_pass}} / {{total_checks}} @@ -140,11 +140,17 @@

    {{#requirements}} -
  1. +
  2. {{description}} + {{#total_ckecks}} + + + +
    + {{/total_ckecks}} {{#total_pass}} diff --git a/src/ifctester/test/test_facet.py b/src/ifctester/test/test_facet.py index 108c2733fd..e23300ee03 100644 --- a/src/ifctester/test/test_facet.py +++ b/src/ifctester/test/test_facet.py @@ -259,9 +259,19 @@ class TestAttribute: facet = Attribute(name="Name", cardinality="prohibited") run("A prohibited facet returns the opposite of a required facet", facet=facet, inst=element, expected=False) facet = Attribute(name="Name", cardinality="optional") - run("An optional facet always passes regardless of outcome 1/2", facet=facet, inst=element, expected=True) + run( + "An optional facet only checks requirements if there is a value to check 1/2", + facet=facet, + inst=element, + expected=True, + ) facet = Attribute(name="Rabbit", cardinality="optional") - run("An optional facet always passes regardless of outcome 2/2", facet=facet, inst=element, expected=True) + run( + "An optional facet only checks requirements if there is a value to check 2/2", + facet=facet, + inst=element, + expected=True, + ) ifc = ifcopenshell.file() facet = Attribute(name="Name") @@ -750,9 +760,19 @@ class TestClassification: facet = Classification(system="Foobar", cardinality="prohibited") run("A prohibited facet returns the opposite of a required facet", facet=facet, inst=element1, expected=False) facet = Classification(system="Foobar", cardinality="optional") - run("An optional facet always passes regardless of outcome 1/2", facet=facet, inst=element0, expected=True) + run( + "An optional facet only checks requirements if there is a value to check 1/2", + facet=facet, + inst=element0, + expected=True, + ) facet = Classification(system="Foobar", cardinality="optional") - run("An optional facet always passes regardless of outcome 2/2", facet=facet, inst=element1, expected=True) + run( + "An optional facet only checks requirements if there is a value to check 2/2", + facet=facet, + inst=element1, + expected=True, + ) facet = Classification(system="Foobar", value="1") run( @@ -887,9 +907,19 @@ class TestProperty: facet = Property(propertySet="Foo_Bar", baseName="Foo", dataType="IFCLABEL", cardinality="prohibited") run("A prohibited facet returns the opposite of a required facet", facet=facet, inst=element, expected=False) facet = Property(propertySet="Foo_Bar", baseName="Foo", dataType="IFCLABEL", cardinality="optional") - run("An optional facet always passes regardless of outcome 1/2", facet=facet, inst=element, expected=True) + run( + "An optional facet only checks requirements if there is a value to check 1/2", + facet=facet, + inst=element, + expected=True, + ) facet = Property(propertySet="Foo_Bar", baseName="Bar", dataType="IFCLABEL", cardinality="optional") - run("An optional facet always passes regardless of outcome 2/2", facet=facet, inst=element, expected=True) + run( + "An optional facet only checks requirements if there is a value to check 2/2", + facet=facet, + inst=element, + expected=True, + ) ifcopenshell.api.run("pset.edit_pset", ifc, pset=pset, properties={"Foo": ""}) facet = Property(propertySet="Foo_Bar", baseName="Foo", dataType="IFCLOGICAL") @@ -1278,9 +1308,19 @@ class TestMaterial: facet = Material(cardinality="prohibited") run("A prohibited facet returns the opposite of a required facet", facet=facet, inst=element, expected=False) facet = Material(cardinality="optional") - run("An optional facet always passes regardless of outcome 1/2", facet=facet, inst=element, expected=True) + run( + "An optional facet only checks requirements if there is a value to check 1/2", + facet=facet, + inst=element, + expected=True, + ) facet = Material(value="Foo", cardinality="optional") - run("An optional facet always passes regardless of outcome 1/2", facet=facet, inst=element, expected=True) + run( + "An optional facet only checks requirements if there is a value to check 1/2", + facet=facet, + inst=element, + expected=False, + ) ifc = ifcopenshell.file() facet = Material(value="Foo") diff --git a/src/ifctester/test/test_ids.py b/src/ifctester/test/test_ids.py index 0278ded943..6099e1bcea 100644 --- a/src/ifctester/test/test_ids.py +++ b/src/ifctester/test/test_ids.py @@ -51,7 +51,7 @@ def run( class TestIds: def test_failing_on_opening_invalid_ids_data(self): - with pytest.raises(xmlschema.validators.exceptions.XMLSchemaValidationError): + with pytest.raises(ids.IdsXmlValidationError): ids.open("""""") def test_create_an_ids_with_minimal_information(self): diff --git a/src/pyodide/demo-app/CNAME b/src/pyodide/demo-app/CNAME new file mode 100644 index 0000000000..17459c2a49 --- /dev/null +++ b/src/pyodide/demo-app/CNAME @@ -0,0 +1 @@ +wasm.ifcopenshell.org \ No newline at end of file diff --git a/src/pyodide/demo-app/README.md b/src/pyodide/demo-app/README.md new file mode 100644 index 0000000000..1b5681e04c --- /dev/null +++ b/src/pyodide/demo-app/README.md @@ -0,0 +1,65 @@ +# IfcOpenShell WASM + +Live version: http://wasm.ifcopenshell.org + +This is a technological preview of an IfcOpenShell WASM python module for use in [pyodide](https://pyodide.org/en/stable/). It uses [emscripten](https://emscripten.org/) to compile the C/C++ (of the python interpreter, IfcOpenShell and its dependencies respectively) into a WebAssembly (WASM) module. WebAssembly can be used in the browser or other JavaScript runtimes such as NodeJS. + +## Demo + +#### Draw polygonal walls and place door and windows by using the tool panel on the left + +![](polygonal_walls.png) + +#### Open existing .ifc models and improve accessibility by adding doors + +![](fzk_improved_accessibility.png) + +#### Save .ifc models and import in any compatible tool + +![](fzk_improved_accessibility_bimvision.png) + +## FAQ + +- *Is this some half-baked attempt to compete with Bonsai?* + + By all means no. And, actually, it's the same code under the hood, namely the IfcOpenShell API calls such as `root.create_entity`, `geometry.add_wall_representation` and `geometry.add_window_representation`. + +## Approach + +This approach of supporting IFC within the browser is rather heavy as it depends on a python interpreter loading the wasm-compiled monolithic IfcOpenShell wheel on demand. But nevertheless, we see this as the most powerful approach of enabling IfcOpenShell in the browser. + +Over time, Python code has surpassed C++ as the predominant language in IfcOpenShell and has become the defacto standard for the IfcOpenShell API. We wouldn't want browser-based usage to require duplicating this powerful set of APIs. + +Wrapping the full IfcOpenShell-python module results in a feature rich module, that makes for example, IFC validation, visualization, but also **authoring** a breeze because any pre-existing pure python module can be leveraged. + +## Technical usage + +### Pyodide proxy objects + +Allows direct usage of python objects in JavaScript code. + +```js +let ifcopenshell_geom = pyodide.pyimport('ifcopenshell.geom'); +let s = ifcopenshell_geom.settings(); +s.set('weld-vertices', false); +``` + +### Python code evaluation by Pyodide + +Allows running actual python code with the ability for JavaScript to easily reflect on global variables. + +```js +pyodide.runPython(` +from context import Context +import numpy as np +modelObject = Context()`); +``` + +### In-line python code using PyScript + +Allows running inline python code in HTML script tags + +## Limitations + +- Performance of loading and running code is not optimal. +- C++ exception handling in the IfcOpenShell dependencies seems to cause issues in WASM, because the OpenCASCADE failures do not inherit from `std::exception` diff --git a/src/pyodide/demo-app/context.py b/src/pyodide/demo-app/context.py new file mode 100644 index 0000000000..3ad8b45464 --- /dev/null +++ b/src/pyodide/demo-app/context.py @@ -0,0 +1,339 @@ +import ifcopenshell +import ifcopenshell.geom +import ifcopenshell.api +import ifcopenshell.util.unit + +import numpy as np + +import propertygroups + +O = 0.0, 0.0, 0.0 +X = 1.0, 0.0, 0.0 +Y = 0.0, 1.0, 0.0 +Z = 0.0, 0.0, 1.0 + + +class Context: + model = None + body = None + storey = None + + def __init__(self): + self._create_empty_model() + + def clear(self): + self._create_empty_model() + + def open(self, file_content): + self.model = ifcopenshell.file.from_string(file_content) + body = [ctx for ctx in self.model.by_type('IfcGeometricRepresentationContext') if ctx.ContextIdentifier == 'Body'] + if body: + self.body = body[0] + else: + context = ifcopenshell.api.run("context.add_context", self.model, context_type="Model") + self.body = ifcopenshell.api.run( + "context.add_context", + self.model, + context_type="Model", + context_identifier="Body", + target_view="MODEL_VIEW", + parent=context, + ) + axis = [ctx for ctx in self.model.by_type('IfcGeometricRepresentationContext') if ctx.ContextIdentifier == 'Axis'] + if body: + self.axis = axis[0] + else: + context = ifcopenshell.api.run("context.add_context", self.model, context_type="Model") + self.axis = ifcopenshell.api.run( + "context.add_context", + self.model, + context_type="Model", + context_identifier="Axis", + target_view="GRAPH_VIEW", + parent=context, + ) + self.storey = self.model.by_type('IfcBuildingStorey')[0] + + def _create_empty_model(self): + # Create a blank model + self.model = ifcopenshell.file() + # All projects must have one IFC Project element + project = ifcopenshell.api.run( + "root.create_entity", self.model, ifc_class="IfcProject", name="My Project" + ) + # Geometry is optional in IFC, but because we want to use geometry in this example, let's define units + # Assigning without arguments defaults to metric units + ifcopenshell.api.run("unit.assign_unit", self.model) + # Let's create a modeling geometry context, so we can store 3D geometry (note: IFC supports 2D too!) + context = ifcopenshell.api.run("context.add_context", self.model, context_type="Model") + # In particular, in this example we want to store the 3D "body" geometry of objects, i.e. the body shape + self.body = ifcopenshell.api.run( + "context.add_context", + self.model, + context_type="Model", + context_identifier="Body", + target_view="MODEL_VIEW", + parent=context, + ) + self.axis = ifcopenshell.api.run( + "context.add_context", + self.model, + context_type="Model", + context_identifier="Axis", + target_view="GRAPH_VIEW", + parent=context, + ) + # Create a site, building, and storey. Many hierarchies are possible. + site = ifcopenshell.api.run( + "root.create_entity", self.model, ifc_class="IfcSite", name="My Site" + ) + building = ifcopenshell.api.run( + "root.create_entity", self.model, ifc_class="IfcBuilding", name="Building A" + ) + self.storey = ifcopenshell.api.run( + "root.create_entity", + self.model, + ifc_class="IfcBuildingStorey", + name="Ground Floor", + ) + # Since the site is our top level location, assign it to the project + # Then place our building on the site, and our storey in the building + ifcopenshell.api.run( + "aggregate.assign_object", + self.model, + relating_object=project, + products=[site], + ) + ifcopenshell.api.run( + "aggregate.assign_object", + self.model, + relating_object=site, + products=[building], + ) + ifcopenshell.api.run( + "aggregate.assign_object", + self.model, + relating_object=building, + products=[self.storey], + ) + + def create_2pt_wall( + self, p1, p2, elevation, height, thickness, container, wall_type=None + ): + p1 = np.array([p1[0], p1[1]]) + p2 = np.array([p2[0], p2[1]]) + + wall = ifcopenshell.api.run("root.create_entity", self.model, ifc_class="IfcWall") + length = float(np.linalg.norm(p2 - p1)) + representation = ifcopenshell.api.run( + "geometry.add_wall_representation", + self.model, + context=self.body, + length=length, + height=height, + thickness=thickness, + ) + ifcopenshell.api.run( + "geometry.assign_representation", + self.model, + product=wall, + representation=representation, + ) + representation = ifcopenshell.api.run( + "geometry.add_axis_representation", + self.model, + context=self.axis, + axis=[(0.0, 0.0), (length, 0.0)], + ) + ifcopenshell.api.run( + "geometry.assign_representation", + self.model, + product=wall, + representation=representation, + ) + v = p2 - p1 + v = np.divide(v, float(np.linalg.norm(v)), casting="unsafe") + matrix = np.array( + [ + [v[0], -v[1], 0, p1[0]], + [v[1], v[0], 0, p1[1]], + [0, 0, 1, elevation], + [0, 0, 0, 1], + ] + ) + ifcopenshell.api.run("geometry.edit_object_placement", self.model, product=wall, matrix=matrix) + ifcopenshell.api.run( + "spatial.assign_container", + self.model, + relating_structure=container, + products=[wall], + ) + if wall_type: + ifcopenshell.api.run( + "type.assign_type", + self.model, + related_object=wall, + relating_type=wall_type, + ) + + return wall + + def get_element(self, guid): + return self.model[guid] + + def get_model(self): + return self.model + + def create_fill(self, ty, pt, wall): + if isinstance(wall, str): + wall = self.model[wall] + if not wall.is_a('IfcWall'): + raise ValueError("Only 'wall' hosts are supported") + if ty == 'door': + props = propertygroups.BIMDoorProperties() + elif ty == 'window': + props = propertygroups.BIMWindowProperties() + else: + raise ValueError("Only 'door' or 'window' fills are supported") + si_conversion = ifcopenshell.util.unit.calculate_unit_scale(self.model) + body = ifcopenshell.util.representation.get_context( + self.model, "Model", "Body", "MODEL_VIEW" + ) + representation_data = props.to_dict(si_conversion=si_conversion) + representation_data["context"] = body + door_representation = ifcopenshell.api.run( + f"geometry.add_{ty}_representation", self.model, **representation_data + ) + door = ifcopenshell.api.run( + "root.create_entity", self.model, ifc_class=f"ifc{ty}" + ) + door.OverallWidth = props.overall_width / si_conversion + door.OverallHeight = props.overall_height / si_conversion + ifcopenshell.api.run( + "geometry.assign_representation", + self.model, + product=door, + representation=door_representation, + ) + ifcopenshell.api.run( + "spatial.assign_container", + self.model, + relating_structure=self.storey, + products=[door], + ) + + r = [ + r + for r in wall.Representation.Representations + if r.RepresentationIdentifier == "Axis" + ] + if not r: + raise ValueError("Axis representation is needed") + r = r[0] + axis_geometry = ifcopenshell.geom.create_shape( + ifcopenshell.geom.settings( + DIMENSIONALITY=ifcopenshell.ifcopenshell_wrapper.CURVES_SURFACES_AND_SOLIDS, + USE_WORLD_COORDS=True, + ), + wall, + r, + ) + vs = np.array(axis_geometry.geometry.verts).reshape((-1, 3)) + es = np.array(axis_geometry.geometry.edges).reshape((-1, 2)) + A, B = vs[es[0]] + v = B - A + P = np.zeros(3) + P[0 : len(pt)] = pt + AP = P - A + AP_dot_v = np.dot(AP, v) + v_dot_v = np.dot(v, v) + t = AP_dot_v / v_dot_v * np.linalg.norm(v) / si_conversion + + opening = ifcopenshell.api.run( + "root.create_entity", + self.model, + ifc_class="IfcOpeningElement", + predefined_type="OPENING", + name="Opening", + ) + + position_3d = None + if self.model.schema == "IFC2X3": + position_3d = self.model.createIfcAxis2Placement2D( + self.model.createIfcCartesianPoint([0.0, 0.0, 0.0]) + ) + position_2d = self.model.createIfcAxis2Placement2D( + self.model.createIfcCartesianPoint([door.OverallWidth / 2.0, 0.0]) + ) + + opening.Representation = self.model.createIfcProductDefinitionShape( + Representations=[ + self.model.createIfcRepresentation( + body, + "Body", + "SweptSolid", + Items=[ + self.model.createIfcExtrudedAreaSolid( + self.model.createIfcRectangleProfileDef( + "AREA", + None, + position_2d, + door.OverallWidth, + 1.2 / si_conversion, + ), + position_3d, + self.model.createIfcDirection((0.0, 0.0, 1.0)), + door.OverallHeight, + ) + ], + ) + ] + ) + + ifcopenshell.api.run( + "void.add_opening", self.model, opening=opening, element=wall + ) + ifcopenshell.api.run( + "void.add_filling", self.model, opening=opening, element=door + ) + + z_offsets = { + 'door': 0, + 'window': 1 + } + opening.ObjectPlacement = self.model.createIfcLocalPlacement( + wall.ObjectPlacement, + self.model.createIfcAxis2Placement3D( + self.model.createIfcCartesianPoint((float(t), 0.0, z_offsets[ty] / si_conversion)) + ), + ) + + door.ObjectPlacement = self.model.createIfcLocalPlacement( + opening.ObjectPlacement, + self.model.createIfcAxis2Placement3D( + self.model.createIfcCartesianPoint((0.0, 0.0, 0.0)) + ), + ) + + return door + + def to_obj_file(self, fn): + st = ifcopenshell.geom.settings(USE_WORLD_COORDS=True, WELD_VERTICES=False) + it = ifcopenshell.geom.iterator(st, self.model, exclude=("IfcOpeningElement",)) + sr = ifcopenshell.geom.serializers.obj( + fn, fn + ".mtl", st, ifcopenshell.geom.serializer_settings() + ) + if it.initialize(): + for el in ifcopenshell.geom.consume_iterator(it): + sr.write(el) + sr.finalize() + + +if __name__ == "__main__": + m = Context() + w1 = m.create_2pt_wall((0.0, 0.0), (4.0, 0.0), 0.0, 3.0, 0.1, m.storey) + w2 = m.create_2pt_wall((1.0, 3.0), (1.0, 0.0), 0.0, 3.0, 0.1, m.storey) + m.create_fill('door', [2, 0.0], w1) + m.create_fill('window', [1, 1.5], w2) + m.model.write("out.ifc") + m.to_obj_file("out.obj") diff --git a/src/pyodide/demo-app/fzk_improved_accessibility.png b/src/pyodide/demo-app/fzk_improved_accessibility.png new file mode 100644 index 0000000000..8179f532cd Binary files /dev/null and b/src/pyodide/demo-app/fzk_improved_accessibility.png differ diff --git a/src/pyodide/demo-app/fzk_improved_accessibility_bimvision.png b/src/pyodide/demo-app/fzk_improved_accessibility_bimvision.png new file mode 100644 index 0000000000..18335c853e Binary files /dev/null and b/src/pyodide/demo-app/fzk_improved_accessibility_bimvision.png differ diff --git a/src/pyodide/demo-app/index.html b/src/pyodide/demo-app/index.html new file mode 100644 index 0000000000..555b83bb99 --- /dev/null +++ b/src/pyodide/demo-app/index.html @@ -0,0 +1,405 @@ + + + + + + + + + + + +
    +
      +
    • arrow_selector_tool
    • +
    • diagonal_line
    • +
    • door_open
    • +
    • window
    • +
    +
    +
    + +
    +
    Loading...
    +
    +
    +
    + + + + + + + + \ No newline at end of file diff --git a/src/pyodide/demo-app/polygonal_walls.png b/src/pyodide/demo-app/polygonal_walls.png new file mode 100644 index 0000000000..ba99e4e961 Binary files /dev/null and b/src/pyodide/demo-app/polygonal_walls.png differ diff --git a/src/pyodide/demo-app/propertygroups.py b/src/pyodide/demo-app/propertygroups.py new file mode 100644 index 0000000000..4ba73b8efb --- /dev/null +++ b/src/pyodide/demo-app/propertygroups.py @@ -0,0 +1,152 @@ +from dataclasses import dataclass, field + +# From: src\bonsai\bonsai\bim\module\model\prop.py +# Adapted to use dataclasses instead of bpy props + +@dataclass +class BIMWindowProperties: + non_si_units_props = ("is_editing", "window_type") + window_types = ( + ("SINGLE_PANEL", "SINGLE_PANEL", ""), + ("DOUBLE_PANEL_HORIZONTAL", "DOUBLE_PANEL_HORIZONTAL", ""), + ("DOUBLE_PANEL_VERTICAL", "DOUBLE_PANEL_VERTICAL", ""), + ("TRIPLE_PANEL_BOTTOM", "TRIPLE_PANEL_BOTTOM", ""), + ("TRIPLE_PANEL_TOP", "TRIPLE_PANEL_TOP", ""), + ("TRIPLE_PANEL_LEFT", "TRIPLE_PANEL_LEFT", ""), + ("TRIPLE_PANEL_RIGHT", "TRIPLE_PANEL_RIGHT", ""), + ("TRIPLE_PANEL_HORIZONTAL", "TRIPLE_PANEL_HORIZONTAL", ""), + ("TRIPLE_PANEL_VERTICAL", "TRIPLE_PANEL_VERTICAL", ""), + ) + + # number of panels and default mullion/transom values + window_types_panels = { + "SINGLE_PANEL": (1, ((0, 0 ), (0, 0 ))), + "DOUBLE_PANEL_HORIZONTAL": (2, ((0, 0 ), (0.45, 0 ))), + "DOUBLE_PANEL_VERTICAL": (2, ((0.3, 0 ), (0, 0 ))), + "TRIPLE_PANEL_BOTTOM": (3, ((0.3, 0 ), (0.45, 0 ))), + "TRIPLE_PANEL_TOP": (3, ((0.3, 0 ), (0.45, 0 ))), + "TRIPLE_PANEL_LEFT": (3, ((0.3, 0 ), (0.45, 0 ))), + "TRIPLE_PANEL_RIGHT": (3, ((0.3, 0 ), (0.45, 0 ))), + "TRIPLE_PANEL_HORIZONTAL": (3, ((0, 0 ), (0.3, 0.6))), + "TRIPLE_PANEL_VERTICAL": (3, ((0.2, 0.4), (0, 0 ))), + } + + is_editing: bool = False + window_type: str = "SINGLE_PANEL" + overall_height: float = 0.9 + overall_width: float = 0.6 + + # lining properties + lining_depth: float = 0.050 + lining_thickness: float = 0.050 + lining_offset: float = 0.050 + lining_to_panel_offset_x: float = 0.025 + lining_to_panel_offset_y: float = 0.025 + mullion_thickness: float = 0.050 + first_mullion_offset: float = 0.3 + second_mullion_offset: float = 0.45 + transom_thickness: float = 0.050 + first_transom_offset: float = 0.3 + second_transom_offset: float = 0.6 + + # panel properties + frame_depth: list = field(default_factory = lambda: [0.035] * 3) + frame_thickness: list = field(default_factory = lambda: [0.035] * 3) + + def to_dict(self, si_conversion=1.): + di = { + "partition_type": self.window_type, + "overall_height": self.overall_height / si_conversion, + "overall_width": self.overall_width / si_conversion, + "lining_properties": { + "LiningDepth": self.lining_depth / si_conversion, + "LiningThickness": self.lining_thickness / si_conversion, + "LiningOffset": self.lining_offset / si_conversion, + "LiningToPanelOffsetX": self.lining_to_panel_offset_x / si_conversion, + "LiningToPanelOffsetY": self.lining_to_panel_offset_y / si_conversion, + "MullionThickness": self.mullion_thickness / si_conversion, + "FirstMullionOffset": self.first_mullion_offset / si_conversion, + "SecondMullionOffset": self.second_mullion_offset / si_conversion, + "TransomThickness": self.transom_thickness / si_conversion, + "FirstTransomOffset": self.first_transom_offset / si_conversion, + "SecondTransomOffset": self.second_transom_offset / si_conversion, + }, + "panel_properties": [], + } + number_of_panels, panels_data = self.window_types_panels[self.window_type] + for panel_i in range(number_of_panels): + panel_data = { + "FrameDepth": self.frame_depth[panel_i] / si_conversion, + "FrameThickness": self.frame_thickness[panel_i] / si_conversion, + } + di["panel_properties"].append(panel_data) + return di + + +@dataclass +class BIMDoorProperties: + non_si_units_props = ("is_editing", "door_type", "panel_width_ratio") + door_types = ( + ("SINGLE_SWING_LEFT", "SINGLE_SWING_LEFT", ""), + ("SINGLE_SWING_RIGHT", "SINGLE_SWING_RIGHT", ""), + ("DOUBLE_SWING_LEFT", "DOUBLE_SWING_LEFT", ""), + ("DOUBLE_SWING_RIGHT", "DOUBLE_SWING_RIGHT", ""), + ("DOUBLE_DOOR_SINGLE_SWING", "DOUBLE_DOOR_SINGLE_SWING", ""), + ("SLIDING_TO_LEFT", "SLIDING_TO_LEFT", ""), + ("SLIDING_TO_RIGHT", "SLIDING_TO_RIGHT", ""), + ("DOUBLE_DOOR_SLIDING", "DOUBLE_DOOR_SLIDING", ""), + ) + + is_editing: bool = False + door_type: str = "SINGLE_SWING_LEFT" + overall_height: float = 2.0 + overall_width: float = 0.9 + + # lining properties + lining_depth: float = 0.050 + lining_thickness: float = 0.050 + lining_offset: float = 0.0 + lining_to_panel_offset_x: float = 0.025 + lining_to_panel_offset_y: float = 0.025 + transom_thickness: float = 0.000 + transom_offset: float = 1.525 + + casing_thickness: float = 0.075 + casing_depth: float = 0.005 + + threshold_thickness: float = 0.025 + threshold_depth: float = 0.1 + threshold_offset: float = 0.000 + + # panel properties + panel_depth: float = 0.035 + panel_width_ratio: float = 1.0 + frame_thickness: float = 0.035 + frame_depth: float = 0.035 + + def to_dict(self, si_conversion=1.): + return { + "operation_type": self.door_type, + "overall_height": self.overall_height / si_conversion, + "overall_width": self.overall_width / si_conversion, + "lining_properties": { + "LiningDepth": self.lining_depth / si_conversion, + "LiningThickness": self.lining_thickness / si_conversion, + "LiningOffset": self.lining_offset / si_conversion, + "LiningToPanelOffsetX": self.lining_to_panel_offset_x / si_conversion, + "LiningToPanelOffsetY": self.lining_to_panel_offset_y / si_conversion, + "TransomThickness": self.transom_thickness / si_conversion, + "TransomOffset": self.transom_offset / si_conversion, + "CasingThickness": self.casing_thickness / si_conversion, + "CasingDepth": self.casing_depth / si_conversion, + "ThresholdThickness": self.threshold_thickness / si_conversion, + "ThresholdDepth": self.threshold_depth / si_conversion, + "ThresholdOffset": self.threshold_offset / si_conversion, + }, + "panel_properties": { + "PanelDepth": self.panel_depth / si_conversion, + "PanelWidth": self.panel_width_ratio, + "FrameDepth": self.frame_depth / si_conversion, + "FrameThickness": self.frame_thickness / si_conversion, + }, + } diff --git a/src/pyodide/demo-app/style.css b/src/pyodide/demo-app/style.css new file mode 100644 index 0000000000..8746ab660e --- /dev/null +++ b/src/pyodide/demo-app/style.css @@ -0,0 +1,93 @@ +* { + padding: 0; + margin: 0; + box-sizing: border-box; +} + +body { + width: 100%; + height: 100vh; + + display: grid; + grid-template-columns: 80px 1fr; + grid-template-rows: 80px 1fr; + grid-template-areas: + "menu menu" + "toolbar main"; +} +.msg { + padding-top: 25vh; + text-align: center; +} +.msg { + display: none; +} +body.loading .msg { + display: block; +} +form { + display: inline-block; + padding: 1vw 5vw; + border: solid 1px #eee; + border-radius: 1vw; +} +form div { + margin-bottom: 3vw; +} +canvas { + display: block; +} +#status1 { + font-size: 150%; + font-weight: bold; +} +#branding { + position: absolute; + top: 16px; + right: 16px; + text-align: right; +} +input[type='button'] { + padding: 8px 64px; + margin-top: 3vw; +} +ul { + background: #fafafa; +} +ul, ul li { + padding: 0; + margin: 0; +} +ul li { + display: inline-block; + border: solid 1px #ccc; + cursor: pointer; + width: 64px; + height: 64px; + padding: 16px; + border-radius: 8px; + background: #eee; + font-size: 32px !important; + margin: 8px; +} +.tools li { + display: block; + float: left; +} +ul li:hover { + border-color: #aaa; + background: #ddd; +} +.menu { + grid-area: menu; +} +.toolbar { + grid-area: toolbar; +} +.toolbar li.active { + border-color: #888; + background: #ccc; +} +.main { + grid-area: main; +} \ No newline at end of file diff --git a/src/pyodide/demo-app/wheels b/src/pyodide/demo-app/wheels new file mode 160000 index 0000000000..33b437e5fd --- /dev/null +++ b/src/pyodide/demo-app/wheels @@ -0,0 +1 @@ +Subproject commit 33b437e5fd5425e606f34aff602c42034ff5e6dc