# SPDX-FileCopyrightText: 2019-2023 Blender Authors # # SPDX-License-Identifier: GPL-2.0-or-later """ This file does not run anything, its methods are accessed for tests by ``run_blender_setup.py``. """ import modules.ui_test_utils as ui # FIXME: Since 2.8 or so, there is a problem with simulated events # where a popup needs the main-loop to cycle once before new events # are handled. This isn't great but seems not to be a problem for users? _MENU_CONFIRM_HACK = True # ----------------------------------------------------------------------------- # Utilities def _view3d_object_calc_screen_space_location(window, name: str): from bpy_extras.view3d_utils import location_3d_to_region_2d area = ui.get_window_area_by_type(window, 'VIEW_3D') region = next((region for region in area.regions if region.type == 'WINDOW')) rv3d = region.data ob = window.view_layer.objects[name] co = location_3d_to_region_2d(region, rv3d, ob.matrix_world.translation) return int(co[0]), int(co[1]) def _view3d_object_select_by_name(e, name: str): location = _view3d_object_calc_screen_space_location(e.window, name) e.cursor_position_set(*location, move=True) # e.shift.rightmouse.tap() # Set the cursor so it's possible to see what was selected. yield e.ctrl.leftmouse.tap() yield def _setup_window_areas_from_ui_types(e, ui_types): assert len(e.window.screen.areas) == 1 total_areas = len(ui_types) i = 0 while len(e.window.screen.areas) < total_areas: areas = list(e.window.screen.areas) for area in areas: event_xy = ui.get_area_center(area) e.cursor_position_set(x=event_xy[0], y=event_xy[1], move=True) # areas_len_prev = len(e.window.screen.areas) if (i % 2) == 0: yield from ui.call_menu(e, "View -> Area -> Horizontal Split") else: yield from ui.call_menu(e, "View -> Area -> Vertical Split") e.leftmouse.tap() yield # areas_len_curr = len(e.window.screen.areas) # assert areas_len_curr != areas_len_prev if len(e.window.screen.areas) >= total_areas: break i += 1 # Use direct assignment, it's possible to use shortcuts for most area types, it's tedious. for ty, area in zip(ui_types, e.window.screen.areas, strict=True): area.ui_type = ty yield def _print_undo_steps_and_line(): """ Keep even when unused, handy for tracking down problems. """ from inspect import currentframe cf = currentframe() line = cf.f_back.f_lineno import bpy wm = bpy.data.window_managers[0] print(__file__ + ":" + str(line)) wm.print_undo_steps() def _bmesh_from_object(ob): import bmesh return bmesh.from_edit_mesh(ob.data) def _bounds_from_coords(coords): """ Return the min/max bounds of coordinates. """ return ( tuple(min(co[axis] for co in coords) for axis in range(3)), tuple(max(co[axis] for co in coords) for axis in range(3)), ) def _object_bounds(ob): """ Return an object's min/max bounds in local space. """ if ob.type == 'MESH' and ob.mode == 'EDIT': # The bounding box isn't isn't reliably updated until leaving edit-mode. return _bounds_from_coords([v.co for v in _bmesh_from_object(ob).verts]) return _bounds_from_coords(ob.bound_box) # ----------------------------------------------------------------------------- # Text Editor def _text_editor_startup(e): yield e.shift.f11() # Text editor. yield e.ctrl.alt.space() # Full-screen. yield e.alt.n() # New text. def _text_editor_and_3dview_startup(e, window): # Add text block in properties editors. pos_text = ui.get_area_center_from_spacetype(window, 'PROPERTIES') e.cursor_position_set(*pos_text, move=True) yield e.shift.f11() # Text editor. yield e.alt.n() # New text. def text_editor_simple(): e, t, _ = ui.test_window() import bpy yield from _text_editor_startup(e) text = bpy.data.texts[0] yield e.text("Hello\nWorld") t.assertEqual(text.as_string(), "Hello\nWorld") yield e.shift.home().ctrl.x().back_space() yield e.home().ctrl.v().ret() t.assertEqual(text.as_string(), "World\nHello") yield e.ctrl.a().tab() t.assertEqual(text.as_string(), " World\n Hello") yield e.ctrl.z(5) t.assertEqual(text.as_string(), "Hello\nWorld") def text_editor_edit_mode_mix(): # Ensure text edits and mesh edits can co-exist properly (see: T66658). e, t, window = ui.test_window() import bpy yield from _text_editor_and_3dview_startup(e, window) text = bpy.data.texts[0] pos_text = ui.get_area_center_from_spacetype(window, 'TEXT_EDITOR') pos_v3d = ui.get_area_center_from_spacetype(window, 'VIEW_3D') # View 3D: edit-mode e.cursor_position_set(*pos_v3d, move=True) yield from ui.call_menu(e, "Add -> Mesh -> Cube") yield e.numpad_period() # View all. yield e.tab() # Edit mode. yield e.a() # Select all. # Text: add text 'AA'. e.cursor_position_set(*pos_text, move=True) yield e.text("AA") t.assertEqual(text.as_string(), "AA") # View 3D: duplicate & move. e.cursor_position_set(*pos_v3d, move=True) yield e.shift.d().x().text("3").ret() yield e.g().z().text("1").ret() t.assertEqual(len(_bmesh_from_object(window.view_layer.objects.active).verts), 8 * 2) e.home() # Text: add text 'BB' e.cursor_position_set(*pos_text, move=True) yield e.text("BB") t.assertEqual(text.as_string(), "AABB") # View 3D: duplicate & move. e.cursor_position_set(*pos_v3d, move=True) yield e.shift.d().x().text("3").ret() yield e.g().z().text("1").ret() e.home() t.assertEqual(len(_bmesh_from_object(window.view_layer.objects.active).verts), 8 * 3) # Text: add text 'CC' e.cursor_position_set(*pos_text, move=True) yield e.text("CC") t.assertEqual(text.as_string(), "AABBCC") # View 3D: duplicate & move. e.cursor_position_set(*pos_v3d, move=True) yield e.shift.d().x().text("3").ret() yield e.g().z().text("1").ret() e.home() t.assertEqual(len(_bmesh_from_object(window.view_layer.objects.active).verts), 8 * 4) # Undo and check the state is valid. yield e.ctrl.z(4) t.assertEqual(len(_bmesh_from_object(window.view_layer.objects.active).verts), 8 * 3) t.assertEqual(text.as_string(), "AABB") yield e.ctrl.z(4) t.assertEqual(len(_bmesh_from_object(window.view_layer.objects.active).verts), 8 * 2) t.assertEqual(text.as_string(), "AA") yield e.ctrl.z(4) t.assertEqual(len(_bmesh_from_object(window.view_layer.objects.active).verts), 8) t.assertEqual(text.as_string(), "") # Finally redo all. yield e.ctrl.shift.z(4 * 3) t.assertEqual(len(_bmesh_from_object(window.view_layer.objects.active).verts), 8 * 4) t.assertEqual(text.as_string(), "AABBCC") # ----------------------------------------------------------------------------- # Node Editor def _compositor_startup_area(e): """ Set up the compositor node editor """ yield e.shift.f3(2) # Compositor # yield e.ctrl.alt.space() # Full-screen. def compositor_make_group(): import bpy e, t, window = ui.test_window() yield from _compositor_startup_area(e) # Create a node tree with multiple nodes and select all nodes. # TODO: Node tree should be created through the UI node_group = bpy.data.node_groups.new(name="comp ntree", type="CompositorNodeTree") window.scene.compositing_node_group = node_group yield from ui.call_menu(e, "Add -> Color -> Alpha Convert") yield e.ret() # Confirm adding node. yield from ui.call_menu(e, "Add -> Filter -> Filter") yield e.ret() yield e.a() # Select all. t.assertEqual(len(window.scene.compositing_node_group.nodes), 2) yield e.ctrl.g() # Make group. t.assertEqual(len(window.scene.compositing_node_group.nodes), 1) yield e.ctrl.z() t.assertEqual(len(window.scene.compositing_node_group.nodes), 2) yield e.ctrl.z(5) # Revert to original state # ----------------------------------------------------------------------------- # 3D View def _view3d_startup_area_maximized(e): """ Set the 3D viewport and set the area full-screen so no other regions. """ yield e.shift.f5() # 3D Viewport. yield e.ctrl.alt.space() # Full-screen. yield e.a() # Select all. yield e.delete().ret() # Delete all. def _view3d_startup_area_single(e): """ Create a single area (not full screen) this has the advantage that the window can be duplicated (not the case with a full-screened area). """ yield e.shift.f5() # 3D Viewport. yield e.a() # Select all. yield e.delete().ret() # Delete all. for _ in range(len(e.window.screen.areas)): # 3D Viewport. event_xy = ui.get_area_center_from_spacetype(e.window, e.window.screen.areas[0].type) e.cursor_position_set(x=event_xy[0], y=event_xy[1], move=True) yield e.shift.f5() yield from ui.call_menu(e, "View -> Area -> Close Area") assert len(e.window.screen.areas) == 1 def view3d_mesh_edit_shape_key(): """Test drawing the final shape-key BMesh wrapper in Edit Mode and basic undo/redo.""" e, t, window = ui.test_window() yield e.shift.f5() # 3D Viewport. yield e.ctrl.alt.space() # Full-screen. def assert_state(mode, key_count, x_bounds): cube = window.view_layer.objects.active t.assertEqual(cube.name, "Cube") t.assertEqual(cube.mode, mode) shape_keys = cube.data.shape_keys t.assertEqual(0 if shape_keys is None else len(shape_keys.key_blocks), key_count) t.assertEqual( (min(co[0] for co in cube.bound_box), max(co[0] for co in cube.bound_box)), x_bounds, ) assert_state('OBJECT', 0, (-1.0, 1.0)) yield from ui.call_operator(e, "Add Shape Key") # Basis. yield from ui.call_operator(e, "Add Shape Key") # Key 1. yield e.tab() # Edit mode. assert_state('EDIT', 2, (-1.0, 1.0)) yield # Draw the evaluated BMesh wrapper. yield e.a() # Select all. yield e.g().x().two().ret() # Translate the active shape key. yield e.tab() # Object mode. assert_state('OBJECT', 2, (1.0, 3.0)) yield from ui.call_operator(e, "Apply to Basis Key") yield e.tab() # Edit mode. yield # Draw the final evaluated BMesh wrapper. assert_state('EDIT', 1, (1.0, 3.0)) yield e.ctrl.z(8) # Undo to the initial state. assert_state('OBJECT', 0, (-1.0, 1.0)) yield e.ctrl.shift.z(8) # Redo to the final state. yield # Redraw after restoring Edit Mode. assert_state('EDIT', 1, (1.0, 3.0)) def view3d_simple(): e, t, window = ui.test_window() yield from _view3d_startup_area_maximized(e) # NOTE: it should be possible to consider "Add -> Mesh -> Plane" an exact match. # However, shortcuts are now included so without them this ends up fuzzy-matching to "Add -> Image -> Mesh Plane". # To resolve that it's necessary to match the entire shortcut which... changes based on the platform (sign!). use_menu_search_workaround = True if use_menu_search_workaround: import sys yield from ui.call_menu(e, "Add ({:s} A) -> Mesh -> Plane".format( "\u21e7" if sys.platform == "darwin" else "Shift" )) del sys else: # It would be nice if this could be restored. yield from ui.call_menu(e, "Add -> Mesh -> Plane") # Duplicate and rotate. for _ in range(3): yield e.shift.d().x().text("3").ret() yield e.r.z().text("15").ret() t.assertEqual(len(window.view_layer.objects), 4) yield e.a() # Select all. yield e.numpad_7().numpad_period() # View top. yield e.ctrl.j() # Join. t.assertEqual(len(window.view_layer.objects), 1) yield e.tab() # Edit mode. yield from ui.call_menu(e, "Edge -> Subdivide") yield e.tab() # Object mode. t.assertEqual(len(window.view_layer.objects.active.data.polygons), 16) yield e.ctrl.z(12) # Undo until start. t.assertEqual(len(window.view_layer.objects), 0) yield e.ctrl.shift.z(12) # Redo until end. t.assertEqual(len(window.view_layer.objects.active.data.polygons), 16) # Utility to extract current mesh coordinates (used to ensure undo/redo steps are applied properly). def _extract_mesh_positions(window): # TODO: Find/add a way to get that info when there is a multires active in Sculpt mode. window.view_layer.update() tmp_mesh = window.view_layer.objects.active.to_mesh(preserve_all_data_layers=True) tmp_cos = [0.0] * len(tmp_mesh.vertices) * 3 tmp_mesh.vertices.foreach_get("co", tmp_cos) window.view_layer.objects.active.to_mesh_clear() return tmp_cos def view3d_sculpt_with_memfile_step(): e, t, window = ui.test_window() yield from _view3d_startup_area_maximized(e) yield from ui.call_menu(e, "Add -> Mesh -> Torus") # Note: this could also be replaced by adding the multires modifier (see comment below). yield e.tab() # Enter Edit mode. yield e.ctrl.e().d() # Subdivide. yield e.ctrl.e().d() # Subdivide. yield e.tab() # Leave Edit mode. yield e.numpad_period() # View all. yield e.ctrl.tab().s() # Sculpt via pie menu. # Add a 'memfile' undo step without leaving Sculpt mode. yield e.f3().text("add const").ret().d() # Add 'Limit Distance' constraint. # Note: Multires modifier exhibits even more issues with undo/redo in sculpt mode, but unfortunately geometry is not # available from python anymore while in sculpt mode, so we cannot test/check if undo/redo steps apply properly. # yield e.ctrl.two() # Add multires modifier. mesh_verts_cos_before_sculpt = _extract_mesh_positions(window) # Add a first sculpt stroke. yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_x(window)) mesh_verts_cos_sculpt_stroke1 = _extract_mesh_positions(window) t.assertNotEqual(mesh_verts_cos_before_sculpt, mesh_verts_cos_sculpt_stroke1) # Add a second sculpt stroke. yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_y(window)) mesh_verts_cos_sculpt_stroke2 = _extract_mesh_positions(window) t.assertNotEqual(mesh_verts_cos_sculpt_stroke1, mesh_verts_cos_sculpt_stroke2) # Undo to first sculpt stroke. yield e.ctrl.z() mesh_verts_cos = _extract_mesh_positions(window) t.assertEqual(mesh_verts_cos, mesh_verts_cos_sculpt_stroke1) # Undo to memfile step (add constraint), fine here (T82532), # but would fail if we had added a Multires modifier instead (T82851). yield e.ctrl.z() mesh_verts_cos = _extract_mesh_positions(window) t.assertEqual(mesh_verts_cos, mesh_verts_cos_before_sculpt) # Redo first sculpt stroke, would now be undone (in Multires case, T82851), # or not redone (in constraint case, T82532). yield e.ctrl.shift.z() mesh_verts_cos = _extract_mesh_positions(window) t.assertEqual(mesh_verts_cos, mesh_verts_cos_sculpt_stroke1) # Redo second sculpt stroke, would redo properly, # as well as part of the first one that affects the same nodes (T82851, T82532). yield e.ctrl.shift.z() mesh_verts_cos = _extract_mesh_positions(window) t.assertEqual(mesh_verts_cos, mesh_verts_cos_sculpt_stroke2) def view3d_sculpt_dyntopo_simple(): e, t, window = ui.test_window() yield from _view3d_startup_area_maximized(e) yield from ui.call_menu(e, "Add -> Mesh -> Torus") # Avoid dynamic topology prompt. yield from ui.call_operator(e, "Remove UV Map") if _MENU_CONFIRM_HACK: yield yield e.r().y().text("45").ret() # Rotate Y 45. yield e.ctrl.a().r() # Apply rotation. yield e.numpad_period() # View all. yield e.ctrl.tab().s() # Sculpt via pie menu. yield from ui.call_menu(e, "Sculpt -> Dynamic Topology Toggle") # TODO: should be accessible from menu. yield from ui.call_operator(e, "Symmetrize") yield e.ctrl.tab().o() # Object mode. t.assertEqual(len(window.view_layer.objects.active.data.polygons), 1258) yield e.delete() # Delete the object. yield e.ctrl.z() # Undo... yield e.ctrl.z() # Undo used to crash here: T60974 t.assertEqual(len(window.view_layer.objects.active.data.polygons), 1258) t.assertEqual(window.view_layer.objects.active.mode, 'SCULPT') def view3d_sculpt_dyntopo_and_edit(): e, _, window = ui.test_window() yield from _view3d_startup_area_maximized(e) yield from ui.call_menu(e, "Add -> Mesh -> Torus") yield e.numpad_period() # View all. yield from ui.call_operator(e, "Remove UV Map") yield e.ctrl.tab().s() # Sculpt via pie menu. yield e.ctrl.d().ret() # Dynamic topology. # TODO: should be accessible from menu. yield from ui.call_operator(e, "Symmetrize") # Some painting (demo it works, not needed for the crash) yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_x(window)) yield e.tab() # Edit mode. yield e.tab() # Object mode. yield e.ctrl.z(3) # Undo # yield e.ctrl.z() # Undo asserts (nested undo call from dyntopo) def view3d_sculpt_trim(): """ Test that trim functionality can be undone and redone correctly. Operations that work on the entire mesh exercise a different code path from normal sculpt undo. """ e, t, window = ui.test_window() yield from _view3d_startup_area_maximized(e) yield from ui.call_menu(e, "Add -> Mesh -> Torus") yield e.numpad_period() # View all. yield from ui.call_operator(e, "Remove UV Map") yield e.ctrl.tab().s() # Sculpt via pie menu. beginning_positions = _extract_mesh_positions(window) yield from ui.call_operator(e, "Box Trim") yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_xy(window)) # Perform the trim after_trim_positions = _extract_mesh_positions(window) t.assertNotEqual(beginning_positions, after_trim_positions) yield e.ctrl.z() # Undo Trim after_undo_positions = _extract_mesh_positions(window) t.assertEqual(beginning_positions, after_undo_positions) yield e.ctrl.shift.z() # Redo Trim after_redo_positions = _extract_mesh_positions(window) t.assertEqual(after_trim_positions, after_redo_positions) def view3d_sculpt_dyntopo_stroke_toggle(): e, t, window = ui.test_window() yield from _view3d_startup_area_maximized(e) yield from ui.call_menu(e, "Add -> Mesh -> Torus") yield e.numpad_period() # View all. yield from ui.call_operator(e, "Remove UV Map") yield e.ctrl.tab().s() # Sculpt via pie menu. original_positions = _extract_mesh_positions(window) yield from ui.call_operator(e, "Dynamic Topology") # On yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_x(window)) yield from ui.call_operator(e, "Dynamic Topology") # Off after_toggle_off = _extract_mesh_positions(window) t.assertNotEqual(original_positions, after_toggle_off) yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_y(window)) after_normal_stroke = _extract_mesh_positions(window) t.assertNotEqual(after_toggle_off, after_normal_stroke) yield e.ctrl.z() # Undo Stroke after_first_undo = _extract_mesh_positions(window) t.assertEqual(after_first_undo, after_toggle_off) yield e.ctrl.z() # Undo Toggle Off yield e.ctrl.z() # Undo Dyntopo Stroke yield e.ctrl.z() # Undo Toggle On after_full_undo = _extract_mesh_positions(window) t.assertEqual(after_full_undo, original_positions) yield e.ctrl.shift.z() # Redo Toggle On yield e.ctrl.shift.z() # Redo Dyntopo Stroke yield e.ctrl.shift.z() # Redo Toggle Off after_toggle_off_redo = _extract_mesh_positions(window) t.assertEqual(after_toggle_off_redo, after_toggle_off) yield e.ctrl.shift.z() # Redo Normal Stroke after_normal_stroke_redo = _extract_mesh_positions(window) t.assertEqual(after_normal_stroke, after_normal_stroke_redo) def view3d_sculpt_with_empty_memfile_step(): e, t, window = ui.test_window() yield from _view3d_startup_area_maximized(e) yield from ui.call_menu(e, "Add -> Mesh -> Torus") yield e.ctrl.tab().s() # Sculpt via pie menu. original_positions = _extract_mesh_positions(window) yield e.ctrl.one() # Add and set multires level 1 yield e.ctrl.one() # Effective no-op that creates undo step # Previously, this would crash due to improper `SculptSession` management, see #152087 yield e.ctrl.z() after_undo_positions = _extract_mesh_positions(window) t.assertEqual(original_positions, after_undo_positions) def view3d_texture_paint_simple(): e, t, window = ui.test_window() yield from _view3d_startup_area_maximized(e) yield from ui.call_menu(e, "Add -> Mesh -> Monkey") yield e.numpad_period() # View monkey yield e.ctrl.tab().t() # Paint via pie menu. yield from ui.call_operator(e, "Add Texture Paint Slot") yield e.ret() # Accept popup. yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_x(window)) yield e.ctrl.z(2) # Undo: initial texture paint. t.assertEqual(window.view_layer.objects.active.mode, 'TEXTURE_PAINT') yield e.ctrl.z() # Undo: object mode. t.assertEqual(window.view_layer.objects.active.mode, 'OBJECT') yield e.ctrl.shift.z(2) # Redo: initial blank canvas. t.assertEqual(window.view_layer.objects.active.mode, 'TEXTURE_PAINT') yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_x(window)) yield e.ctrl.z() # Used to crash T61172. def view3d_texture_paint_complex(): import bpy # More complex test than `view3d_texture_paint_simple`, # including interleaved memfile steps, # and a call to history to undo several steps at once. e, t, window = ui.test_window() yield from _view3d_startup_area_maximized(e) yield from ui.call_menu(e, "Add -> Mesh -> Monkey") yield e.numpad_period() # View monkey yield e.ctrl.tab().t() # Paint via pie menu. yield from ui.call_operator(e, "Add Texture Paint Slot") yield e.ret() # Accept popup. initial_data = tuple(bpy.data.images['Suzanne Base Color'].pixels) yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_x(window)) yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_y(window)) after_strokes = tuple(bpy.data.images['Suzanne Base Color'].pixels) t.assertTrue(any([orig != new for (orig, new) in zip(initial_data, after_strokes)]), "At least one pixel should differ in color component") yield from ui.call_operator(e, "Add Texture Paint Slot") yield e.ret() # Accept popup. yield from ui.call_operator(e, "Add Modifier") yield e.a() # Array modifier t.assertEqual(len(bpy.context.active_object.modifiers), 1, "One modifier should exist") yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_x(window)) yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_y(window)) yield e.ctrl.z(6) # Undo: second slot added. t.assertEqual(len(bpy.context.active_object.modifiers), 0, "No modifiers should exist") after_undo = tuple(bpy.data.images['Suzanne Base Color'].pixels) t.assertTrue(all([orig == new for (orig, new) in zip(initial_data, after_undo)]), "All pixels should be the same as their original state") yield e.ctrl.z(1) # Undo: initial texture paint. t.assertEqual(window.view_layer.objects.active.mode, 'TEXTURE_PAINT') yield e.ctrl.z() # Undo: object mode. t.assertEqual(window.view_layer.objects.active.mode, 'OBJECT') yield e.ctrl.shift.z(2) # Redo: initial blank canvas. t.assertEqual(window.view_layer.objects.active.mode, 'TEXTURE_PAINT') yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_x(window)) yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_y(window)) yield from ui.call_operator(e, "Undo History") yield e.o() # Undo everything to Original step. t.assertEqual(window.view_layer.objects.active.mode, 'OBJECT') def view3d_particle_edit_undo_from_texture_paint(): import bpy e, t, window = ui.test_window() yield from _view3d_startup_area_maximized(e) def assert_particle_edit_valid(): ob = bpy.context.active_object t.assertEqual(ob.mode, 'PARTICLE_EDIT') t.assertEqual(bpy.context.mode, 'PARTICLE') psys = ob.particle_systems.active t.assertIsNotNone(psys) t.assertEqual(psys.settings.type, 'HAIR') t.assertGreater(len(psys.particles), 0) t.assertGreater(len(psys.particles[0].hair_keys), 0) t.assertTrue(bpy.ops.particle.select_all.poll()) yield from ui.call_menu(e, "Add -> Mesh -> Cube") ob = window.view_layer.objects.active yield e.ctrl.tab().t() # Texture Paint via the mode pie. yield from ui.call_operator(e, "Add Texture Paint Slot") yield e.ret() # Accept popup. yield e.ctrl.tab().o() # Object mode. yield from ui.call_operator(e, "Add Particle System Slot") psys = ob.particle_systems.active psys.settings.type = 'HAIR' psys.settings.count = 8 psys.settings.hair_length = 1.0 window.view_layer.update() yield # Include the programmatic setup in a memfile undo step through a normal user action. yield e.r.z().text("15").ret() yield from ui.call_operator(e, "Particle Edit Toggle") assert_particle_edit_valid() # Establish particle-edit undo steps through the same shortcuts a user invokes. yield e.alt.a() # Deselect all. yield e.a() # Select all. yield e.ctrl.tab().t() # Texture Paint via the mode pie. t.assertEqual(ob.mode, 'TEXTURE_PAINT') # This used to combine Particle Edit with Texture Paint into invalid mode value 48. yield e.ctrl.z() assert_particle_edit_valid() # Exercise the mode transition in both directions, then repeat the cross-mode undo. yield e.ctrl.shift.z() t.assertEqual(bpy.context.active_object.mode, 'TEXTURE_PAINT') yield e.ctrl.z() assert_particle_edit_valid() # Verify the reconstructed Particle Edit session still accepts user operations. yield e.alt.a() assert_particle_edit_valid() yield e.a() assert_particle_edit_valid() def view3d_mesh_edit_mode_object_mix(): """ Ensure global (MEMFILE) undo data is properly stored in mesh edit-mode. """ import bpy e, t, window = ui.test_window() yield from _view3d_startup_area_maximized(e) def state_get(): # Re-read the objects, 'memfile' undo steps re-allocate ID's. objects = window.view_layer.objects return ( bpy.context.mode, tuple(objects["Camera"].location), _object_bounds(objects["Cube"]), ) def state_check(state_prev, state_curr, camera_changed): # Only the camera or the mesh may change, never both. (_, camera_prev, bounds_prev) = state_prev (mode_curr, camera_curr, bounds_curr) = state_curr t.assertEqual(mode_curr, 'EDIT_MESH') if camera_changed: t.assertNotEqual(camera_curr, camera_prev) t.assertEqual(bounds_curr, bounds_prev) else: t.assertEqual(camera_curr, camera_prev) t.assertNotEqual(bounds_curr, bounds_prev) yield from ui.call_menu(e, "Add -> Camera") yield from ui.call_menu(e, "Add -> Mesh -> Cube") yield e.numpad_period() # View all. # NOTE: framing the camera uses evaluated geometry, # there is a bug in Blender currently that this is skipped for edit-mode geometry. # Adding the simple deform modifier works around this bug. yield from ui.call_menu(e, "Modifiers -> Add Modifier -> Deform -> Simple Deform") t.assertEqual([mod.type for mod in window.view_layer.objects["Cube"].modifiers], ['SIMPLE_DEFORM']) yield e.tab() # Edit mode. steps = [state_get()] t.assertEqual(steps[0][0], 'EDIT_MESH') for _ in range(3): yield e.g().z().text("1").ret() # Edit-mesh change. steps.append(state_get()) state_check(steps[-2], steps[-1], camera_changed=False) # Object-mode change. yield from ui.call_menu(e, "View -> Align View -> Align Active Camera to Selected") steps.append(state_get()) state_check(steps[-2], steps[-1], camera_changed=True) # Undo each step, checking the recorded state. for i in reversed(range(len(steps) - 1)): yield e.ctrl.z() t.assertEqual(state_get(), steps[i]) # Redo each step. for i in range(1, len(steps)): yield e.ctrl.shift.z() t.assertEqual(state_get(), steps[i]) def view3d_mesh_edit_separate(): e, t, window = ui.test_window() yield from _view3d_startup_area_maximized(e) yield from ui.call_menu(e, "Add -> Mesh -> Cube") yield e.numpad_period() # View all. yield e.tab() # Edit mode. yield e.shift.d() # Duplicate... yield e.x().text("3").ret() # Move X-3. yield e.p().s() # Separate selection. t.assertEqual(len(window.view_layer.objects), 2) yield e.ctrl.z() # Undo. t.assertEqual(len(window.view_layer.objects), 1) yield e.tab() # Object mode. t.assertEqual(len(window.view_layer.objects.active.data.polygons), 12) yield e.tab() # Edit mode. yield e.ctrl.i() # Invert selection. yield e.p().s() # Separate selection. yield e.tab() # Object mode. t.assertEqual([len(ob.data.polygons) for ob in window.view_layer.objects], [6, 6]) yield e.ctrl.z(8) # Undo until start. t.assertEqual(len(window.view_layer.objects), 0) yield e.ctrl.shift.z(8) # Redo until end. t.assertEqual([len(ob.data.polygons) for ob in window.view_layer.objects], [6, 6]) def view3d_mesh_particle_edit_mode_simple(): e, t, window = ui.test_window() yield from _view3d_startup_area_maximized(e) yield from ui.call_menu(e, "Add -> Mesh -> Cube") yield e.r.z().text("15").ret() # Single object-mode action (to test mixing different kinds of undo steps). yield from ui.call_menu(e, "Object -> Quick Effects -> Quick Fur") yield e.ctrl.tab().s() # Particle sculpt mode. t.assertEqual(window.view_layer.objects.active.mode, 'SCULPT_CURVES') # Brush strokes. yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_y(window)) yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_x(window)) # Undo and redo. yield e.ctrl.z(5) t.assertEqual(window.view_layer.objects.active.mode, 'OBJECT') yield e.shift.ctrl.z(5) t.assertEqual(window.view_layer.objects.active.mode, 'SCULPT_CURVES') # Brush strokes. yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_y(window)) yield from e.leftmouse.cursor_motion(ui.cursor_motion_data_x(window)) yield e.ctrl.z(7) t.assertEqual(window.view_layer.objects.active.mode, 'OBJECT') yield e.shift.ctrl.z(7) def view3d_font_edit_mode_object_mix(): """ Ensure global (MEMFILE) undo data is properly stored for 3D text in edit-mode. """ import bpy e, t, window = ui.test_window() yield from _view3d_startup_area_maximized(e) def state_get(): objects = window.view_layer.objects return ( bpy.context.mode, tuple(objects["Camera"].location), _object_bounds(objects["Text"]), ) def state_check(state_prev, state_curr, camera_changed): # Only the camera or the text may change, never both. (_, camera_prev, bounds_prev) = state_prev (mode_curr, camera_curr, bounds_curr) = state_curr t.assertEqual(mode_curr, 'EDIT_TEXT') if camera_changed: t.assertNotEqual(camera_curr, camera_prev) t.assertEqual(bounds_curr, bounds_prev) else: t.assertEqual(camera_curr, camera_prev) t.assertNotEqual(bounds_curr, bounds_prev) yield from ui.call_menu(e, "Add -> Camera") yield from ui.call_menu(e, "Add -> Text") yield e.numpad_period() # View all. yield e.tab() # Edit mode. steps = [state_get()] t.assertEqual(steps[0][0], 'EDIT_TEXT') for chars in ("AB", "CD", "EF"): for ch in chars: yield e.text(ch) # Text change, each character is its own undo step. steps.append(state_get()) state_check(steps[-2], steps[-1], camera_changed=False) # Object-mode change. yield from ui.call_menu(e, "View -> Align View -> Align Active Camera to Selected") steps.append(state_get()) state_check(steps[-2], steps[-1], camera_changed=True) # Undo each step, checking the recorded state. for i in reversed(range(len(steps) - 1)): yield e.ctrl.z() t.assertEqual(state_get(), steps[i]) # Redo each step. for i in range(1, len(steps)): yield e.ctrl.shift.z() t.assertEqual(state_get(), steps[i]) def view3d_font_edit_mode_simple(): e, t, window = ui.test_window() yield from _view3d_startup_area_maximized(e) yield from ui.call_menu(e, "Add -> Text") yield e.numpad_period() # View all. yield e.tab() # Edit mode. yield e.ctrl.back_space() yield e.text("Hello\nWorld") yield e.tab() # Object mode. t.assertEqual(window.view_layer.objects.active.data.body, 'Hello\nWorld') yield e.r.x().text("90").ret() # Rotate 90, face the view. yield e.tab() # Edit mode. yield e.end() # Edit mode. yield e.ctrl.back_space() yield e.back_space() yield e.tab() # Object mode. t.assertEqual(window.view_layer.objects.active.data.body, 'Hello') yield e.ctrl.z(3) t.assertEqual(window.view_layer.objects.active.data.body, 'Hello\nWorld') yield e.shift.ctrl.z(3) t.assertEqual(window.view_layer.objects.active.data.body, 'Hello') def view3d_multi_mode_select(): # Note, this test should be extended to change modes for each object type. e, t, window = ui.test_window() yield from _view3d_startup_area_maximized(e) object_names = [] for i, (menu_search, ob_name) in enumerate(( ("Add -> Armature", "Armature"), ("Add -> Text", "Text"), ("Add -> Mesh -> Cube", "Cube"), ("Add -> Curve -> Bézier", "Curve"), ("Add -> Volume -> Empty", "Volume Empty"), ("Add -> Metaball -> Ball", "Metaball"), ("Add -> Lattice", "Lattice"), ("Add -> Light -> Point", "Point Light"), ("Add -> Camera", "Camera"), ("Add -> Empty -> Plain Axis", "Empty"), )): yield from ui.call_menu(e, menu_search) # Single object-mode action (to test mixing different kinds of undo steps). yield e.g.z().text(str(i * 2)).ret() # Rename. yield e.f2().text(ob_name).ret() object_names.append(window.view_layer.objects.active.name) yield from ui.call_menu(e, "View -> Frame All") # print(object_names) for ob_name in object_names: yield from _view3d_object_select_by_name(e, ob_name) yield # print() # print('=' * 40) # print(window.view_layer.objects.active.name, ob_name) for ob_name in reversed(object_names): t.assertEqual(ob_name, window.view_layer.objects.active.name) yield e.ctrl.z() def view3d_multi_mode_multi_window(): e_a, t, window_a = ui.test_window(0) yield from ui.call_menu(e_a, "Window -> New Main Window") e_b, _, window_b = ui.test_window(1) del _ yield from ui.call_menu(e_b, "New Scene") yield e_b.ret() if _MENU_CONFIRM_HACK: yield from ui.idle_until(lambda: window_a.view_layer != window_b.view_layer) t.assertNotEqual(window_a.view_layer, window_b.view_layer, "Windows should have different view layers") for e in (e_a, e_b): pos_v3d = ui.get_area_center_from_spacetype(e.window, 'VIEW_3D') e.cursor_position_set(x=pos_v3d[0], y=pos_v3d[1], move=True) del pos_v3d yield from _view3d_startup_area_maximized(e_a) yield from _view3d_startup_area_maximized(e_b) undo_current = 0 undo_state_empty = undo_current yield from ui.call_menu(e_a, "Add -> Torus") yield from ui.call_menu(e_b, "Add -> Monkey") undo_current += 2 # Weight paint via pie menu. yield e_a.ctrl.tab().w() yield e_b.ctrl.tab().w() undo_current += 2 undo_state_wpaint = undo_current t.assertEqual(window_a.view_layer.objects.active.mode, 'WEIGHT_PAINT') t.assertEqual(window_b.view_layer.objects.active.mode, 'WEIGHT_PAINT') # Object mode via pie menu. yield e_a.ctrl.tab().o() yield e_b.ctrl.tab().o() undo_current += 2 undo_state_non_empty_start = undo_current # Edit mode. yield e_a.tab() yield e_b.tab() undo_current += 2 vert_count_a_start = len(_bmesh_from_object(window_a.view_layer.objects.active).verts) vert_count_b_start = len(_bmesh_from_object(window_b.view_layer.objects.active).verts) yield from ui.call_menu(e_a, "Edge -> Subdivide") yield from ui.call_menu(e_b, "Edge -> Subdivide") undo_current += 2 yield e_a.r().y().text("45").ret() # Rotate Y 45. yield e_b.r().z().text("45").ret() # Rotate Z 45. undo_current += 2 # Object mode. yield e_a.tab() yield e_b.tab() undo_current += 2 # Object mode via pie menu. yield e_a.ctrl.tab().s() yield e_b.ctrl.tab().s() undo_current += 2 t.assertEqual(window_a.view_layer.objects.active.mode, 'SCULPT') t.assertEqual(window_b.view_layer.objects.active.mode, 'SCULPT') # Rotate 90. yield from ui.call_menu(e_a, "Sculpt -> Rotate") yield e_a.text("90").ret() yield from ui.call_menu(e_b, "Sculpt -> Rotate") yield e_b.text("90").ret() undo_current += 2 # Object mode. yield e_a.ctrl.tab().o() yield e_b.ctrl.tab().o() undo_current += 2 # Edit mode. yield e_a.tab() yield e_b.tab() undo_current += 2 yield from ui.call_menu(e_a, "Edge -> Subdivide") yield from ui.call_menu(e_b, "Edge -> Subdivide") undo_current += 2 vert_count_a_end = len(_bmesh_from_object(window_a.view_layer.objects.active).verts) vert_count_b_end = len(_bmesh_from_object(window_b.view_layer.objects.active).verts) t.assertEqual(vert_count_a_end, 9216) t.assertEqual(vert_count_b_end, 7830) yield e_a.r().y().text("45").ret() # Rotate Y 45. yield e_b.r().z().text("45").ret() # Rotate Z 45. undo_current += 2 yield e_a.tab() yield e_b.tab() undo_current += 2 undo_state_final = undo_current undo_delta = undo_state_final - undo_state_empty yield e_a.ctrl.z(undo_delta) undo_current -= undo_delta # Ensure scene is empty. t.assertEqual(len(window_a.view_layer.objects), 0) t.assertEqual(len(window_b.view_layer.objects), 0) undo_delta = undo_state_final - undo_state_empty yield e_a.ctrl.shift.z(undo_delta) undo_current += undo_delta t.assertEqual(window_a.view_layer.objects.active.mode, 'OBJECT') t.assertEqual(window_b.view_layer.objects.active.mode, 'OBJECT') t.assertEqual(len(window_a.view_layer.objects.active.data.vertices), vert_count_a_end) t.assertEqual(len(window_b.view_layer.objects.active.data.vertices), vert_count_b_end) undo_delta = undo_state_final - undo_state_wpaint yield e_a.ctrl.z(undo_delta) undo_current -= undo_delta t.assertEqual(window_a.view_layer.objects.active.mode, 'WEIGHT_PAINT') t.assertEqual(window_b.view_layer.objects.active.mode, 'WEIGHT_PAINT') undo_delta = undo_state_non_empty_start - undo_state_wpaint yield e_a.ctrl.shift.z(undo_delta) undo_current += undo_delta t.assertEqual(len(window_a.view_layer.objects.active.data.vertices), vert_count_a_start) t.assertEqual(len(window_b.view_layer.objects.active.data.vertices), vert_count_b_start) # Further checks could be added but this seems enough. def view3d_edit_mode_multi_window(): """ Use undo and redo with multiple windows in edit-mode, this test caused a crash with #110022. """ e_a, t, window_a = ui.test_window(0) # Nice but slower. use_all_area_ui_types = False # Use a large, single area so the window can be duplicated & split. yield from _view3d_startup_area_single(e_a) yield from ui.call_menu(e_a, "Window -> New Main Window") e_b, _, window_b = ui.test_window(1) del _ yield from ui.call_menu(e_b, "New Scene") yield e_b.ret() if _MENU_CONFIRM_HACK: yield from ui.idle_until(lambda: window_a.view_layer != window_b.view_layer) t.assertNotEqual(window_a.view_layer, window_b.view_layer, "Windows should have different view layers") for e in (e_a, e_b): pos_v3d = ui.get_area_center_from_spacetype(e.window, 'VIEW_3D') e.cursor_position_set(x=pos_v3d[0], y=pos_v3d[1], move=True) del pos_v3d undo_current = 0 yield from ui.call_menu(e_a, "Add -> Cone") yield from ui.call_menu(e_b, "Add -> Cylinder") undo_current += 2 # Edit mode. yield e_a.tab() yield e_b.tab() undo_current += 2 undo_state_edit_mode = undo_current vert_count_a_start = len(_bmesh_from_object(window_a.view_layer.objects.active).verts) vert_count_b_start = len(_bmesh_from_object(window_b.view_layer.objects.active).verts) yield e_a.r().y().text("45").ret() # Rotate Y 45. yield e_b.r().z().text("45").ret() # Rotate Z 45. undo_current += 2 yield from ui.call_menu(e_a, "Face -> Poke Faces") yield from ui.call_menu(e_b, "Face -> Poke Faces") undo_current += 2 yield from ui.call_menu(e_a, "Face -> Beautify Faces") yield from ui.call_menu(e_b, "Face -> Beautify Faces") undo_current += 2 yield from ui.call_menu(e_a, "Face -> Wireframe") yield from ui.call_menu(e_b, "Face -> Wireframe") undo_current += 2 vert_count_a_end = len(_bmesh_from_object(window_a.view_layer.objects.active).verts) vert_count_b_end = len(_bmesh_from_object(window_b.view_layer.objects.active).verts) # Object mode. yield e_a.tab() yield e_b.tab() undo_current += 2 # Finished with edits, assert undo is working as expected. yield e_a.ctrl.z(undo_current - undo_state_edit_mode) t.assertEqual(len(_bmesh_from_object(window_a.view_layer.objects.active).verts), vert_count_a_start) t.assertEqual(len(_bmesh_from_object(window_b.view_layer.objects.active).verts), vert_count_b_start) t.assertEqual(window_a.view_layer.objects.active.mode, 'EDIT') t.assertEqual(window_b.view_layer.objects.active.mode, 'EDIT') yield e_a.ctrl.shift.z(undo_current - undo_state_edit_mode) t.assertEqual(len(window_a.view_layer.objects.active.data.vertices), vert_count_a_end) t.assertEqual(len(window_b.view_layer.objects.active.data.vertices), vert_count_b_end) t.assertEqual(window_a.view_layer.objects.active.mode, 'OBJECT') t.assertEqual(window_b.view_layer.objects.active.mode, 'OBJECT') # Delete objects. yield e_a.delete() yield e_b.delete() undo_current += 2 yield e_b.ctrl.z(undo_current) # Ensure scene is empty. t.assertEqual(len(window_a.view_layer.objects), 0) t.assertEqual(len(window_b.view_layer.objects), 0) yield e_b.ctrl.shift.z(undo_current - 2) undo_current -= 2 t.assertEqual(len(window_a.view_layer.objects.active.data.vertices), vert_count_a_end) t.assertEqual(len(window_b.view_layer.objects.active.data.vertices), vert_count_b_end) t.assertEqual(window_a.view_layer.objects.active.mode, 'OBJECT') t.assertEqual(window_b.view_layer.objects.active.mode, 'OBJECT') # Second phase! # Split windows & show space types (could be a utility function). # Test undo / redo doesn't cause issues when showing different space types. if use_all_area_ui_types: # TODO: extracting the enum from an exception is not good. # As it's a dynamic enum it can't be accessed from `bl_rna.properties`. try: e_a.window.screen.areas[0].ui_type = '__INVALID__' except TypeError as ex: ui_types = ex.args[0] ui_types = eval(ui_types[ui_types.rfind("("):]) else: ui_types = ('VIEW_3D', 'PROPERTIES') for e in (e_a, e_b): yield from _setup_window_areas_from_ui_types(e, ui_types) # Ensure each undo step redraws. for _ in range(undo_current - undo_state_edit_mode): yield e_b.ctrl.z() t.assertEqual(len(_bmesh_from_object(window_a.view_layer.objects.active).verts), vert_count_a_start) t.assertEqual(len(_bmesh_from_object(window_b.view_layer.objects.active).verts), vert_count_b_start) t.assertEqual(window_a.view_layer.objects.active.mode, 'EDIT') t.assertEqual(window_b.view_layer.objects.active.mode, 'EDIT') # Ensure each undo step redraws. for _ in range(undo_current - undo_state_edit_mode): yield e_b.ctrl.shift.z() t.assertEqual(len(window_a.view_layer.objects.active.data.vertices), vert_count_a_end) t.assertEqual(len(window_b.view_layer.objects.active.data.vertices), vert_count_b_end) t.assertEqual(window_a.view_layer.objects.active.mode, 'OBJECT') t.assertEqual(window_b.view_layer.objects.active.mode, 'OBJECT')