blender/tests/python/ui_simulate/test_undo.py
Campbell Barton 69f5d770c4 Core: support for mixing global undo with other undo systems
Address a long standing limitation with undo: where edit-mode often
prevented edits in other areas from being tracked by undo.

Allows properties in the object/scene to be adjusted while in edit-mode
supporting undo for both edit-mode and global undo steps to co-exist.

Details:

- This works by encoding undo with a "hint"
  that the change includes MEMFILE undo changes.
  - This hint can be created from property edits -
    where we know which kind of property is being edited.
  - It can also be calculated before adding a generic undo step,
    to check if other data needs a global undo step
    (editing a scene or object while in edit-mode for e.g.).
- The undo system checks for this hint and adds a hidden "pre"
  MEMFILE undo step in these cases
  (the initial MEMFILE undo step is already done at times,
  we make use of that).
- Eventually the mode-specific undo step creation can be skipped,
  this has been left out as it's a larger change which impacts
  each undo system.
- Added tests to support mixing edit-mode mesh and 3d text
  with MEMFILE undo.
- When pushing undo steps a check for changes outside
  the mode is performed (in parallel, per ID type),
  in my own tests the ID scanning overhead is negligible,
  even on heavy files.

Resolves: #50423, #75013, #153282.

Ref !161566
2026-08-11 04:01:33 +00:00

1235 lines
46 KiB
Python

# 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')