From 33f4461a87767abb9ea125165313a325f3ab336d Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Mon, 18 Oct 2021 14:15:16 +1100 Subject: [PATCH] New feature to resize / squash / stretch objects to their storey height. Useful for remodeling IFCs for construction simulations. --- .../blenderbim/bim/module/misc/__init__.py | 1 + .../blenderbim/bim/module/misc/operator.py | 16 ++ .../blenderbim/bim/module/misc/ui.py | 6 +- src/blenderbim/blenderbim/core/misc.py | 30 ++++ src/blenderbim/blenderbim/core/tool.py | 11 ++ src/blenderbim/blenderbim/tool/__init__.py | 1 + src/blenderbim/blenderbim/tool/container.py | 4 +- src/blenderbim/blenderbim/tool/misc.py | 91 +++++++++++ src/blenderbim/test/bim/feature/misc.feature | 18 ++- src/blenderbim/test/core/bootstrap.py | 7 + src/blenderbim/test/core/test_misc.py | 41 +++++ src/blenderbim/test/tool/test_misc.py | 151 ++++++++++++++++++ 12 files changed, 369 insertions(+), 8 deletions(-) create mode 100644 src/blenderbim/blenderbim/core/misc.py create mode 100644 src/blenderbim/blenderbim/tool/misc.py create mode 100644 src/blenderbim/test/core/test_misc.py create mode 100644 src/blenderbim/test/tool/test_misc.py diff --git a/src/blenderbim/blenderbim/bim/module/misc/__init__.py b/src/blenderbim/blenderbim/bim/module/misc/__init__.py index 5e226b3884..687e45cfee 100644 --- a/src/blenderbim/blenderbim/bim/module/misc/__init__.py +++ b/src/blenderbim/blenderbim/bim/module/misc/__init__.py @@ -20,6 +20,7 @@ import bpy from . import ui, prop, operator classes = ( + operator.ResizeToStorey, operator.SetOverrideColour, operator.SetViewportShadowFromSun, operator.SnapSpacesTogether, diff --git a/src/blenderbim/blenderbim/bim/module/misc/operator.py b/src/blenderbim/blenderbim/bim/module/misc/operator.py index 25e8c5c60f..1f4c39f3e5 100644 --- a/src/blenderbim/blenderbim/bim/module/misc/operator.py +++ b/src/blenderbim/blenderbim/bim/module/misc/operator.py @@ -18,6 +18,8 @@ import bpy import blenderbim.bim.handler +import blenderbim.tool as tool +import blenderbim.core.misc as core from blenderbim.bim.ifc import IfcStore from mathutils import Vector, Matrix, Euler @@ -109,3 +111,17 @@ class SnapSpacesTogether(bpy.types.Operator): for v in polygon.vertices: obj.data.vertices[v].co += offset return {"FINISHED"} + + +class ResizeToStorey(bpy.types.Operator, Operator): + bl_idname = "bim.resize_to_storey" + bl_label = "Resize To Storey" + bl_options = {"REGISTER", "UNDO"} + + @classmethod + def poll(cls, context): + return context.selected_objects + + def _execute(self, context): + for obj in context.selected_objects: + core.resize_to_storey(tool.Misc, obj=obj) diff --git a/src/blenderbim/blenderbim/bim/module/misc/ui.py b/src/blenderbim/blenderbim/bim/module/misc/ui.py index 557667aac4..90bd3d3d56 100644 --- a/src/blenderbim/blenderbim/bim/module/misc/ui.py +++ b/src/blenderbim/blenderbim/bim/module/misc/ui.py @@ -34,7 +34,9 @@ class BIM_PT_misc_utilities(bpy.types.Panel): row = layout.split(factor=0.2, align=True) row.prop(props, "override_colour", text="") row.operator("bim.set_override_colour") - row = layout.row(align=True) + row = layout.row() row.operator("bim.set_viewport_shadow_from_sun") - row = layout.row(align=True) + row = layout.row() row.operator("bim.snap_spaces_together") + row = layout.row() + row.operator("bim.resize_to_storey") diff --git a/src/blenderbim/blenderbim/core/misc.py b/src/blenderbim/blenderbim/core/misc.py new file mode 100644 index 0000000000..2ded3a3099 --- /dev/null +++ b/src/blenderbim/blenderbim/core/misc.py @@ -0,0 +1,30 @@ +# BlenderBIM Add-on - OpenBIM Blender Add-on +# Copyright (C) 2021 Dion Moult +# +# This file is part of BlenderBIM Add-on. +# +# BlenderBIM Add-on is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# BlenderBIM Add-on is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with BlenderBIM Add-on. If not, see . + + +def resize_to_storey(misc, obj=None): + storey = misc.get_object_storey(obj) + if not storey: + return + height = misc.get_storey_height_in_si(storey) + if not height: + return + misc.set_object_origin_to_bottom(obj) + misc.move_object_to_elevation(obj, misc.get_storey_elevation_in_si(storey)) + misc.scale_object_to_height(obj, height) + misc.mark_object_as_edited(obj) diff --git a/src/blenderbim/blenderbim/core/tool.py b/src/blenderbim/blenderbim/core/tool.py index 2a6d19e7f8..583b93b696 100644 --- a/src/blenderbim/blenderbim/core/tool.py +++ b/src/blenderbim/blenderbim/core/tool.py @@ -77,6 +77,17 @@ class Material: def unlink(cls, obj): pass +@interface +class Misc: + def get_object_storey(cls, obj): pass + def get_storey_elevation_in_si(cls, storey): pass + def get_storey_height_in_si(cls, storey): pass + def mark_object_as_edited(cls, obj): pass + def move_object_to_elevation(cls, obj, elevation): pass + def scale_object_to_height(cls, obj, height): pass + def set_object_origin_to_bottom(cls, obj): pass + + @interface class Owner: def add_address_attribute(cls, name): pass diff --git a/src/blenderbim/blenderbim/tool/__init__.py b/src/blenderbim/blenderbim/tool/__init__.py index 94c387d29f..a6a4f85086 100644 --- a/src/blenderbim/blenderbim/tool/__init__.py +++ b/src/blenderbim/blenderbim/tool/__init__.py @@ -23,6 +23,7 @@ from blenderbim.tool.container import Container from blenderbim.tool.context import Context from blenderbim.tool.ifc import Ifc from blenderbim.tool.material import Material +from blenderbim.tool.misc import Misc from blenderbim.tool.owner import Owner from blenderbim.tool.style import Style from blenderbim.tool.surveyor import Surveyor diff --git a/src/blenderbim/blenderbim/tool/container.py b/src/blenderbim/blenderbim/tool/container.py index 009aab48aa..d651b17226 100644 --- a/src/blenderbim/blenderbim/tool/container.py +++ b/src/blenderbim/blenderbim/tool/container.py @@ -60,9 +60,7 @@ class Container(blenderbim.core.tool.Container): for rel in parent.IsDecomposedBy or []: related_objects = [] for element in rel.RelatedObjects: - related_objects.append( - (element, ifcopenshell.util.placement.get_storey_elevation(element)) - ) + related_objects.append((element, ifcopenshell.util.placement.get_storey_elevation(element))) related_objects = sorted(related_objects, key=lambda e: e[1]) for element in related_objects: element = element[0] diff --git a/src/blenderbim/blenderbim/tool/misc.py b/src/blenderbim/blenderbim/tool/misc.py new file mode 100644 index 0000000000..fe37f1080c --- /dev/null +++ b/src/blenderbim/blenderbim/tool/misc.py @@ -0,0 +1,91 @@ +# BlenderBIM Add-on - OpenBIM Blender Add-on +# Copyright (C) 2021 Dion Moult +# +# This file is part of BlenderBIM Add-on. +# +# BlenderBIM Add-on is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# BlenderBIM Add-on is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with BlenderBIM Add-on. If not, see . + +import bpy +import ifcopenshell +import blenderbim.core.tool +import blenderbim.tool as tool +from mathutils import Vector, Matrix +from blenderbim.bim.ifc import IfcStore + + +class Misc(blenderbim.core.tool.Misc): + @classmethod + def get_object_storey(cls, obj): + storey = ifcopenshell.util.element.get_container(tool.Ifc.get_entity(obj)) + if storey and storey.is_a("IfcBuildingStorey"): + return storey + + @classmethod + def get_storey_elevation_in_si(cls, storey): + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + elevation = ifcopenshell.util.placement.get_storey_elevation(storey) + return elevation * unit_scale + + @classmethod + def get_storey_height_in_si(cls, storey): + building = ifcopenshell.util.element.get_aggregate(storey) + related_objects = [] + for rel in building.IsDecomposedBy: + if rel.is_a("IfcRelAggregates"): + for element in rel.RelatedObjects: + if element.is_a("IfcBuildingStorey"): + related_objects.append((element, ifcopenshell.util.placement.get_storey_elevation(element))) + related_objects = sorted(related_objects, key=lambda e: e[1]) + storey_elevation = None + next_storey_elevation = None + for related_object in related_objects: + if related_object[0] == storey: + storey_elevation = related_object[1] + elif storey_elevation is not None: + next_storey_elevation = related_object[1] + break + if next_storey_elevation: + unit_scale = ifcopenshell.util.unit.calculate_unit_scale(tool.Ifc.get()) + return (next_storey_elevation - storey_elevation) * unit_scale + + @classmethod + def set_object_origin_to_bottom(cls, obj): + absolute_bound_box = [obj.matrix_world @ Vector(c) for c in obj.bound_box] + min_z = min([c[2] for c in absolute_bound_box]) + new_origin = obj.matrix_world.translation.copy() + new_origin[2] = min_z + obj.data.transform( + Matrix.Translation( + (obj.matrix_world.inverted().to_quaternion() @ (obj.matrix_world.translation - new_origin)) + ) + ) + obj.matrix_world.translation = new_origin + + @classmethod + def move_object_to_elevation(cls, obj, elevation): + obj.matrix_world.translation[2] = elevation + + @classmethod + def scale_object_to_height(cls, obj, height): + absolute_bound_box = [obj.matrix_world @ Vector(c) for c in obj.bound_box] + max_z = max([c[2] for c in absolute_bound_box]) + min_z = min([c[2] for c in absolute_bound_box]) + current_absolute_height = max_z - min_z + scale_factor = height / current_absolute_height + obj.matrix_world @= Matrix.Scale(scale_factor, 4, obj.matrix_world.to_quaternion() @ Vector((0, 0, 1))) + bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) + + @classmethod + def mark_object_as_edited(cls, obj): + IfcStore.edited_objs.add(obj) diff --git a/src/blenderbim/test/bim/feature/misc.feature b/src/blenderbim/test/bim/feature/misc.feature index 900a4563f8..56f55c84cc 100644 --- a/src/blenderbim/test/bim/feature/misc.feature +++ b/src/blenderbim/test/bim/feature/misc.feature @@ -2,24 +2,36 @@ Feature: Misc Scenario: Set override colour - Given an empty IFC project + Given an empty Blender session And I add a cube And the object "Cube" is selected When I press "bim.set_override_colour" Then nothing happens Scenario: Set viewport shadow from sun - Given an empty IFC project + Given an empty Blender session And I add a sun And the object "Sun" is selected When I press "bim.set_viewport_shadow_from_sun" Then nothing happens Scenario: Snap spaces together - Given an empty IFC project + Given an empty Blender session And I add a cube And I add a cube And the object "Cube" is selected And additionally the object "Cube.001" is selected When I press "bim.snap_spaces_together" Then nothing happens + +Scenario: Resize to storey + Given an empty IFC project + And I add a cube + And the object "Cube" is selected + And I set "scene.BIMRootProperties.ifc_class" to "IfcWall" + And I press "bim.assign_class" + And the object "IfcWall/Cube" is selected + And the variable "storey" is "tool.Ifc.get().by_type('IfcBuildingStorey')[0].id()" + And I press "bim.assign_container(structure={storey})" + When I press "bim.resize_to_storey" + Then nothing happens diff --git a/src/blenderbim/test/core/bootstrap.py b/src/blenderbim/test/core/bootstrap.py index f9e50bfca2..3149e3fddc 100644 --- a/src/blenderbim/test/core/bootstrap.py +++ b/src/blenderbim/test/core/bootstrap.py @@ -56,6 +56,13 @@ def material(): prophet.verify() +@pytest.fixture +def misc(): + prophet = Prophecy(blenderbim.core.tool.Misc) + yield prophet + prophet.verify() + + @pytest.fixture def owner(): prophet = Prophecy(blenderbim.core.tool.Owner) diff --git a/src/blenderbim/test/core/test_misc.py b/src/blenderbim/test/core/test_misc.py new file mode 100644 index 0000000000..30367401aa --- /dev/null +++ b/src/blenderbim/test/core/test_misc.py @@ -0,0 +1,41 @@ +# BlenderBIM Add-on - OpenBIM Blender Add-on +# Copyright (C) 2021 Dion Moult +# +# This file is part of BlenderBIM Add-on. +# +# BlenderBIM Add-on is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# BlenderBIM Add-on is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with BlenderBIM Add-on. If not, see . + +import blenderbim.core.misc as subject +from test.core.bootstrap import misc + + +class TestResizeToStorey: + def test_run(self, misc): + misc.get_object_storey("obj").should_be_called().will_return("storey") + misc.get_storey_elevation_in_si("storey").should_be_called().will_return("elevation") + misc.get_storey_height_in_si("storey").should_be_called().will_return("height") + misc.set_object_origin_to_bottom("obj").should_be_called() + misc.move_object_to_elevation("obj", "elevation").should_be_called() + misc.scale_object_to_height("obj", "height").should_be_called() + misc.mark_object_as_edited("obj").should_be_called() + subject.resize_to_storey(misc, obj="obj") + + def test_doing_nothing_when_the_object_has_no_storey(self, misc): + misc.get_object_storey("obj").should_be_called().will_return(None) + subject.resize_to_storey(misc, obj="obj") + + def test_doing_nothing_when_the_storey_has_no_height(self, misc): + misc.get_object_storey("obj").should_be_called().will_return("storey") + misc.get_storey_height_in_si("storey").should_be_called().will_return(None) + subject.resize_to_storey(misc, obj="obj") diff --git a/src/blenderbim/test/tool/test_misc.py b/src/blenderbim/test/tool/test_misc.py new file mode 100644 index 0000000000..2ff1be9cc9 --- /dev/null +++ b/src/blenderbim/test/tool/test_misc.py @@ -0,0 +1,151 @@ +# BlenderBIM Add-on - OpenBIM Blender Add-on +# Copyright (C) 2021 Dion Moult +# +# This file is part of BlenderBIM Add-on. +# +# BlenderBIM Add-on is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# BlenderBIM Add-on is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with BlenderBIM Add-on. If not, see . + +import bpy +import math +import numpy +import ifcopenshell +import test.bim.bootstrap +import blenderbim.core.tool +import blenderbim.tool as tool +from blenderbim.tool.misc import Misc as subject +from blenderbim.bim.ifc import IfcStore + + +class TestImplementsTool(test.bim.bootstrap.NewFile): + def test_run(self): + assert isinstance(subject(), blenderbim.core.tool.Misc) + + +class TestGetObjectStorey(test.bim.bootstrap.NewFile): + def test_run(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + obj = bpy.data.objects.new("Object", None) + wall = ifc.createIfcWall() + tool.Ifc.link(wall, obj) + storey = ifc.createIfcBuildingStorey() + ifcopenshell.api.run("spatial.assign_container", ifc, product=wall, relating_structure=storey) + assert subject.get_object_storey(obj) == storey + + def test_only_returning_a_building_storey(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + obj = bpy.data.objects.new("Object", None) + wall = ifc.createIfcWall() + tool.Ifc.link(wall, obj) + building = ifc.createIfcBuilding() + ifcopenshell.api.run("spatial.assign_container", ifc, product=wall, relating_structure=building) + assert subject.get_object_storey(obj) is None + + def test_returning_nothing_if_uncontained(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + obj = bpy.data.objects.new("Object", None) + wall = ifc.createIfcWall() + tool.Ifc.link(wall, obj) + assert subject.get_object_storey(obj) is None + + +class TestGetStoreyElevation(test.bim.bootstrap.NewFile): + def test_run(self): + ifc = ifcopenshell.file() + ifc.createIfcProject() + unit = ifcopenshell.api.run("unit.add_si_unit", ifc, prefix="MILLI") + ifcopenshell.api.run("unit.assign_unit", ifc, units=[unit]) + storey = ifc.createIfcBuildingStorey() + storey.Elevation = 3000 + tool.Ifc.set(ifc) + assert subject.get_storey_elevation_in_si(storey) == 3.0 + + +class TestGetStoreyHeight(test.bim.bootstrap.NewFile): + def test_run(self): + ifc = ifcopenshell.file() + ifc.createIfcProject() + unit = ifcopenshell.api.run("unit.add_si_unit", ifc, prefix="MILLI") + ifcopenshell.api.run("unit.assign_unit", ifc, units=[unit]) + tool.Ifc.set(ifc) + building = ifc.createIfcBuilding() + storey = ifc.createIfcBuildingStorey() + storey.Elevation = 3000 + storey2 = ifc.createIfcBuildingStorey() + storey2.Elevation = 5000 + ifcopenshell.api.run("aggregate.assign_object", ifc, product=storey, relating_object=building) + ifcopenshell.api.run("aggregate.assign_object", ifc, product=storey2, relating_object=building) + assert subject.get_storey_height_in_si(storey) == 2.0 + + def test_only_considering_storeys_in_the_same_building(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + building = ifc.createIfcBuilding() + storey = ifc.createIfcBuildingStorey() + storey.Elevation = 3000 + storey2 = ifc.createIfcBuildingStorey() + storey2.Elevation = 5000 + ifcopenshell.api.run("aggregate.assign_object", ifc, product=storey, relating_object=building) + assert subject.get_storey_height_in_si(storey) is None + + def test_returning_none_if_the_storey_height_is_undefined(self): + ifc = ifcopenshell.file() + tool.Ifc.set(ifc) + building = ifc.createIfcBuilding() + storey = ifc.createIfcBuildingStorey() + ifcopenshell.api.run("aggregate.assign_object", ifc, product=storey, relating_object=building) + assert subject.get_storey_height_in_si(storey) is None + + +class TestSetObjectOriginToBottom(test.bim.bootstrap.NewFile): + def test_run(self): + bpy.ops.mesh.primitive_cube_add() + obj = bpy.data.objects.get("Cube") + subject.set_object_origin_to_bottom(obj) + assert list(obj.matrix_world.translation) == [0.0, 0.0, -1.0] + + def test_moving_the_origin_of_a_rotated_object(self): + bpy.ops.mesh.primitive_cube_add() + obj = bpy.data.objects.get("Cube") + obj.rotation_euler[1] = math.pi / 2 + bpy.context.view_layer.update() + subject.set_object_origin_to_bottom(obj) + assert list(obj.matrix_world.translation) == [0.0, 0.0, -1.0] + + +class TestMoveObjectToElevation(test.bim.bootstrap.NewFile): + def test_run(self): + bpy.ops.mesh.primitive_cube_add() + obj = bpy.data.objects.get("Cube") + subject.move_object_to_elevation(obj, 3) + assert list(obj.matrix_world.translation) == [0.0, 0.0, 3.0] + + +class TestScaleObjectToHeight(test.bim.bootstrap.NewFile): + def test_run(self): + bpy.ops.mesh.primitive_cube_add() + obj = bpy.data.objects.get("Cube") + subject.set_object_origin_to_bottom(obj) + subject.scale_object_to_height(obj, 3) + assert obj.dimensions[2] == 3.0 + assert list(obj.scale) == [1.0, 1.0, 1.0] + + +class TestMarkObjectAsEdited(test.bim.bootstrap.NewFile): + def test_run(self): + obj = bpy.data.objects.new("Cube", None) + subject.mark_object_as_edited(obj) + assert obj in IfcStore.edited_objs