Welcome to mirror list, hosted at ThFree Co, Russian Federation.

git.blender.org/blender.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'tests/python/bl_run_operators_event_simulate.py')
-rw-r--r--tests/python/bl_run_operators_event_simulate.py577
1 files changed, 577 insertions, 0 deletions
diff --git a/tests/python/bl_run_operators_event_simulate.py b/tests/python/bl_run_operators_event_simulate.py
new file mode 100644
index 00000000000..251d27fce90
--- /dev/null
+++ b/tests/python/bl_run_operators_event_simulate.py
@@ -0,0 +1,577 @@
+# ##### BEGIN GPL LICENSE BLOCK #####
+#
+# This program 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 2
+# of the License, or (at your option) any later version.
+#
+# This program 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 this program; if not, write to the Free Software Foundation,
+# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# ##### END GPL LICENSE BLOCK #####
+
+# <pep8 compliant>
+
+r"""
+Overview
+========
+
+This is a utility to generate events from the command line,
+so reproducible test cases can be written without having to create a custom script each time.
+
+The key differentiating feature for this utility as is that it's able to control modal operators.
+
+Possible use cases for this script include:
+
+- Creating reproducible user interactions for the purpose of benchmarking and profiling.
+
+ Note that cursor-motion actions report the update time between events
+ which can be helpful when measuring optimizations.
+
+- As a convenient way to replay interactive actions that reproduce a bug.
+
+- For writing tests (although some extra functionality may be necessary in this case).
+
+
+Actions
+=======
+
+You will notice most of the functionality is supported using the actions command line argument,
+this is a kind of mini-language to drive Blender.
+
+While the current set of commands is fairly limited more can be added as needed.
+
+To see a list of actions as well as their arguments run:
+
+ ./blender.bin --python tests/python/bl_run_operators_event_simulate.py -- --help
+
+
+Examples
+========
+
+Rotate in edit-mode examples:
+
+ ./blender.bin \
+ --factory-startup \
+ --enable-event-simulate \
+ --python tests/python/bl_run_operators_event_simulate.py \
+ -- \
+ --actions \
+ 'area_maximize(ui_type="VIEW_3D")' \
+ 'operator("object.mode_set", mode="EDIT")' \
+ 'operator("mesh.select_all", action="SELECT")' \
+ 'operator("mesh.subdivide", number_cuts=5)' \
+ 'operator("transform.rotate")' \
+ 'cursor_motion(path="CIRCLE", radius=300, steps=100, repeat=2)'
+
+Sculpt stroke:
+
+ ./blender.bin \
+ --factory-startup \
+ --enable-event-simulate \
+ --python tests/python/bl_run_operators_event_simulate.py \
+ -- \
+ --actions \
+ 'area_maximize(ui_type="VIEW_3D")' \
+ 'event(type="FIVE", value="TAP", ctrl=True)' \
+ 'menu("Visual Geometry to Mesh")' \
+ 'menu("Frame Selected")' \
+ 'menu("Toggle Sculpt Mode")' \
+ 'event(type="WHEELDOWNMOUSE", value="TAP", repeat=2)' \
+ 'event(type="LEFTMOUSE", value="PRESS")' \
+ 'cursor_motion(path="CIRCLE", radius=300, steps=100, repeat=5)' \
+ 'event(type="LEFTMOUSE", value="RELEASE")'
+
+
+Implementation
+==============
+
+While most of the operations listed above can be executed in Python directly,
+either the event loop won't be handled between actions (the case for typical Python script),
+or the context for executing the actions is not properly set (the case for timers).
+
+This utility executes actions as if the user initiated them from a key shortcut.
+"""
+
+
+import os
+import sys
+import argparse
+from argparse import ArgumentTypeError
+
+import bpy
+
+
+# -----------------------------------------------------------------------------
+# Constants
+
+EVENT_TYPES = tuple(bpy.types.Event.bl_rna.properties["type"].enum_items.keys())
+EVENT_VALUES = tuple(bpy.types.Event.bl_rna.properties["value"].enum_items.keys())
+# `TAP` is just convenience for (`PRESS`, `RELEASE`).
+EVENT_VALUES_EXTRA = EVENT_VALUES + ('TAP',)
+
+
+# -----------------------------------------------------------------------------
+# Globals
+
+# Assign a global since this script is not going to be loading new files (which would free the window).
+win = bpy.context.window_manager.windows[0]
+
+
+# -----------------------------------------------------------------------------
+# Utilities
+
+def find_main_area(ui_type=None):
+ """
+ Find the largest area from the current screen.
+ """
+ area_best = None
+ size_best = -1
+ for area in win.screen.areas:
+ if ui_type is not None:
+ if ui_type != area.ui_type:
+ continue
+
+ size = area.width * area.height
+ if size > size_best:
+ size_best = size
+ area_best = area
+ return area_best
+
+
+def gen_events_type_text(text):
+ """
+ Generate events to type in `text`.
+ """
+ for ch in text:
+ kw_extra = {}
+ # The event type in this case is ignored as only the unicode value is used for text input.
+ type = 'SPACE'
+ if ch == '\t':
+ type = 'TAB'
+ elif ch == '\n':
+ type = 'RET'
+ else:
+ kw_extra["unicode"] = ch
+
+ yield dict(type=type, value='PRESS', **kw_extra)
+ kw_extra.pop("unicode", None)
+ yield dict(type=type, value='RELEASE', **kw_extra)
+
+
+# -----------------------------------------------------------------------------
+# Simulate Events
+
+def mouse_location_get():
+ return (
+ run_event_simulate.last_event["x"],
+ run_event_simulate.last_event["y"],
+ )
+
+
+def run_event_simulate(*, event_iter):
+ """
+ Pass events from event_iter into Blender.
+ """
+ last_event = run_event_simulate.last_event
+
+ def event_step():
+ win = bpy.context.window_manager.windows[0]
+
+ val = next(event_step.run_events, Ellipsis)
+ if val is Ellipsis:
+ bpy.app.use_event_simulate = False
+ print("Finished simulation")
+
+ sys.exit(0)
+ return None
+
+ # Run event simulation.
+ for attr in ("x", "y"):
+ if attr in val:
+ last_event[attr] = val[attr]
+ else:
+ val[attr] = last_event[attr]
+
+ # Fake event value, since press, release is so common.
+ if val.get("value") == 'TAP':
+ del val["value"]
+ win.event_simulate(**val, value='PRESS')
+ # Needed if new files are loaded.
+ # win = bpy.context.window_manager.windows[0]
+ win.event_simulate(**val, value='RELEASE')
+ else:
+ # print("val", val)
+ win.event_simulate(**val)
+ return 0.0
+
+ event_step.run_events = iter(event_iter)
+
+ bpy.app.timers.register(event_step, first_interval=0.0, persistent=True)
+
+
+run_event_simulate.last_event = dict(
+ x=win.width // 2,
+ y=win.height // 2,
+)
+
+
+# -----------------------------------------------------------------------------
+# Action Implementations
+
+# Static methods from this class are automatically exposed as actions and included in the help text.
+class action_handlers:
+
+ @staticmethod
+ def area_maximize(*, ui_type=None, only_validate=False):
+ """
+ ui_type:
+ Select the area type (typically 'VIEW_3D').
+ Note that this area type needs to exist in the current screen.
+ """
+ if not ((ui_type is None) or (isinstance(ui_type, str))):
+ raise ArgumentTypeError("'type' argument %r not None or a string type")
+
+ if only_validate:
+ return
+
+ area = find_main_area(ui_type=ui_type)
+ if area is None:
+ raise ArgumentTypeError("Area with ui_type=%r not found" % ui_type)
+
+ x = area.x + (area.width // 2)
+ y = area.y + (area.height // 2)
+
+ yield dict(type='MOUSEMOVE', value='NOTHING', x=x, y=y)
+ yield dict(type='SPACE', value='TAP', ctrl=True, alt=True)
+
+ x = win.width // 2
+ y = win.height // 2
+
+ yield dict(type='MOUSEMOVE', value='NOTHING', x=x, y=y)
+
+ @staticmethod
+ def menu(text, *, only_validate=False):
+ """
+ text: Menu item to search for and execute.
+ """
+ if not isinstance(text, str):
+ raise ArgumentTypeError("'text' argument not a string")
+
+ if only_validate:
+ return
+
+ yield dict(type='F3', value='TAP')
+ yield from gen_events_type_text(text)
+ yield dict(type='RET', value='TAP')
+
+ @staticmethod
+ def event(*, value, type, ctrl=False, alt=False, shift=False, repeat=1, only_validate=False):
+ """
+ value: The event, typically key, e.g. 'ESC', 'RET', 'SPACE', 'A'.
+ type: The event type, valid values include: 'PRESS', 'RELEASE', 'TAP'.
+ ctrl: Control modifier.
+ alt: Alt modifier.
+ shift: Shift modifier.
+ """
+ valid_items = EVENT_VALUES_EXTRA
+ if value not in valid_items:
+ raise ArgumentTypeError("'value' argument %r not in %r" % (value, valid_items))
+ valid_items = EVENT_TYPES
+ if type not in valid_items:
+ raise ArgumentTypeError("'type' argument %r not in %r" % (value, valid_items))
+ valid_items = range(1, sys.maxsize)
+ if repeat not in valid_items:
+ raise ArgumentTypeError("'repeat' argument %r not in %r" % (repeat, valid_items))
+ del valid_items
+
+ if only_validate:
+ return
+
+ for _ in range(repeat):
+ yield dict(type=type, ctrl=ctrl, alt=alt, shift=shift, value=value)
+
+ @staticmethod
+ def cursor_motion(*, path, steps, radius=100, repeat=1, only_validate=False):
+ """
+ path: The path type to use in ('CIRCLE').
+ steps: The number of events to generate.
+ radius: The radius in pixels.
+ repeat: Number of times to repeat the cursor rotation.
+ """
+
+ import time
+ from math import sin, cos, pi
+
+ valid_items = range(1, sys.maxsize)
+ if steps not in valid_items:
+ raise ArgumentTypeError("'steps' argument %r not in %r" % (steps, valid_items))
+
+ valid_items = range(1, sys.maxsize)
+ if radius not in valid_items:
+ raise ArgumentTypeError("'radius' argument %r not in %r" % (steps, valid_items))
+
+ valid_items = ('CIRCLE',)
+ if path not in valid_items:
+ raise ArgumentTypeError("'path' argument %r not in %r" % (path, valid_items))
+
+ valid_items = range(1, sys.maxsize)
+ if repeat not in valid_items:
+ raise ArgumentTypeError("'repeat' argument %r not in %r" % (repeat, valid_items))
+ del valid_items
+
+ if only_validate:
+ return
+
+ x_init, y_init = mouse_location_get()
+
+ y_init_ofs = y_init + radius
+
+ yield dict(type='MOUSEMOVE', value='NOTHING', x=x_init, y=y_init_ofs)
+
+ print("\n" "Times for: %s" % os.path.basename(bpy.data.filepath))
+
+ t = time.time()
+ step_total = 0
+
+ if path == 'CIRCLE':
+ for _ in range(repeat):
+ for i in range(1, steps + 1):
+ phi = (i / steps) * 2.0 * pi
+ x_ofs = -radius * sin(phi)
+ y_ofs = +radius * cos(phi)
+ step_total += 1
+ yield dict(
+ type='MOUSEMOVE',
+ value='NOTHING',
+ x=int(x_init + x_ofs),
+ y=int(y_init + y_ofs),
+ )
+
+ delta = time.time() - t
+ delta_step = delta / step_total
+ print(
+ "Average:",
+ ("%.6f FPS" % (1 / delta_step)).rjust(10),
+ )
+
+ yield dict(type='MOUSEMOVE', value='NOTHING', x=x_init, y=y_init)
+
+ @staticmethod
+ def operator(idname, *, only_validate=False, **kw):
+ """
+ idname: The operator identifier (positional argument only).
+ kw: Passed to the operator.
+ """
+
+ # Create a temporary key binding to call the operator.
+ wm = bpy.context.window_manager
+ keyconf = wm.keyconfigs.user
+
+ keymap_id = "Screen"
+ key_to_map = 'F24'
+
+ if only_validate:
+ op_mod, op_submod = idname.partition(".")[0::2]
+ op = getattr(getattr(bpy.ops, op_mod), op_submod)
+ try:
+ # The poll result doesn't matter we only want to know if the operator exists or not.
+ op.poll()
+ except AttributeError:
+ raise ArgumentTypeError("Operator %r does not exist" % (idname))
+
+ keymap = keyconf.keymaps[keymap_id]
+ kmi = keymap.keymap_items.new(idname=idname, type=key_to_map, value='PRESS')
+ kmi.idname = idname
+ props = kmi.properties
+ for key, value in kw.items():
+ if not hasattr(props, key):
+ raise ArgumentTypeError("Operator %r does not have a %r property" % (idname, key))
+
+ try:
+ setattr(props, key, value)
+ except Exception as ex:
+ raise ArgumentTypeError("Operator %r assign %r property with error %s" % (idname, key, str(ex)))
+
+ keymap.keymap_items.remove(kmi)
+ return
+
+ keymap = keyconf.keymaps[keymap_id]
+ kmi = keymap.keymap_items.new(idname=idname, type=key_to_map, value='PRESS')
+ kmi.idname = idname
+ props = kmi.properties
+ for key, value in kw.items():
+ setattr(props, key, value)
+
+ yield dict(type=key_to_map, value='TAP')
+
+ keymap = keyconf.keymaps[keymap_id]
+ kmi = keymap.keymap_items[-1]
+ keymap.keymap_items.remove(kmi)
+
+
+ACTION_DIR = tuple([
+ key for key in sorted(action_handlers.__dict__.keys())
+ if not key.startswith("_")
+])
+
+
+def handle_action(op, args, kwargs, only_validate=False):
+ fn = getattr(action_handlers, op, None)
+ if fn is None:
+ raise ArgumentTypeError("Action %r is not found in %r" % (op, ACTION_DIR))
+ yield from fn(*args, **kwargs, only_validate=only_validate)
+
+
+# -----------------------------------------------------------------------------
+# Argument Parsing
+
+
+class BlenderAction(argparse.Action):
+ """
+ This class is used to extract positional & keyword arguments from
+ a string, validate them, and return the (action, positional_args, keyword_args).
+
+ All of this happens during argument parsing so any errors in the actions
+ show useful error messages instead of failing to execute part way through.
+ """
+
+ @staticmethod
+ def _parse_value(value, index):
+ """
+ Convert:
+ "value(1, 2, a=1, b='', c=None)"
+ To:
+ ("value", (1, 2), {"a": 1, "b": "", "c": None})
+ """
+ split = value.find("(")
+ if split == -1:
+ op = value
+ args = None
+ kwargs = None
+ else:
+ op = value[:split]
+ namespace = {op: lambda *args, **kwargs: (args, kwargs)}
+ expr = value
+ try:
+ args, kwargs = eval(expr, namespace, namespace)
+ except Exception as ex:
+ raise ArgumentTypeError("Unable to parse \"%s\" at index %d, error: %s" % (expr, index, str(ex)))
+
+ # Creating a list is necessary since this is a generator.
+ try:
+ dummy_result = list(handle_action(op, args, kwargs, only_validate=True))
+ except ArgumentTypeError as ex:
+ raise ArgumentTypeError("Invalid 'action' arguments \"%s\" at index %d, %s" % (value, index, str(ex)))
+ # Validation should never yield any events.
+ assert(not dummy_result)
+
+ return (op, args, kwargs)
+
+ def __call__(self, parser, namespace, values, option_string=None):
+ setattr(
+ namespace,
+ self.dest, [
+ self._parse_value(value, index)
+ for index, value in enumerate(values)
+ ],
+ )
+
+
+def argparse_create():
+ import inspect
+ import textwrap
+
+ # When --help or no args are given, print this help
+ parser = argparse.ArgumentParser(
+ description=__doc__,
+ formatter_class=argparse.RawTextHelpFormatter,
+ )
+
+ # Collect doc-strings from static methods in `actions`.
+ actions_docstring = []
+ for action_key in ACTION_DIR:
+ action = getattr(action_handlers, action_key)
+ args = str(inspect.signature(action))
+ args = "(" + args[1:].removeprefix("*, ")
+ args = args.replace(", *, ", ", ") # Needed in case the are positional arguments.
+ args = args.replace(", only_validate=False", "")
+
+ actions_docstring.append("- %s%s\n" % (action_key, args))
+ docs = textwrap.dedent((action.__doc__ or "").lstrip("\n").rstrip()) + "\n\n"
+
+ actions_docstring.append(textwrap.indent(docs, " "))
+
+ parser.add_argument(
+ "--actions",
+ dest="actions",
+ metavar='ACTIONS', type=str,
+ help=(
+ "\n" "Arguments must use one of the following prefix:\n"
+ "\n" + "".join(actions_docstring)
+ ),
+ nargs='+',
+ required=True,
+ action=BlenderAction,
+ )
+
+ return parser
+
+
+# -----------------------------------------------------------------------------
+# Default Startup
+
+
+def setup_default_preferences(prefs):
+ """
+ Set preferences useful for automation.
+ """
+ prefs.view.show_splash = False
+ prefs.view.smooth_view = 0
+ prefs.view.use_save_prompt = False
+ prefs.view.show_developer_ui = True
+ prefs.filepaths.use_auto_save_temporary_files = False
+
+
+# -----------------------------------------------------------------------------
+# Main Function
+
+
+def main_event_iter(*, action_list):
+ """
+ Yield all events from action handlers.
+ """
+ area = find_main_area()
+
+ x_init = area.x + (area.width // 2)
+ y_init = area.y + (area.height // 2)
+
+ yield dict(type='MOUSEMOVE', value='NOTHING', x=x_init, y=y_init)
+
+ for (op, args, kwargs) in action_list:
+ yield from handle_action(op, args, kwargs)
+
+
+def main():
+ from sys import argv
+ argv = argv[argv.index("--") + 1:] if "--" in argv else []
+
+ try:
+ args = argparse_create().parse_args(argv)
+ except ArgumentTypeError as ex:
+ print(ex)
+ sys.exit(1)
+
+ setup_default_preferences(bpy.context.preferences)
+
+ run_event_simulate(event_iter=main_event_iter(action_list=args.actions))
+
+
+if __name__ == "__main__":
+ main()