The `a + array_len > in.len` check was off-by-1 whenever accessing a non-array property without raw access. This was because `array_len` was actually the array length of the property, which is `0` for non-array properties. Given an array which was too short, this would cause the slower loop to overrun the end of the array by one item. When getting items this would cause a crash on a debug build with `Fatal Python error: _PyMem_DebugRawFree: bad trailing pad byte`. So use `item_len` instead, wichi is always set to `1` for non-array properties. Also do not assume that an `array_len` of `0` means that the property is an array. While this may be true currently, it is cleaner and safer to use the dedicated RNA API to check that. This PR also adds some basic checks for expected failure of `foreach_set` /`foreach_get` API when the provided array is too small. Co-authored-by: Bastien Montagne <bastien@blender.org> Pull Request: https://projects.blender.org/blender/blender/pulls/115967
387 lines
13 KiB
Python
387 lines
13 KiB
Python
# SPDX-FileCopyrightText: 2020-2023 Blender Authors
|
|
#
|
|
# SPDX-License-Identifier: Apache-2.0
|
|
|
|
# ./blender.bin --background --python tests/python/bl_pyapi_prop_array.py -- --verbose
|
|
import bpy
|
|
from bpy.props import (
|
|
BoolVectorProperty,
|
|
FloatVectorProperty,
|
|
IntVectorProperty,
|
|
)
|
|
import unittest
|
|
import numpy as np
|
|
|
|
id_inst = bpy.context.scene
|
|
id_type = bpy.types.Scene
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Utility Functions
|
|
|
|
def seq_items_xform(data, xform_fn):
|
|
"""
|
|
Recursively expand items using ``xform_fn``.
|
|
"""
|
|
if hasattr(data, "__len__"):
|
|
return tuple(seq_items_xform(v, xform_fn) for v in data)
|
|
return xform_fn(data)
|
|
|
|
|
|
def seq_items_as_tuple(data):
|
|
"""
|
|
Return nested sequences as a nested tuple.
|
|
Useful when comparing different kinds of nested sequences.
|
|
"""
|
|
return seq_items_xform(data, lambda v: v)
|
|
|
|
|
|
def seq_items_as_dims(data):
|
|
"""
|
|
Nested length calculation, extracting the length from each sequence.
|
|
Where a 4x4 matrix returns ``(4, 4)`` for example.
|
|
"""
|
|
return ((len(data),) + seq_items_as_dims(data[0])) if hasattr(data, "__len__") else ()
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# Tests
|
|
|
|
class TestPropArray(unittest.TestCase):
|
|
def setUp(self):
|
|
id_type.test_array_f = FloatVectorProperty(size=10)
|
|
id_type.test_array_f_2d = FloatVectorProperty(size=(4, 1))
|
|
id_type.test_array_f_3d = FloatVectorProperty(size=(3, 2, 4))
|
|
id_type.test_array_i = IntVectorProperty(size=10)
|
|
id_type.test_array_i_2d = IntVectorProperty(size=(4, 1))
|
|
id_type.test_array_i_3d = IntVectorProperty(size=(3, 2, 4))
|
|
|
|
def tearDown(self):
|
|
del id_type.test_array_f
|
|
del id_type.test_array_f_2d
|
|
del id_type.test_array_f_3d
|
|
del id_type.test_array_i
|
|
del id_type.test_array_i_2d
|
|
del id_type.test_array_i_3d
|
|
|
|
@staticmethod
|
|
def parse_test_args(prop_array_first_dim, prop_type, prop_size):
|
|
match prop_type:
|
|
case 'INT':
|
|
expected_dtype = np.int32
|
|
wrong_kind_dtype = np.float32
|
|
wrong_size_dtype = np.int64
|
|
case 'FLOAT':
|
|
expected_dtype = np.float32
|
|
wrong_kind_dtype = np.int32
|
|
wrong_size_dtype = np.float64
|
|
case _:
|
|
raise AssertionError("Unexpected property type '%s'" % prop_type)
|
|
|
|
expected_length = np.prod(prop_size)
|
|
num_dims = len(prop_size)
|
|
|
|
assert expected_length > 0
|
|
too_short_length = expected_length - 1
|
|
|
|
match num_dims:
|
|
case 1:
|
|
def get_flat_iterable_all_dimensions():
|
|
return prop_array_first_dim[:]
|
|
case 2:
|
|
def get_flat_iterable_all_dimensions():
|
|
return (flat_elem for array_1d in prop_array_first_dim[:] for flat_elem in array_1d[:])
|
|
case 3:
|
|
def get_flat_iterable_all_dimensions():
|
|
return (flat_elem
|
|
for array_2d in prop_array_first_dim[:]
|
|
for array_1d in array_2d[:]
|
|
for flat_elem in array_1d[:])
|
|
case _:
|
|
raise AssertionError("Number of dimensions must be 1, 2 or 3, but was %i" % num_dims)
|
|
|
|
return (expected_dtype, wrong_kind_dtype, wrong_size_dtype, expected_length, too_short_length,
|
|
get_flat_iterable_all_dimensions)
|
|
|
|
def do_test_foreach_getset_current_dimension(self, prop_array, expected_dtype, wrong_kind_dtype, wrong_size_dtype,
|
|
expected_length, too_short_length, get_flat_iterable_all_dimensions):
|
|
with self.assertRaises(TypeError):
|
|
prop_array.foreach_set(range(too_short_length))
|
|
|
|
prop_array.foreach_set(range(5, 5 + expected_length))
|
|
|
|
with self.assertRaises(TypeError):
|
|
prop_array.foreach_set(np.arange(too_short_length, dtype=expected_dtype))
|
|
|
|
with self.assertRaises(TypeError):
|
|
prop_array.foreach_set(np.arange(expected_length, dtype=wrong_size_dtype))
|
|
|
|
with self.assertRaises(TypeError):
|
|
prop_array.foreach_get(np.arange(expected_length, dtype=wrong_kind_dtype))
|
|
|
|
a = np.arange(expected_length, dtype=expected_dtype)
|
|
prop_array.foreach_set(a)
|
|
|
|
with self.assertRaises(TypeError):
|
|
prop_array.foreach_set(a[:too_short_length])
|
|
|
|
for v1, v2 in zip(a, get_flat_iterable_all_dimensions()):
|
|
self.assertEqual(v1, v2)
|
|
|
|
b = np.empty(expected_length, dtype=expected_dtype)
|
|
prop_array.foreach_get(b)
|
|
for v1, v2 in zip(a, b):
|
|
self.assertEqual(v1, v2)
|
|
|
|
b = [None] * expected_length
|
|
prop_array.foreach_get(b)
|
|
for v1, v2 in zip(a, b):
|
|
self.assertEqual(v1, v2)
|
|
|
|
def do_test_foreach_getset(self, prop_array, prop_type, prop_size):
|
|
if not isinstance(prop_size, (tuple, list)):
|
|
prop_size = (prop_size,)
|
|
num_dimensions = len(prop_size)
|
|
|
|
test_args = self.parse_test_args(prop_array, prop_type, prop_size)
|
|
|
|
# Test that foreach_get/foreach_set work, and work the same regardless of the current dimension/sub-array being
|
|
# accessed.
|
|
self.do_test_foreach_getset_current_dimension(prop_array, *test_args)
|
|
if num_dimensions > 1:
|
|
for i in range(prop_size[0]):
|
|
self.do_test_foreach_getset_current_dimension(prop_array[i], *test_args)
|
|
if num_dimensions > 2:
|
|
for j in range(prop_size[1]):
|
|
self.do_test_foreach_getset_current_dimension(prop_array[i][j], *test_args)
|
|
|
|
def test_foreach_getset_i(self):
|
|
self.do_test_foreach_getset(id_inst.test_array_i, 'INT', 10)
|
|
|
|
def test_foreach_getset_f(self):
|
|
self.do_test_foreach_getset(id_inst.test_array_f, 'FLOAT', 10)
|
|
|
|
def test_foreach_getset_i_2d(self):
|
|
self.do_test_foreach_getset(id_inst.test_array_i_2d, 'INT', (4, 1))
|
|
|
|
def test_foreach_getset_f_2d(self):
|
|
self.do_test_foreach_getset(id_inst.test_array_f_2d, 'FLOAT', (4, 1))
|
|
|
|
def test_foreach_getset_i_3d(self):
|
|
self.do_test_foreach_getset(id_inst.test_array_i_3d, 'INT', (3, 2, 4))
|
|
|
|
def test_foreach_getset_f_3d(self):
|
|
self.do_test_foreach_getset(id_inst.test_array_f_3d, 'FLOAT', (3, 2, 4))
|
|
|
|
|
|
class TestPropArrayMultiDimensional(unittest.TestCase):
|
|
|
|
def setUp(self):
|
|
self._initial_dir = set(dir(id_type))
|
|
|
|
def tearDown(self):
|
|
for member in (set(dir(id_type)) - self._initial_dir):
|
|
delattr(id_type, member)
|
|
|
|
def test_defaults(self):
|
|
# The data is in int format, converted into float & bool to avoid duplication.
|
|
default_data = (
|
|
# 1D.
|
|
(1,),
|
|
(1, 2),
|
|
(1, 2, 3),
|
|
(1, 2, 3, 4),
|
|
# 2D.
|
|
((1,),),
|
|
((1,), (11,)),
|
|
((1, 2), (11, 22)),
|
|
((1, 2, 3), (11, 22, 33)),
|
|
((1, 2, 3, 4), (11, 22, 33, 44)),
|
|
# 3D.
|
|
(((1,),),),
|
|
((1,), (11,), (111,)),
|
|
((1, 2), (11, 22), (111, 222),),
|
|
((1, 2, 3), (11, 22, 33), (111, 222, 333)),
|
|
((1, 2, 3, 4), (11, 22, 33, 44), (111, 222, 333, 444)),
|
|
)
|
|
for data in default_data:
|
|
for (vector_prop_fn, xform_fn) in (
|
|
(BoolVectorProperty, lambda v: bool(v % 2)),
|
|
(FloatVectorProperty, lambda v: float(v)),
|
|
(IntVectorProperty, lambda v: v),
|
|
):
|
|
data_native = seq_items_xform(data, xform_fn)
|
|
size = seq_items_as_dims(data)
|
|
id_type.temp = vector_prop_fn(size=size, default=data_native)
|
|
data_as_tuple = seq_items_as_tuple(id_inst.temp)
|
|
self.assertEqual(data_as_tuple, data_native)
|
|
del id_type.temp
|
|
|
|
def test_matrix(self):
|
|
data = ((1, 2, 3, 4), (11, 22, 33, 44), (111, 222, 333, 444), (1111, 2222, 3333, 4444),)
|
|
data_native = seq_items_xform(data, lambda v: float(v))
|
|
id_type.temp = FloatVectorProperty(size=(4, 4), subtype='MATRIX', default=data_native)
|
|
data_as_tuple = seq_items_as_tuple(id_inst.temp)
|
|
self.assertEqual(data_as_tuple, data_native)
|
|
del id_type.temp
|
|
|
|
def test_matrix_with_callbacks(self):
|
|
# """
|
|
# Internally matrices have rows/columns swapped,
|
|
# This test ensures this is being done properly.
|
|
# """
|
|
data = ((1, 2, 3, 4), (11, 22, 33, 44), (111, 222, 333, 444), (1111, 2222, 3333, 4444),)
|
|
data_native = seq_items_xform(data, lambda v: float(v))
|
|
local_data = {"array": data}
|
|
|
|
def get_fn(id_arg):
|
|
return local_data["array"]
|
|
|
|
def set_fn(id_arg, value):
|
|
local_data["array"] = value
|
|
|
|
id_type.temp = FloatVectorProperty(size=(4, 4), subtype='MATRIX', get=get_fn, set=set_fn)
|
|
id_inst.temp = data_native
|
|
data_as_tuple = seq_items_as_tuple(id_inst.temp)
|
|
self.assertEqual(data_as_tuple, data_native)
|
|
del id_type.temp
|
|
|
|
|
|
class TestPropArrayDynamicAssign(unittest.TestCase):
|
|
"""
|
|
Pixels are dynamic in the sense the size can change however the assignment does not define the size.
|
|
"""
|
|
|
|
dims = 12
|
|
|
|
def setUp(self):
|
|
self.image = bpy.data.images.new("", self.dims, self.dims)
|
|
|
|
def tearDown(self):
|
|
bpy.data.images.remove(self.image)
|
|
self.image = None
|
|
|
|
def test_assign_fixed_under_1px(self):
|
|
image = self.image
|
|
with self.assertRaises(ValueError):
|
|
image.pixels = [1.0, 1.0, 1.0, 1.0]
|
|
|
|
def test_assign_fixed_under_0px(self):
|
|
image = self.image
|
|
with self.assertRaises(ValueError):
|
|
image.pixels = []
|
|
|
|
def test_assign_fixed_over_by_1px(self):
|
|
image = self.image
|
|
with self.assertRaises(ValueError):
|
|
image.pixels = ([1.0, 1.0, 1.0, 1.0] * (self.dims * self.dims)) + [1.0]
|
|
|
|
def test_assign_fixed(self):
|
|
# Valid assignment, ensure it works as intended.
|
|
image = self.image
|
|
values = [1.0, 0.0, 1.0, 0.0] * (self.dims * self.dims)
|
|
image.pixels = values
|
|
self.assertEqual(tuple(values), tuple(image.pixels))
|
|
|
|
|
|
class TestPropArrayDynamicArg(unittest.TestCase):
|
|
"""
|
|
Index array, a dynamic array argument which defines its own length.
|
|
"""
|
|
|
|
dims = 8
|
|
|
|
def setUp(self):
|
|
self.me = bpy.data.meshes.new("")
|
|
self.me.vertices.add(self.dims)
|
|
self.ob = bpy.data.objects.new("", self.me)
|
|
|
|
def tearDown(self):
|
|
bpy.data.objects.remove(self.ob)
|
|
bpy.data.meshes.remove(self.me)
|
|
self.me = None
|
|
self.ob = None
|
|
|
|
def test_param_dynamic(self):
|
|
ob = self.ob
|
|
vg = ob.vertex_groups.new(name="")
|
|
|
|
# Add none.
|
|
vg.add(index=(), weight=1.0, type='REPLACE')
|
|
for i in range(self.dims):
|
|
with self.assertRaises(RuntimeError):
|
|
vg.weight(i)
|
|
|
|
# Add all.
|
|
vg.add(index=range(self.dims), weight=1.0, type='REPLACE')
|
|
self.assertEqual(tuple([1.0] * self.dims), tuple([vg.weight(i) for i in range(self.dims)]))
|
|
|
|
|
|
class TestPropArrayInvalidForeachGetSet(unittest.TestCase):
|
|
"""
|
|
Test proper detection of invalid usages of foreach_get/foreach_set.
|
|
"""
|
|
|
|
dims = 8
|
|
|
|
def setUp(self):
|
|
self.me = bpy.data.meshes.new("")
|
|
self.me.vertices.add(self.dims)
|
|
self.ob = bpy.data.objects.new("", self.me)
|
|
|
|
def tearDown(self):
|
|
bpy.data.objects.remove(self.ob)
|
|
bpy.data.meshes.remove(self.me)
|
|
self.me = None
|
|
self.ob = None
|
|
|
|
def test_foreach_valid(self):
|
|
me = self.me
|
|
|
|
# Non-array (scalar) data access.
|
|
valid_1b_list = [False] * len(me.vertices)
|
|
me.vertices.foreach_get("select", valid_1b_list)
|
|
self.assertEqual(tuple([True] * self.dims), tuple(valid_1b_list))
|
|
|
|
valid_1b_list = [False] * len(me.vertices)
|
|
me.vertices.foreach_set("select", valid_1b_list)
|
|
for v in me.vertices:
|
|
self.assertFalse(v.select)
|
|
|
|
# Array (vector) data access.
|
|
valid_3f_list = [1.0] * (len(me.vertices) * 3)
|
|
me.vertices.foreach_get("co", valid_3f_list)
|
|
self.assertEqual(tuple([0.0] * self.dims * 3), tuple(valid_3f_list))
|
|
|
|
valid_3f_list = [1.0] * (len(me.vertices) * 3)
|
|
me.vertices.foreach_set("co", valid_3f_list)
|
|
for v in me.vertices:
|
|
self.assertEqual(tuple(v.co), (1.0, 1.0, 1.0))
|
|
|
|
def test_foreach_invalid_smaller_array(self):
|
|
me = self.me
|
|
|
|
# Non-array (scalar) data access.
|
|
invalid_1b_list = [False] * (len(me.vertices) - 1)
|
|
with self.assertRaises(RuntimeError):
|
|
me.vertices.foreach_get("select", invalid_1b_list)
|
|
|
|
invalid_1b_list = [False] * (len(me.vertices) - 1)
|
|
with self.assertRaises(RuntimeError):
|
|
me.vertices.foreach_set("select", invalid_1b_list)
|
|
|
|
# Array (vector) data access.
|
|
invalid_3f_list = [1.0] * (len(me.vertices) * 3 - 1)
|
|
with self.assertRaises(RuntimeError):
|
|
me.vertices.foreach_get("co", invalid_3f_list)
|
|
|
|
invalid_3f_list = [1.0] * (len(me.vertices) * 3 - 1)
|
|
with self.assertRaises(RuntimeError):
|
|
me.vertices.foreach_set("co", invalid_3f_list)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
import sys
|
|
sys.argv = [__file__] + (sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [])
|
|
unittest.main()
|