From 1d47ad3cd44443f9a106beb2487c8a4e533a18e3 Mon Sep 17 00:00:00 2001 From: Dion Moult Date: Wed, 26 Jan 2022 21:27:41 +1100 Subject: [PATCH] New demo module to teach developers how to hack on the BlenderBIM Add-on. --- src/blenderbim/blenderbim/bim/__init__.py | 2 + .../blenderbim/bim/module/demo/__init__.py | 55 ++++++++ .../blenderbim/bim/module/demo/data.py | 78 +++++++++++ .../blenderbim/bim/module/demo/operator.py | 79 +++++++++++ .../blenderbim/bim/module/demo/prop.py | 37 +++++ .../blenderbim/bim/module/demo/ui.py | 131 ++++++++++++++++++ src/blenderbim/blenderbim/core/demo.py | 73 ++++++++++ src/blenderbim/blenderbim/core/tool.py | 32 +++++ src/blenderbim/blenderbim/tool/__init__.py | 1 + src/blenderbim/blenderbim/tool/demo.py | 66 +++++++++ src/blenderbim/blenderbim/tool/ifc.py | 6 + src/blenderbim/pytest.ini | 1 + src/blenderbim/test/bim/feature/demo.feature | 45 ++++++ src/blenderbim/test/bim/test_feature.py | 13 ++ src/blenderbim/test/core/bootstrap.py | 7 + src/blenderbim/test/core/test_demo.py | 83 +++++++++++ src/blenderbim/test/tool/test_demo.py | 87 ++++++++++++ 17 files changed, 796 insertions(+) create mode 100644 src/blenderbim/blenderbim/bim/module/demo/__init__.py create mode 100644 src/blenderbim/blenderbim/bim/module/demo/data.py create mode 100644 src/blenderbim/blenderbim/bim/module/demo/operator.py create mode 100644 src/blenderbim/blenderbim/bim/module/demo/prop.py create mode 100644 src/blenderbim/blenderbim/bim/module/demo/ui.py create mode 100644 src/blenderbim/blenderbim/core/demo.py create mode 100644 src/blenderbim/blenderbim/tool/demo.py create mode 100644 src/blenderbim/test/bim/feature/demo.feature create mode 100644 src/blenderbim/test/core/test_demo.py create mode 100644 src/blenderbim/test/tool/test_demo.py diff --git a/src/blenderbim/blenderbim/bim/__init__.py b/src/blenderbim/blenderbim/bim/__init__.py index 5ffc744353..f9493de6b9 100644 --- a/src/blenderbim/blenderbim/bim/__init__.py +++ b/src/blenderbim/blenderbim/bim/__init__.py @@ -68,6 +68,8 @@ modules = { "covetool": None, "augin": None, "debug": None, + # Uncomment this line to enable loading of the demo module. Happy hacking! + # "demo": None, } diff --git a/src/blenderbim/blenderbim/bim/module/demo/__init__.py b/src/blenderbim/blenderbim/bim/module/demo/__init__.py new file mode 100644 index 0000000000..5d71ee79ab --- /dev/null +++ b/src/blenderbim/blenderbim/bim/module/demo/__init__.py @@ -0,0 +1,55 @@ +# BlenderBIM Add-on - OpenBIM Blender Add-on +# Copyright (C) 2022 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 . + +# ############################################################################ # + +# Hey there! Welcome to the BlenderBIM Add-on code. Please feel free to reach +# out if you have any questions or need further guidance. Happy hacking! + +# ############################################################################ # + +# Every module has a __init__.py file to load all of its classes. Every +# operation, property, and interface needs to be registered with the Blender +# system when the add-on loads. This is where it happens. + +import bpy +from . import ui, prop, operator + +# You'll need to provide a list of every one of your classes here. If you forget +# to specify your class, it won't load and you won't be able to use that +# operator, property, or interface. +classes = ( + operator.DemonstrateHelloWorld, + operator.DemonstrateRenameProject, + prop.BIMDemoProperties, + ui.BIM_PT_demo, +) + + +# When the add-on loads, this register function is called. This allows you to +# perform additional tasks during startup. If you need to store custom +# properties, this is where you tell Blender where they are going to be stored. +# You might see more advanced registrations happening in other modules. +def register(): + bpy.types.Scene.BIMDemoProperties = bpy.props.PointerProperty(type=prop.BIMDemoProperties) + + +# When someone disables the add-on, we need to unload everything we loaded. This +# does the reverse of the register function. +def unregister(): + del bpy.types.Scene.BIMDemoProperties diff --git a/src/blenderbim/blenderbim/bim/module/demo/data.py b/src/blenderbim/blenderbim/bim/module/demo/data.py new file mode 100644 index 0000000000..a1632de4a1 --- /dev/null +++ b/src/blenderbim/blenderbim/bim/module/demo/data.py @@ -0,0 +1,78 @@ +# BlenderBIM Add-on - OpenBIM Blender Add-on +# Copyright (C) 2022 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 . + +# ############################################################################ # + +# Hey there! Welcome to the BlenderBIM Add-on code. Please feel free to reach +# out if you have any questions or need further guidance. Happy hacking! + +# ############################################################################ # + +# Every module has a data.py file to load data for its interface panels to +# display. When a panel needs to show information that doesn't come from a +# Blender property, it gets that data from one of these classes. This separation +# of "fetching data" and "displaying data" is a common concept that makes UI +# code much simpler, more efficient, and less error-prone to refresh data. + +import blenderbim.tool as tool + + +# All data must have a refresh function. The refresh function simply sets all +# data class to be not yet loaded. So the next time the interface is drawn to +# the user, it will force the data to be reloaded. +def refresh(): + # When you define your own data classes, just add to this list! + DemoData.is_loaded = False + + +# This is a sample data class. It correlates to a single interface panel. Panels +# should not share data classes. This makes it easy to write your interface +# without having your code mixed in with other parts of the interface. As a +# convention, the class is named the same name as the panel. +class DemoData: + # All data classes must have two variables. One to store all the data it has + # loaded and another to store the load state. + data = {} + is_loaded = False + + # Every data class must have a load function. This lets us load data in a + # predictable manner. + @classmethod + def load(cls): + # The load function always has two responsibilities: populate the data, + # and set is_loaded to true. + cls.data = { + "has_project": cls.has_project(), + "project_name": cls.project_name(), + } + cls.is_loaded = True + + @classmethod + def has_project(cls): + # Here, we check whether or not there is an active IFC project in our + # Blender session. + return bool(tool.Ifc.get()) + + @classmethod + def project_name(cls): + # Imagine how messy our UI code would be if all of this was mixed in + # with our layout code. Here, it's isolated and testable, and the UX + # becomes easier to maintain and change. + ifc = tool.Ifc.get() + if ifc: + return ifc.by_type("IfcProject")[0].Name or "Unnamed" diff --git a/src/blenderbim/blenderbim/bim/module/demo/operator.py b/src/blenderbim/blenderbim/bim/module/demo/operator.py new file mode 100644 index 0000000000..59c487c1a0 --- /dev/null +++ b/src/blenderbim/blenderbim/bim/module/demo/operator.py @@ -0,0 +1,79 @@ +# BlenderBIM Add-on - OpenBIM Blender Add-on +# Copyright (C) 2022 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 . + +# ############################################################################ # + +# Hey there! Welcome to the BlenderBIM Add-on code. Please feel free to reach +# out if you have any questions or need further guidance. Happy hacking! + +# ############################################################################ # + +# Every module has an operator.py file to define all of the buttons that a user +# can click on from the Blender interface. Blender calls these buttons +# "Operators", since they correlate to a single user operation. + +import bpy +import blenderbim.tool as tool +import blenderbim.core.demo as core +import blenderbim.bim.handler + + +# Each button correlates to a class like the one below. In this case, we're +# creating a new button that will execute a hello world feature. +class DemonstrateHelloWorld(bpy.types.Operator, tool.Ifc.Operator): + # Every operator has a unique ID. If you enable Python tooltips and hover + # over any button in the interface, you will see each button will run a + # function that uses this ID. For example, hovering over this button will + # show that the code it executes is "bpy.ops.bim.demonstrate_hello_world()" + bl_idname = "bim.demonstrate_hello_world" + + # In the interface, this button will have the text "Print Hello World". + bl_label = "Print Hello World" + + # This code means that the user can undo or redo after pressing the button. + bl_options = {"REGISTER", "UNDO"} + + # When hovering over the button, this helpful description will be shown. + bl_description = "Prints the text 'Hello World'" + + # When the button is pressed, this _execute() function will run. + def _execute(self, context): + # Every operator should do one thing only: execute a core function. In + # order to execute a core function, the operator's responsibility is to + # pass in all of the inputs the core needs to do its job. + + # A core function simply tells tools what to do, so a core function will + # always need at least one tool as an input. + core.demonstrate_hello_world(tool.Demo) + + +# This class, just like the one above, will correlate to another button in the +# interface. It has a different ID and a different label. +class DemonstrateRenameProject(bpy.types.Operator, tool.Ifc.Operator): + bl_idname = "bim.demonstrate_rename_project" + bl_label = "Rename IFC Project" + bl_options = {"REGISTER", "UNDO"} + bl_description = "Renames your IFC project name and prints the name" + + def _execute(self, context): + # This core function requires three inputs: two tools, and a name + # string. In this case, the name is taken from some custom Blender + # properties. Generally, the inputs to the core function will come from + # properties (such as an input field) or data from the scene (like the + # actively selected object). + core.demonstrate_rename_project(tool.Ifc, tool.Demo, name=bpy.context.scene.BIMDemoProperties.name) diff --git a/src/blenderbim/blenderbim/bim/module/demo/prop.py b/src/blenderbim/blenderbim/bim/module/demo/prop.py new file mode 100644 index 0000000000..d17bc19a08 --- /dev/null +++ b/src/blenderbim/blenderbim/bim/module/demo/prop.py @@ -0,0 +1,37 @@ +# BlenderBIM Add-on - OpenBIM Blender Add-on +# Copyright (C) 2022 Dion Moult +# +# This file is part of BlenderBIM Add-on. +# +# BlenderBIM Add-on is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# BlenderBIM Add-on is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with BlenderBIM Add-on. If not, see . + +import bpy +from blenderbim.bim.prop import StrProperty, Attribute +from bpy.types import PropertyGroup +from bpy.props import ( + PointerProperty, + StringProperty, + EnumProperty, + BoolProperty, + IntProperty, + FloatProperty, + FloatVectorProperty, + CollectionProperty, +) + + +class BIMDemoProperties(PropertyGroup): + name: StringProperty(name="Name", default="New Project Name") + message: StringProperty(name="Message") + show_hints: BoolProperty(name="Show Hints", default=False) diff --git a/src/blenderbim/blenderbim/bim/module/demo/ui.py b/src/blenderbim/blenderbim/bim/module/demo/ui.py new file mode 100644 index 0000000000..f00d4ae05b --- /dev/null +++ b/src/blenderbim/blenderbim/bim/module/demo/ui.py @@ -0,0 +1,131 @@ +# BlenderBIM Add-on - OpenBIM Blender Add-on +# Copyright (C) 2022 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 . + +# ############################################################################ # + +# Hey there! Welcome to the BlenderBIM Add-on code. Please feel free to reach +# out if you have any questions or need further guidance. Happy hacking! + +# ############################################################################ # + +# Every module has a ui.py file to define its interface. Interfaces describe how +# panels, buttons, labels, and input fields are laid out. + +import bpy +from blenderbim.bim.module.demo.data import DemoData + +# Every panel in the interface correlates to one of these classes. If you enable +# the "Developer Extras" option in Blender, then you can actually right click on +# any panel in Blender, and click "Edit Source". This will bring you to a ui.py +# file like this one where you can see this code. Pretty neat! +class BIM_PT_demo(bpy.types.Panel): + # Every panel has a title. + bl_label = "BlenderBIM Demo" + + # Every panel must have an ID. It must be unique. For example, you may want + # to later reference the panel (such as if you want to create a nested + # subpanel). + bl_idname = "BIM_PT_demo" + + # This tells the panel to appear in the properties section (in the bottom + # right by default) of the Blender interface. + bl_space_type = "PROPERTIES" + bl_region_type = "WINDOW" + + # This tells the panel to appear in the "scene" tab of the properties panel. + bl_context = "scene" + + # Every panel has a draw function. This draws all the layout of the panel, + # including all of its labels, buttons, and so on. Note that if the panel is + # hidden, this draw function will not be called. + def draw(self, context): + # Before drawing any content, the panel must load any dynamic variables + # that it will show. Most panels have dynamic content, which display + # changing data from your BIM model. You must load that data first. The + # two lines are always the same, "if not loaded, then load". + if not DemoData.is_loaded: + # Each panel should have its own data class that it loads data from. + # Note that we only load data if it hasn't already been loaded. + # This is because interface panels are drawn continuously. This + # draw() function will be called every time you scroll or move your + # mouse over it. Loading data is slow, so we only refresh data when + # we have to. + DemoData.load() + + # Interface panels often show properties. For convenience, define where + # the properties are stored for the module. + self.props = context.scene.BIMDemoProperties + + # This defines a new "row" in our layout. When a new row is defined, the + # things on that row, like buttons, labels, and input fields, show on a + # new line. + row = self.layout.row() + # This is the simplest interface element - a label placed in our row. + # If you enable the "Icon Viewer" add-on, you can view a list of + # Blender's built-in icons to choose from in the Blender text editor's + # side panel. + row.label(text="This is a demo panel", icon="INFO") + + # Our interface can contain simple logic. For example, if we don't have + # an IFC project, show an error message and don't draw anything else. + # The ui.py should always have very simple logic. Details like how to + # determine whether we have a project is delegated to the data loader. + if not DemoData.data["has_project"]: + row = self.layout.row() + row.label(text="Load or create an IFC project first", icon="ERROR") + return + + row = self.layout.row() + # When you want to show a button in the interface, you have to specify + # an operator. Operators are defined in operator.py. You reference an + # operator using its unique ID. + row.operator("bim.demonstrate_hello_world") + + # Blender properties can be used to affect what your panel shows. Notice + # how the logic is very simple. Only use simple boolean predicates and + # loops in your layout. No complex logic should ever been seen in the + # interface code. + if self.props.message: + row = self.layout.row() + row.label(text=self.props.message) + + row = self.layout.row(align=True) + row.label(text="Project Name") + # Sometimes you need data that doesn't come from Blender properties, + # like from your IFC model. In this case, just get some data from your + # data loader. For those familiar with how templating languages work, + # this is exactly the same. + row.label(text=DemoData.data["project_name"]) + + row = self.layout.row() + # We've seen how to show text labels and buttons using operators. What + # about text input fields, number sliders, dropdowns, checkboxes, and + # scrollable lists? The way Blender works is that you simply show a + # Blender property in your interface. The data type of that property + # determines which UI widget is shown. A text property will show a text + # field. A number property will show a number slider. An enum property + # will show a drop down. And so on. In this case, our name property is a + # text data type, so expect to see a text input field show up here. + row.prop(self.props, "name") + row = self.layout.row() + # Here's another button, referencing another operator. + row.operator("bim.demonstrate_rename_project") + + if self.props.show_hints: + row = self.layout.row() + row.label(text="Name cannot be blank!") diff --git a/src/blenderbim/blenderbim/core/demo.py b/src/blenderbim/blenderbim/core/demo.py new file mode 100644 index 0000000000..a31be242d4 --- /dev/null +++ b/src/blenderbim/blenderbim/core/demo.py @@ -0,0 +1,73 @@ +# BlenderBIM Add-on - OpenBIM Blender Add-on +# Copyright (C) 2022 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 . + +# ############################################################################ # + +# Hey there! Welcome to the BlenderBIM Add-on code. Please feel free to reach +# out if you have any questions or need further guidance. Happy hacking! + +# ############################################################################ # + +# Every module has a core.py file to define all of its core functions. A core +# function describes what happens when the user wants to do something like +# pressing a button. + +# Think of a core function as a short poem of pseudocode that describes what +# happens in different usecases. A core should be no more than 50 lines of code, +# even in the most complex of features. A core simply delegates tasks to tools +# in a sequence that describes the flow of logic in a feature - in other words, +# it tells tools to do different things. You will notice that the core doesn't +# have any code that deals with Blender or IFC directly - these are all little +# details that are hidden away in tools. The core is not interested in these +# details, the core is only concerned with the big picture. + +# Imagine, no matter how complex a software can be, every feature can be +# described in regular sentences in under 50 lines. That is the purpose of the +# core. + +# Here's the simplest possible core function. It does one thing only. Remember: +# core functions delegate tasks to tools, so all core functions need at least +# one tool. +def demonstrate_hello_world(demo): + # We're telling the demo tool to set a message. We aren't interested how the + # tool works, that's a detail. We aren't interested in the interface, like + # where the message is shown. You can name these functions whatever you feel + # best describes what's going on, like if you had to describe the feature to + # someone else. + demo.set_message("Hello, World!") + + +# Here's a slightly more complex core function. It uses two tools. By default, +# you'll want to use your module's tool (in this case, "Demo") for all tasks, +# except for changing IFC data, where you'll use the "Ifc" tool. At a glance, +# simply by seeing what tools are used, this gives you an idea about what +# aspects (or dependencies) a core function cares about. This function also has +# an non-tool input called "name". Non-tool inputs should be keyword arguments +# with possible default values. +def demonstrate_rename_project(ifc, demo, name=None): + # As you can see, core functions read almost like pseudocode. A great way to + # code a new feature is to write out what it does in English first, then + # change them into tool functions. You can choose any function you want, and + # you can see a list of every single tool function in # `core/tool.py`. + if name: + project = demo.get_project() + ifc.run("attribute.edit_attributes", product=project, attributes={"Name": name}) + demo.clear_name_field() + demo.hide_user_hints() + else: + demo.show_user_hints() diff --git a/src/blenderbim/blenderbim/core/tool.py b/src/blenderbim/blenderbim/core/tool.py index 67ec682829..f47f870401 100644 --- a/src/blenderbim/blenderbim/core/tool.py +++ b/src/blenderbim/blenderbim/core/tool.py @@ -30,6 +30,38 @@ def interface(cls): return type(cls.__name__, (Interface, cls), attrs) +# ############################################################################ # + +# Hey there! Welcome to the BlenderBIM Add-on code. Please feel free to reach +# out if you have any questions or need further guidance. Happy hacking! + +# ############################################################################ # + +# This portion of the code is singled out as it is part of the Demo module, used +# to teach people how the code is structured in the BlenderBIM Add-on. + +# This is an example "interface" (as in, a software interface, not a user +# interface) for the Demo module. Think of it as a list of all of the functions +# your module can perform. In this file, you will come across every single +# capability of the entire add-on. By breaking it down into little functions, we +# can test each one separately, and at a glance know if there are opportunities +# for code reuse or refactoring. At this point, we're only interested in a list +# of functions, not the details of how they're implemented. This is because the +# purpose of the core is to just give a high level overview of the application's +# capabilities. + +@interface +class Demo: + def clear_name_field(cls): pass + def get_project(cls): pass + def hide_user_hints(cls): pass + def set_message(cls, message): pass + def show_user_hints(cls): pass + +# The rest of the code in this file is not part of the Demo tutorial. + +# ############################################################################ # + @interface class Aggregate: def can_aggregate(cls, relating_object, related_object): pass diff --git a/src/blenderbim/blenderbim/tool/__init__.py b/src/blenderbim/blenderbim/tool/__init__.py index a5400a099a..137d4d6102 100644 --- a/src/blenderbim/blenderbim/tool/__init__.py +++ b/src/blenderbim/blenderbim/tool/__init__.py @@ -23,6 +23,7 @@ from blenderbim.tool.brick import Brick from blenderbim.tool.collector import Collector from blenderbim.tool.context import Context from blenderbim.tool.debug import Debug +from blenderbim.tool.demo import Demo from blenderbim.tool.drawing import Drawing from blenderbim.tool.geometry import Geometry from blenderbim.tool.ifc import Ifc diff --git a/src/blenderbim/blenderbim/tool/demo.py b/src/blenderbim/blenderbim/tool/demo.py new file mode 100644 index 0000000000..1cabe848c7 --- /dev/null +++ b/src/blenderbim/blenderbim/tool/demo.py @@ -0,0 +1,66 @@ +# BlenderBIM Add-on - OpenBIM Blender Add-on +# Copyright (C) 2022 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 . + +# ############################################################################ # + +# Hey there! Welcome to the BlenderBIM Add-on code. Please feel free to reach +# out if you have any questions or need further guidance. Happy hacking! + +# ############################################################################ # + +# Every module has a tool file which implements all the functions that the core +# needs. Whereas the core is simply high level code, the tool file has the +# concrete implementations, dealing with exactly how things interact with +# Blender's property systems, IFC's data structures, the filesystem, geometry +# processing, and more. + +import bpy +import blenderbim.core.tool +import blenderbim.tool as tool + + +# There is always one class in each tool file, which implements the interface +# defined by `core/tool.py`. +class Demo(blenderbim.core.tool.Demo): + @classmethod + def clear_name_field(cls): + # In this concrete implementation, we see that "clear name field" + # actually translates to "set this Blender string property to empty + # string". In this case, it's pretty simple - but even simple scenarios + # like these are important to implement in the tool, as it makes the + # pseudocode easier to read in the core, and makes it easier to test + # implementations separately from control flow. It also makes it easy to + # refactor and share functions, where every tool function is captured by + # a function name that describes its intention. + bpy.context.scene.BIMDemoProperties.name = "" + + @classmethod + def get_project(cls): + return tool.Ifc.get().by_type("IfcProject")[0] + + @classmethod + def hide_user_hints(cls): + bpy.context.scene.BIMDemoProperties.show_hints = False + + @classmethod + def set_message(cls, message): + bpy.context.scene.BIMDemoProperties.message = message + + @classmethod + def show_user_hints(cls): + bpy.context.scene.BIMDemoProperties.show_hints = True diff --git a/src/blenderbim/blenderbim/tool/ifc.py b/src/blenderbim/blenderbim/tool/ifc.py index 198700a6b6..22bdc6428d 100644 --- a/src/blenderbim/blenderbim/tool/ifc.py +++ b/src/blenderbim/blenderbim/tool/ifc.py @@ -65,3 +65,9 @@ class Ifc(blenderbim.core.tool.Ifc): @classmethod def unlink(cls, element=None, obj=None): IfcStore.unlink_element(element, obj) + + class Operator: + def execute(self, context): + IfcStore.execute_ifc_operator(self, context) + blenderbim.bim.handler.refresh_ui_data() + return {"FINISHED"} diff --git a/src/blenderbim/pytest.ini b/src/blenderbim/pytest.ini index 71d660e766..2da67b7e82 100644 --- a/src/blenderbim/pytest.ini +++ b/src/blenderbim/pytest.ini @@ -6,6 +6,7 @@ markers = brick context debug + demo drawing geometry library diff --git a/src/blenderbim/test/bim/feature/demo.feature b/src/blenderbim/test/bim/feature/demo.feature new file mode 100644 index 0000000000..637b38cae6 --- /dev/null +++ b/src/blenderbim/test/bim/feature/demo.feature @@ -0,0 +1,45 @@ +# ############################################################################ # + +# Hey there! Welcome to the BlenderBIM Add-on code. Please feel free to reach +# out if you have any questions or need further guidance. Happy hacking! + +# ############################################################################ # + +# This allows us to write full integration tests that test how all systems work +# as a whole. Whilst other tests focus on portions of the software, these tests +# simulate what happens when a user opens Blender, presses buttons, and does +# things. + +# These tests read like english. You can see all the possible sentences defined +# in test_feature.py. Most of the time, there is already a sentence defined for +# what you want to test. + +@demo +Feature: Demo + +# Every operator has at least one scenario associated with it to test it. +Scenario: Demonstrate hello world + Given an empty IFC project + When I press "bim.demonstrate_hello_world" + # Blender doesn't have a way of testing that things are visible in the + # interface and layout. We can check properties, and whats in the 3D + # scenegraph, but not layout. There is no "DOM" like in web applications. + # Too bad, we can't check the results, but we still write the test, that way + # we can still check for errors like crashes or Python errors, like a "smoke + # test". + Then nothing happens + +# This operator has two scenarios because there are two possibilities of a user +# interacting with it. +Scenario: Demonstrate rename project - with a name provided + Given an empty IFC project + When I set "scene.BIMDemoProperties.name" to "Foobar" + And I press "bim.demonstrate_rename_project" + Then the object "IfcProject/Foobar" is an "IfcProject" + +# This is the other possible scenario for the rename project operator. +Scenario: Demonstrate rename project - with no name + Given an empty IFC project + When I set "scene.BIMDemoProperties.name" to "" + And I press "bim.demonstrate_rename_project" + Then the object "IfcProject/My Project" is an "IfcProject" diff --git a/src/blenderbim/test/bim/test_feature.py b/src/blenderbim/test/bim/test_feature.py index 4b855b7308..dc014ca8b9 100644 --- a/src/blenderbim/test/bim/test_feature.py +++ b/src/blenderbim/test/bim/test_feature.py @@ -209,6 +209,19 @@ def i_set_prop_to_value(prop, value): exec(f"bpy.context.{prop} = {value}") +@given(parsers.parse('I set "{prop}" to ""')) +@when(parsers.parse('I set "{prop}" to ""')) +def i_set_prop_to_value(prop): + try: + eval(f"bpy.context.{prop}") + except: + assert False, "Property does not exist" + try: + exec(f'bpy.context.{prop} = r""') + except: + pass + + @when(parsers.parse('I am on frame "{number}"')) def i_am_on_frame_number(number): bpy.context.scene.frame_set(int(number)) diff --git a/src/blenderbim/test/core/bootstrap.py b/src/blenderbim/test/core/bootstrap.py index 42dab7d0e4..059dfef0fb 100644 --- a/src/blenderbim/test/core/bootstrap.py +++ b/src/blenderbim/test/core/bootstrap.py @@ -70,6 +70,13 @@ def debug(): prophet.verify() +@pytest.fixture +def demo(): + prophet = Prophecy(blenderbim.core.tool.Demo) + yield prophet + prophet.verify() + + @pytest.fixture def drawing(): prophet = Prophecy(blenderbim.core.tool.Drawing) diff --git a/src/blenderbim/test/core/test_demo.py b/src/blenderbim/test/core/test_demo.py new file mode 100644 index 0000000000..dca59f1e79 --- /dev/null +++ b/src/blenderbim/test/core/test_demo.py @@ -0,0 +1,83 @@ +# BlenderBIM Add-on - OpenBIM Blender Add-on +# Copyright (C) 2022 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 . + +# ############################################################################ # + +# Hey there! Welcome to the BlenderBIM Add-on code. Please feel free to reach +# out if you have any questions or need further guidance. Happy hacking! + +# ############################################################################ # + +# Testing the core might seem strange if you haven't written this type of +# abstract test before. You essentially want to test that things are called in +# the right sequence. This seems almost like writing the code twice, like in +# double entry bookkeeping in accounting. However, it guards against typos, +# helps document intention of different logical flows, and ensures that all +# permutations of logic flows meet sanity checks. + +# We always call the module we're testing the "test subject". This makes our +# tests simple to read: just look for where the "subject" is called! +import blenderbim.core.demo as subject +# These are like mocks, stubs, or spy objects. They don't do anything, but they +# let us check our test expectations. +from test.core.bootstrap import ifc, demo + + +# Let's test the hello world function. +class TestDemonstrateHelloWorld: + # This function has only one logical flow, there is no benefit to describing + # it further, so we have a test_run function. We need to specify all the + # mock objects we need in the signature. + def test_run(self, demo): + # We set an expectation that in this default sequence of events, the + # demo tool should have the set_message called with the "Hello, World!" + # string as its argument. Notice how our test also reads like English. + demo.set_message("Hello, World!").should_be_called() + # After we've finished specifying our test expectations, let's run the + # test subject! + subject.demonstrate_hello_world(demo) + + +# Another test, but this time with two logical flows. +class TestDemonstrateRenameProject: + # The default logical flow is where we rename the project successfully. + def test_renaming_the_project(self, ifc, demo): + # This time, we describe an expectation that the demo tool should have + # its get_project() function called with no attributes. When it is + # called, we expect it to return "project" as a string. + + # This might sound strange. How is get_project implemented? Does it + # actually return a string? We don't know, and we don't care. That's a + # detail that our core isn't interested in. All our core is interested + # in is that we get back a project - we're arbitrarily using a string to + # represent it. + demo.get_project().should_be_called().will_return("project") + # Here, again, we're not interested in the details. However, we are + # interested in checking that the project we previously retrieved is + # passed verbatim into the Ifc tool. + ifc.run("attribute.edit_attributes", product="project", attributes={"Name": "name"}).should_be_called() + demo.clear_name_field().should_be_called() + demo.hide_user_hints().should_be_called() + # Let's test our test subject! Notice that just like the arbitrary + # string "project", we've specified an arbitrary input string of "name". + subject.demonstrate_rename_project(ifc, demo, name="name") + + # One alternative flow is when no name is provided. What happens then? + def test_showing_a_hint_if_no_name_provided(self, ifc, demo): + demo.show_user_hints().should_be_called() + subject.demonstrate_rename_project(ifc, demo, name=None) diff --git a/src/blenderbim/test/tool/test_demo.py b/src/blenderbim/test/tool/test_demo.py new file mode 100644 index 0000000000..a14a594fb5 --- /dev/null +++ b/src/blenderbim/test/tool/test_demo.py @@ -0,0 +1,87 @@ +# 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 . + +# ############################################################################ # + +# Hey there! Welcome to the BlenderBIM Add-on code. Please feel free to reach +# out if you have any questions or need further guidance. Happy hacking! + +# ############################################################################ # + +# Because our tools have well defined, isolated functions, it means we can test +# them very easily in isolation. Tests are fun, fast, and easy to setup! + +import bpy +import ifcopenshell +import blenderbim.core.tool +import blenderbim.tool as tool +from test.bim.bootstrap import NewFile +from blenderbim.tool.demo import Demo as subject + + +# Our first test is that our tool implements all the abstract methods defined by +# the `core/tool.py` interface. Anytime the core wants something that the tools +# don't provide, this test will catch it. This type of test comes for free in +# other languages, but not Python, so we test it explicitly. +class TestImplementsTool(NewFile): + def test_run(self): + assert isinstance(subject(), blenderbim.core.tool.Demo) + + +# These are fairly boring tests. What it does demonstrate is that no matter how +# complex a BIM application can get, it can always be reduced down to small, +# easily tested functions. Note that these functions actually are concrete +# implementations, so that means that you need to use Blender headlessly to run +# these tests. +class TestClearNameField(NewFile): + def test_run(self): + bpy.context.scene.BIMDemoProperties.name = "name" + subject.clear_name_field() + assert bpy.context.scene.BIMDemoProperties.name == "" + + +class TestGetProject(NewFile): + def test_run(self): + # Sometimes, there is a bit of preparation work to setup a scenario that + # can be tested. In this case, we need to set an active IFC dataset with + # a project. This is normal. + ifc = ifcopenshell.file() + project = ifc.createIfcProject() + tool.Ifc.set(ifc) + assert subject.get_project() == project + + +class TestHideUserHints(NewFile): + def test_run(self): + bpy.context.scene.BIMDemoProperties.show_hints = True + subject.hide_user_hints() + assert bpy.context.scene.BIMDemoProperties.show_hints == False + + +class TestSetMessage(NewFile): + def test_run(self): + bpy.context.scene.BIMDemoProperties.message = "" + subject.set_message("message") + assert bpy.context.scene.BIMDemoProperties.message == "message" + + +class TestShowUserHints(NewFile): + def test_run(self): + bpy.context.scene.BIMDemoProperties.show_hints = False + subject.show_user_hints() + assert bpy.context.scene.BIMDemoProperties.show_hints == True