Add Chromium-only Blender WebEngine parity work

This commit is contained in:
mes123456
2026-08-12 04:47:48 -04:00
commit 9fd26010f6
18225 changed files with 11622124 additions and 0 deletions

View File

@@ -0,0 +1,171 @@
# SPDX-FileCopyrightText: 2025 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later */
"""
blender -b --factory-startup --python tests/python/sculpt_paint/automask_test.py -- --testdir tests/files/sculpting/
"""
__all__ = (
"main",
)
import os
import unittest
import sys
import pathlib
import numpy as np
import bpy
sys.path.append(os.path.dirname(os.path.realpath(__file__)))
from modules.test_helpers import AttributeType, BackendType, get_attribute_data, set_view3d_context_override, generate_stroke
args = None
def get_verts_without_face_set(mesh, attr_data, face_set):
faces = np.where(attr_data != face_set)
verts_per_face = [list(mesh.polygons[int(idx)].vertices) for idx in faces[0]]
verts = [v for face_verts in verts_per_face for v in face_verts]
return list(set(verts))
def get_verts_with_face_set(mesh, attr_data, face_set):
faces = np.where(attr_data == face_set)
verts_per_face = [list(mesh.polygons[int(idx)].vertices) for idx in faces[0]]
verts = [v for face_verts in verts_per_face for v in face_verts]
return list(set(verts))
def get_verts_with_island_id(attr_data, island_id):
verts = np.where(attr_data == island_id)
return verts
def get_verts_without_island_id(attr_data, island_id):
verts = np.where(attr_data != island_id)
return verts
class BrushAutomaskTest(unittest.TestCase):
"""
Test that automasking prevents certain vertices from being modified
"""
def setUp(self):
bpy.ops.wm.open_mainfile(
filepath=str(
args.testdir /
"monkey_realized_island_id_and_face_set.blend"),
load_ui=False)
bpy.ops.object.mode_set(mode="SCULPT")
bpy.ops.ed.undo_push()
result = bpy.ops.brush.asset_activate(
asset_library_type='ESSENTIALS',
relative_asset_identifier='brushes/essentials_brushes-mesh_sculpt.blend/Brush/Draw')
self.assertEqual({'FINISHED'}, result)
def test_face_set_automasking_ignores_any_non_starting_face_set(self):
# This test mesh has 3 face sets, 1 and 2 are used for the eyes, the rest of the monkey's head is 3
ACTIVE_FACE_SET = 3
bpy.data.scenes[0].tool_settings.sculpt.mesh_automasking_settings.use_automasking_face_sets = True
initial_data = get_attribute_data(BackendType.MESH, AttributeType.POSITION)
context_override = bpy.context.copy()
set_view3d_context_override(context_override)
with bpy.context.temp_override(**context_override):
bpy.ops.sculpt.brush_stroke(
stroke=generate_stroke(
context_override,
start_percent=(0.5, 0.5)),
override_location=True)
new_data = get_attribute_data(BackendType.MESH, AttributeType.POSITION)
mesh = bpy.context.active_object.data
num_faces = mesh.attributes.domain_size('FACE')
face_set_data = np.zeros(num_faces, dtype=np.int32)
face_set_attribute = mesh.attributes.get(".sculpt_face_set")
face_set_attribute.data.foreach_get('value', np.ravel(face_set_data))
verts_with_face_set = get_verts_with_face_set(mesh, face_set_data, ACTIVE_FACE_SET)
filtered_initial_data = initial_data[verts_with_face_set]
filtered_new_data = new_data[verts_with_face_set]
any_different = any([orig != new for (orig, new) in zip(filtered_initial_data, filtered_new_data)])
self.assertTrue(any_different, "At least one position should be different from its original value")
verts_without_face_set = get_verts_without_face_set(mesh, face_set_data, ACTIVE_FACE_SET)
filtered_initial_data = initial_data[verts_without_face_set]
filtered_new_data = new_data[verts_without_face_set]
all_same = all([orig == new for (orig, new) in zip(filtered_initial_data, filtered_new_data)])
self.assertTrue(all_same, "Vertices that are not included in the original face sets should be unchanged")
def test_topology_automasking_ignores_any_non_starting_island(self):
# This test mesh has 3 island ids, 0 and 1 are used for the eyes, the rest of the monkey's head is 2
ACTIVE_ISLAND_ID = 2
bpy.data.scenes[0].tool_settings.sculpt.mesh_automasking_settings.use_automasking_topology = True
initial_data = get_attribute_data(BackendType.MESH, AttributeType.POSITION)
context_override = bpy.context.copy()
set_view3d_context_override(context_override)
with bpy.context.temp_override(**context_override):
bpy.ops.sculpt.brush_stroke(
stroke=generate_stroke(
context_override,
start_percent=(0.5, 0.5)),
override_location=True)
new_data = get_attribute_data(BackendType.MESH, AttributeType.POSITION)
mesh = bpy.context.active_object.data
num_faces = mesh.attributes.domain_size('POINT')
island_id_data = np.zeros(num_faces, dtype=np.int32)
island_id_attribute = mesh.attributes.get("island_id")
island_id_attribute.data.foreach_get('value', np.ravel(island_id_data))
verts_with_island_id = get_verts_with_island_id(island_id_data, ACTIVE_ISLAND_ID)
filtered_initial_data = initial_data[verts_with_island_id]
filtered_new_data = new_data[verts_with_island_id]
any_different = any([orig != new for (orig, new) in zip(filtered_initial_data, filtered_new_data)])
self.assertTrue(any_different, "At least one position should be different from its original value")
verts_without_island_id = get_verts_without_island_id(island_id_data, ACTIVE_ISLAND_ID)
filtered_initial_data = initial_data[verts_without_island_id]
filtered_new_data = new_data[verts_without_island_id]
all_same = all([orig == new for (orig, new) in zip(filtered_initial_data, filtered_new_data)])
self.assertTrue(all_same, "Vertices that are not part of the initial mesh island should be unchanged")
def main():
global args
import argparse
argv = [sys.argv[0]]
if '--' in sys.argv:
argv += sys.argv[sys.argv.index('--') + 1:]
parser = argparse.ArgumentParser()
parser.add_argument('--testdir', required=True, type=pathlib.Path)
args, remaining = parser.parse_known_args(argv)
unittest.main(argv=remaining)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,72 @@
# SPDX-FileCopyrightText: 2025 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
"""
blender -b --factory-startup --python tests/python/sculpt_paint/brush_asset_test.py
"""
import sys
import unittest
import bpy
class AssetActivateTest(unittest.TestCase):
def setUp(self):
# Test case isn't specific to Sculpt Mode, but we need a paint mode in general.
bpy.ops.object.mode_set(mode='SCULPT')
bpy.ops.brush.asset_activate(
asset_library_type='ESSENTIALS',
relative_asset_identifier='brushes/essentials_brushes-mesh_sculpt.blend/Brush/Draw')
def test_loads_essential_asset(self):
result = bpy.ops.brush.asset_activate(
asset_library_type='ESSENTIALS',
relative_asset_identifier='brushes/essentials_brushes-mesh_sculpt.blend/Brush/Smooth')
self.assertEqual({'FINISHED'}, result)
def test_toggle_when_brush_differs_sets_specified_brush(self):
"""Test that using the 'Toggle' parameter when the brush is not active still activates the correct brush"""
bpy.ops.brush.asset_activate(
asset_library_type='ESSENTIALS',
relative_asset_identifier='brushes/essentials_brushes-mesh_sculpt.blend/Brush/Mask',
use_toggle=True)
self.assertEqual(bpy.context.tool_settings.sculpt.brush.name, 'Mask')
def test_toggle_when_brush_matches_sets_previous_brush(self):
"""Test that using the 'Toggle' parameter when the brush is active activates the previously activated brush"""
bpy.ops.brush.asset_activate(
asset_library_type='ESSENTIALS',
relative_asset_identifier='brushes/essentials_brushes-mesh_sculpt.blend/Brush/Mask',
use_toggle=True)
self.assertEqual(bpy.context.tool_settings.sculpt.brush.name, 'Mask')
bpy.ops.brush.asset_activate(
asset_library_type='ESSENTIALS',
relative_asset_identifier='brushes/essentials_brushes-mesh_sculpt.blend/Brush/Mask',
use_toggle=True)
self.assertEqual(bpy.context.tool_settings.sculpt.brush.name, 'Draw')
class AssetSaveAsTest(unittest.TestCase):
def setUp(self):
# Test case isn't specific to Sculpt Mode, but we need a paint mode in general.
bpy.ops.object.mode_set(mode='SCULPT')
bpy.ops.brush.asset_activate(
asset_library_type='ESSENTIALS',
relative_asset_identifier='brushes/essentials_brushes-mesh_sculpt.blend/Brush/Smooth')
def test_saves_asset_locally(self):
"""Test that saving an asset to the local creates a copy correctly"""
result = bpy.ops.brush.asset_save_as(name="Local Copy", asset_library_reference="LOCAL", catalog_path="")
self.assertEqual({'FINISHED'}, result)
self.assertTrue("Local Copy" in bpy.data.brushes)
local_brush = bpy.data.brushes["Local Copy"]
self.assertEqual(local_brush.sculpt_brush_type, 'SMOOTH')
self.assertGreaterEqual(local_brush.users, 1)
if __name__ == "__main__":
# Drop all arguments before "--", or everything if the delimiter is absent. Keep the executable path.
unittest.main(argv=sys.argv[:1] + (sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []))

View File

@@ -0,0 +1,115 @@
# SPDX-FileCopyrightText: 2025 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later */
"""
blender -b --factory-startup --python tests/python/sculpt_paint/brush_strength_curves_test.py -- --testdir tests/files/sculpting/
"""
__all__ = (
"main",
)
import math
import os
import pathlib
import numpy as np
import sys
import unittest
import bpy
sys.path.append(os.path.dirname(os.path.realpath(__file__)))
from modules.test_helpers import set_view3d_context_override, generate_stroke
args = None
class BrushCurvesTest(unittest.TestCase):
"""
Test that using a basic "Draw" brush stroke with each of the given brush curve presets doesn't produce invalid
deformations in the mesh, usually resulting in what looks like geometry disappearing to the user.
"""
def setUp(self):
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.ed.undo_push()
bpy.ops.mesh.primitive_monkey_add()
bpy.ops.sculpt.sculptmode_toggle()
def _check_stroke(self):
# Ideally, we would use something like pytest and parameterized tests here, but this helper function is an
# alright solution for now...
context_override = bpy.context.copy()
set_view3d_context_override(context_override)
with bpy.context.temp_override(**context_override):
bpy.ops.sculpt.brush_stroke(stroke=generate_stroke(context_override), override_location=True)
mesh = bpy.context.object.data
position_attr = mesh.attributes['position']
num_vertices = mesh.attributes.domain_size('POINT')
position_data = np.zeros((num_vertices * 3), dtype=np.float32)
position_attr.data.foreach_get('vector', np.ravel(position_data))
# Note, depending on if the tests are run with asserts enabled or not, the test may fail before this point
# inside blender itself.
all_valid = all([not math.isinf(pos) and not math.isnan(pos) for pos in position_data])
self.assertTrue(all_valid, "All position components should be rational values")
def test_smooth_preset_curve_creates_valid_stroke(self):
bpy.context.tool_settings.sculpt.brush.curve_distance_falloff_preset = 'SMOOTH'
self._check_stroke()
def test_smoother_preset_curve_creates_valid_stroke(self):
bpy.context.tool_settings.sculpt.brush.curve_distance_falloff_preset = 'SMOOTHER'
self._check_stroke()
def test_sphere_preset_curve_creates_valid_stroke(self):
bpy.context.tool_settings.sculpt.brush.curve_distance_falloff_preset = 'SPHERE'
self._check_stroke()
def test_root_preset_curve_creates_valid_stroke(self):
bpy.context.tool_settings.sculpt.brush.curve_distance_falloff_preset = 'ROOT'
self._check_stroke()
def test_sharp_preset_curve_creates_valid_stroke(self):
bpy.context.tool_settings.sculpt.brush.curve_distance_falloff_preset = 'SHARP'
self._check_stroke()
def test_linear_preset_curve_creates_valid_stroke(self):
bpy.context.tool_settings.sculpt.brush.curve_distance_falloff_preset = 'LIN'
self._check_stroke()
def test_sharper_preset_curve_creates_valid_stroke(self):
bpy.context.tool_settings.sculpt.brush.curve_distance_falloff_preset = 'POW4'
self._check_stroke()
def test_inverse_square_preset_curve_creates_valid_stroke(self):
bpy.context.tool_settings.sculpt.brush.curve_distance_falloff_preset = 'INVSQUARE'
self._check_stroke()
def test_constant_preset_curve_creates_valid_stroke(self):
bpy.context.tool_settings.sculpt.brush.curve_distance_falloff_preset = 'CONSTANT'
self._check_stroke()
def main():
global args
import argparse
argv = [sys.argv[0]]
if '--' in sys.argv:
argv += sys.argv[sys.argv.index('--') + 1:]
parser = argparse.ArgumentParser()
parser.add_argument('--testdir', required=True, type=pathlib.Path)
args, remaining = parser.parse_known_args(argv)
unittest.main(argv=remaining)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,79 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later */
"""
blender -b --factory-startup --python tests/python/dyntopo_test.py -- --testdir tests/files/sculpting/
"""
__all__ = (
"main",
)
import pathlib
import sys
import unittest
import bpy
args = None
class DetailFloodFillTest(unittest.TestCase):
def setUp(self):
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.ed.undo_push()
bpy.ops.mesh.primitive_cube_add()
bpy.ops.sculpt.sculptmode_toggle()
bpy.ops.sculpt.dynamic_topology_toggle()
def test_operator_subdivides_mesh(self):
"""Test that the operator generates a mesh with appropriately sized edges."""
max_edge_length = 1.0
# Based on the detail_size::EDGE_LENGTH_MIN_FACTOR constant
min_edge_length = max_edge_length * 0.4
bpy.context.scene.tool_settings.sculpt.detail_type_method = 'CONSTANT'
bpy.context.scene.tool_settings.sculpt.constant_detail_resolution = max_edge_length
ret_val = bpy.ops.sculpt.detail_flood_fill()
self.assertEqual({'FINISHED'}, ret_val)
# Toggle to ensure the mesh data is refreshed.
bpy.ops.sculpt.dynamic_topology_toggle()
mesh = bpy.context.object.data
for edge in mesh.edges:
v0 = mesh.vertices[edge.vertices[0]]
v1 = mesh.vertices[edge.vertices[1]]
length = (v0.co - v1.co).length
self.assertGreaterEqual(
length,
min_edge_length,
f"Edge between {v0.index} and {v1.index} should be longer than minimum length")
self.assertLessEqual(
length,
max_edge_length,
f"Edge between {v0.index} and {v1.index} should be shorter than maximum length")
def main():
global args
import argparse
argv = [sys.argv[0]]
if '--' in sys.argv:
argv += sys.argv[sys.argv.index('--') + 1:]
parser = argparse.ArgumentParser()
parser.add_argument('--testdir', required=True, type=pathlib.Path)
args, remaining = parser.parse_known_args(argv)
unittest.main(argv=remaining)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,157 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later */
"""
blender -b --factory-startup --python tests/python/sculpt_paint/face_set_test.py -- --testdir tests/files/sculpting
"""
__all__ = (
"main",
)
import numpy as np
import os
import pathlib
import sys
import unittest
import bpy
args = None
def get_attribute_data(
attribute_name='position',
attribute_domain='POINT',
attribute_size=3,
attribute_type=np.float32,
is_color=False):
mesh = bpy.context.object.data
num_elements = mesh.attributes.domain_size(attribute_domain)
attribute_data = np.zeros((num_elements * attribute_size), dtype=attribute_type)
attribute = mesh.attributes.get(attribute_name)
if is_color:
meta_attribute = 'color'
else:
if attribute_size > 1:
meta_attribute = 'vector'
else:
meta_attribute = 'value'
if attribute:
attribute.data.foreach_get(meta_attribute, np.ravel(attribute_data))
return attribute_data
class ChangeVisibilityTest(unittest.TestCase):
"""
Test that none of the mesh filters create NaN or inf valued vertices
"""
def setUp(self):
bpy.ops.wm.open_mainfile(filepath=str(args.testdir / "30k_monkey_mask_and_face_set.blend"), load_ui=False)
bpy.ops.ed.undo_push()
bpy.ops.sculpt.sculptmode_toggle()
def test_toggle_with_no_face_set_hides_and_unhides_everything(self):
bpy.ops.sculpt.face_set_change_visibility(mode='TOGGLE', active_face_set=0)
hidden_faces = get_attribute_data(
attribute_name=".hide_poly",
attribute_domain='FACE',
attribute_size=1,
attribute_type=np.bool)
mesh = bpy.context.object.data
faces_num = mesh.attributes.domain_size('FACE')
self.assertEqual(np.count_nonzero(hidden_faces), faces_num, "All faces should be hidden")
bpy.ops.sculpt.face_set_change_visibility(mode='TOGGLE', active_face_set=0)
hidden_faces = get_attribute_data(
attribute_name=".hide_poly",
attribute_domain='FACE',
attribute_size=1,
attribute_type=np.bool)
self.assertEqual(np.count_nonzero(hidden_faces), 0, "No faces should be hidden")
def test_toggle_with_specified_face_set_modifies_other_faces(self):
face_set_data = get_attribute_data(
attribute_name=".sculpt_face_set",
attribute_domain='FACE',
attribute_size=1,
attribute_type=np.int32)
bpy.ops.sculpt.face_set_change_visibility(mode='TOGGLE', active_face_set=2)
hidden_faces = get_attribute_data(
attribute_name=".hide_poly",
attribute_domain='FACE',
attribute_size=1,
attribute_type=np.bool)
self.assertEqual(np.count_nonzero(hidden_faces), np.count_nonzero(
face_set_data != 2), "All non-specified faces should be hidden")
bpy.ops.sculpt.face_set_change_visibility(mode='TOGGLE', active_face_set=2)
hidden_faces = get_attribute_data(
attribute_name=".hide_poly",
attribute_domain='FACE',
attribute_size=1,
attribute_type=np.bool)
self.assertEqual(np.count_nonzero(hidden_faces), 0, "No faces should be hidden")
def test_hide_show_affects_specified_faces(self):
face_set_data = get_attribute_data(
attribute_name=".sculpt_face_set",
attribute_domain='FACE',
attribute_size=1,
attribute_type=np.int32)
bpy.ops.sculpt.face_set_change_visibility(mode='HIDE_ACTIVE', active_face_set=2)
hidden_faces = get_attribute_data(
attribute_name=".hide_poly",
attribute_domain='FACE',
attribute_size=1,
attribute_type=np.bool)
self.assertEqual(
np.count_nonzero(hidden_faces),
np.count_nonzero(
face_set_data == 2),
"All specified faces should be hidden")
bpy.ops.sculpt.face_set_change_visibility(mode='SHOW_ACTIVE', active_face_set=2)
hidden_faces = get_attribute_data(
attribute_name=".hide_poly",
attribute_domain='FACE',
attribute_size=1,
attribute_type=np.bool)
self.assertEqual(np.count_nonzero(hidden_faces), 0, "No faces should be hidden")
def main():
global args
import argparse
argv = [sys.argv[0]]
if '--' in sys.argv:
argv += sys.argv[sys.argv.index('--') + 1:]
parser = argparse.ArgumentParser()
parser.add_argument('--testdir', required=True, type=pathlib.Path)
args, remaining = parser.parse_known_args(argv)
unittest.main(argv=remaining)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,230 @@
# SPDX-FileCopyrightText: 2025 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later */
"""
blender -b --factory-startup --python tests/python/mask_test.py -- --testdir tests/files/sculpting/
"""
__all__ = (
"main",
)
import os
import pathlib
import numpy as np
import sys
import unittest
import bpy
sys.path.append(os.path.dirname(os.path.realpath(__file__)))
from modules.test_helpers import set_view3d_context_override
args = None
class GrowMaskTest(unittest.TestCase):
def setUp(self):
bpy.ops.wm.open_mainfile(filepath=str(args.testdir / "partially_masked_sphere.blend"), load_ui=False)
bpy.ops.ed.undo_push()
def test_grow_increases_number_of_masked_vertices(self):
mesh = bpy.context.object.data
mask_attr = mesh.attributes['.sculpt_mask']
num_vertices = mesh.attributes.domain_size('POINT')
old_mask_data = np.zeros(num_vertices, dtype=np.float32)
mask_attr.data.foreach_get('value', old_mask_data)
bpy.ops.sculpt.mask_filter(filter_type='GROW')
new_mask_data = np.zeros(num_vertices, dtype=np.float32)
mask_attr.data.foreach_get('value', new_mask_data)
self.assertGreater(np.count_nonzero(new_mask_data), np.count_nonzero(old_mask_data))
class ShrinkMaskTest(unittest.TestCase):
def setUp(self):
bpy.ops.wm.open_mainfile(filepath=str(args.testdir / "partially_masked_sphere.blend"), load_ui=False)
bpy.ops.ed.undo_push()
def test_shrink_decreases_number_of_masked_vertices(self):
mesh = bpy.context.object.data
mask_attr = mesh.attributes['.sculpt_mask']
num_vertices = mesh.attributes.domain_size('POINT')
old_mask_data = np.zeros(num_vertices, dtype=np.float32)
mask_attr.data.foreach_get('value', old_mask_data)
bpy.ops.sculpt.mask_filter(filter_type='SHRINK')
new_mask_data = np.zeros(num_vertices, dtype=np.float32)
mask_attr.data.foreach_get('value', new_mask_data)
self.assertLess(np.count_nonzero(new_mask_data), np.count_nonzero(old_mask_data))
class ClearMaskTest(unittest.TestCase):
def setUp(self):
bpy.ops.wm.open_mainfile(filepath=str(args.testdir / "partially_masked_sphere.blend"), load_ui=False)
bpy.ops.ed.undo_push()
def test_value_removes_attribute(self):
mesh = bpy.context.object.data
bpy.ops.paint.mask_flood_fill(mode='VALUE', value=0)
self.assertFalse(mesh.attributes.get('.sculpt_mask'))
class InvertMaskTest(unittest.TestCase):
def test_invert_applies_correct_values(self):
bpy.ops.wm.open_mainfile(filepath=str(args.testdir / "partially_masked_sphere.blend"), load_ui=False)
bpy.ops.ed.undo_push()
mesh = bpy.context.object.data
mask_attr = mesh.attributes['.sculpt_mask']
num_vertices = mesh.attributes.domain_size('POINT')
old_mask_data = np.zeros(num_vertices, dtype=np.float32)
mask_attr.data.foreach_get('value', old_mask_data)
bpy.ops.paint.mask_flood_fill(mode='INVERT')
new_mask_data = np.zeros(num_vertices, dtype=np.float32)
mask_attr.data.foreach_get('value', new_mask_data)
expected_mask_data = np.array([1.0 - m for m in old_mask_data])
self.assertEqual(expected_mask_data.tolist(), new_mask_data.tolist())
def test_invert_on_empty_fills_mesh(self):
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.ed.undo_push()
bpy.ops.mesh.primitive_monkey_add()
bpy.ops.sculpt.sculptmode_toggle()
bpy.ops.paint.mask_flood_fill(mode='INVERT')
mesh = bpy.context.object.data
mask_attr = mesh.attributes['.sculpt_mask']
num_vertices = mesh.attributes.domain_size('POINT')
new_mask_data = np.zeros(num_vertices, dtype=np.float32)
mask_attr.data.foreach_get('value', new_mask_data)
expected_mask_data = np.ones(num_vertices, dtype=np.float32)
self.assertEqual(expected_mask_data.tolist(), new_mask_data.tolist())
class MaskByColorTest(unittest.TestCase):
def setUp(self):
bpy.ops.wm.open_mainfile(filepath=str(args.testdir / "plane_with_red_circle.blend"), load_ui=False)
self.context_override = bpy.context.copy()
set_view3d_context_override(self.context_override)
bpy.ops.ed.undo_push()
def test_off_grid_returns_cancelled(self):
"""Test that operator does not run when the cursor is not on the mesh."""
with bpy.context.temp_override(**self.context_override):
location = (0, 0)
ret_val = bpy.ops.sculpt.mask_by_color(location=location)
self.assertEqual({'CANCELLED'}, ret_val)
mesh = bpy.context.object.data
self.assertFalse('.sculpt_mask' in mesh.attributes.keys(), "Mesh should not have the .sculpt_mask attribute!")
def test_on_circle_masks_red_vertices(self):
"""Test that the operator only masks red vertices on the mesh."""
with bpy.context.temp_override(**self.context_override):
location = (int(self.context_override['area'].width / 2), int(self.context_override['area'].height / 2))
ret_val = bpy.ops.sculpt.mask_by_color(location=location)
self.assertEqual({'FINISHED'}, ret_val)
mesh = bpy.context.object.data
color_attr = mesh.attributes['Color']
mask_attr = mesh.attributes['.sculpt_mask']
num_vertices = mesh.attributes.domain_size('POINT')
color_data = np.zeros((num_vertices, 4), dtype=np.float32)
color_attr.data.foreach_get('color', np.ravel(color_data))
mask_data = np.zeros(num_vertices, dtype=np.float32)
mask_attr.data.foreach_get('value', mask_data)
for i in range(num_vertices):
# If either of the green or blue components are less than 1 (i.e. the vertex is the red part of the image instead of
# the white background), then that vertex should also be masked.
if color_data[i][1] < 0.4 and color_data[i][2] < 0.4:
self.assertTrue(mask_data[i] > 0.0, f"Vertex {i} should be masked ({color_data[i]}) -> {mask_data[i]}")
else:
self.assertTrue(mask_data[i] < 0.1,
f"Vertex {i} should not be masked ({color_data[i]}) -> {mask_data[i]}")
class MaskFromCavityTest(unittest.TestCase):
def setUp(self):
bpy.ops.wm.open_mainfile(filepath=str(args.testdir / "plane_with_valley.blend"), load_ui=False)
bpy.ops.ed.undo_push()
def test_operator_masks_low_vertices(self):
"""Test that the operator applies a full mask value to any elements that are part of the cavity."""
ret_val = bpy.ops.sculpt.mask_from_cavity()
self.assertEqual({'FINISHED'}, ret_val)
mesh = bpy.context.object.data
position_attr = mesh.attributes['position']
mask_attr = mesh.attributes['.sculpt_mask']
num_vertices = mesh.attributes.domain_size('POINT')
position_data = np.zeros((num_vertices, 3), dtype=np.float32)
position_attr.data.foreach_get('vector', np.ravel(position_data))
mask_data = np.zeros(num_vertices, dtype=np.float32)
mask_attr.data.foreach_get('value', mask_data)
for i in range(num_vertices):
if position_data[i][2] < 0.0:
self.assertEqual(
mask_data[i],
0.0,
f"Vertex {i} should not be masked ({position_data[i]}) -> {mask_data[i]}")
else:
self.assertNotEqual(mask_data[i], 0.0,
f"Vertex {i} should be masked ({position_data[i]}) -> {mask_data[i]}")
def main():
global args
import argparse
argv = [sys.argv[0]]
if '--' in sys.argv:
argv += sys.argv[sys.argv.index('--') + 1:]
parser = argparse.ArgumentParser()
parser.add_argument('--testdir', required=True, type=pathlib.Path)
args, remaining = parser.parse_known_args(argv)
unittest.main(argv=remaining)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,137 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later */
"""
blender -b --factory-startup --python tests/python/sculpt_paint/mesh_filter_test.py -- --testdir tests/files/
"""
__all__ = (
"main",
)
import math
import numpy as np
import os
import pathlib
import sys
import unittest
import bpy
sys.path.append(os.path.dirname(os.path.realpath(__file__)))
from modules.test_helpers import set_view3d_context_override
args = None
def get_attribute_data(
attribute_name='position',
attribute_domain='POINT',
attribute_size=3,
attribute_type=np.float32,
is_color=False):
mesh = bpy.context.object.data
num_elements = mesh.attributes.domain_size(attribute_domain)
attribute_data = np.zeros((num_elements * attribute_size), dtype=attribute_type)
attribute = mesh.attributes.get(attribute_name)
if is_color:
meta_attribute = 'color'
else:
if attribute_size > 1:
meta_attribute = 'vector'
else:
meta_attribute = 'value'
if attribute:
attribute.data.foreach_get(meta_attribute, np.ravel(attribute_data))
return attribute_data
class MeshFilterTests(unittest.TestCase):
"""
Test that none of the mesh filters create NaN or inf valued vertices
"""
def setUp(self):
bpy.ops.wm.open_mainfile(filepath=str(args.testdir / "30k_monkey_mask_and_face_set.blend"), load_ui=False)
bpy.ops.ed.undo_push()
bpy.ops.sculpt.sculptmode_toggle()
def _check_filter(self, type, opts={}):
# Ideally, we would use something like pytest and parameterized tests here, but this helper function is an
# alright solution for now...
initial_data = get_attribute_data()
context_override = bpy.context.copy()
set_view3d_context_override(context_override)
with bpy.context.temp_override(**context_override):
bpy.ops.sculpt.mesh_filter(type=type, **opts)
new_data = get_attribute_data()
# Note, depending on if the tests are run with asserts enabled or not, the test may fail before this point
# inside blender itself.
all_valid = all([not math.isinf(pos) and not math.isnan(pos) for pos in new_data])
any_different = any([orig != new for (orig, new) in zip(initial_data, new_data)])
self.assertTrue(all_valid, "All position components should be rational values")
self.assertTrue(any_different, "At least one position should be different from its original value")
def test_smooth_filter_creates_valid_data(self):
self._check_filter('SMOOTH')
def test_surface_smooth_filter_creates_valid_data(self):
self._check_filter('SURFACE_SMOOTH')
def test_inflate_filter_creates_valid_data(self):
self._check_filter('INFLATE')
def test_relax_topology_filter_creates_valid_data(self):
self._check_filter('RELAX')
def test_relax_face_sets_filter_creates_valid_data(self):
self._check_filter('RELAX_FACE_SETS')
def test_sharpen_filter_creates_valid_data(self):
self._check_filter('SHARPEN')
def test_sharpen_filter_intensify_details_creates_valid_data(self):
self._check_filter('SHARPEN', opts={"sharpen_intensify_detail_strength": 1.0})
def test_sharpen_filter_curvature_smooth_creates_valid_data(self):
self._check_filter('SHARPEN', opts={"sharpen_curvature_smooth_iterations": 10})
def test_enhance_details_filter_creates_valid_data(self):
self._check_filter('ENHANCE_DETAILS')
def test_scale_filter_creates_valid_data(self):
self._check_filter('SCALE')
def test_sphere_filter_creates_valid_data(self):
self._check_filter('SPHERE')
def test_randomize_filter_creates_valid_data(self):
self._check_filter('RANDOM')
def main():
global args
import argparse
argv = [sys.argv[0]]
if '--' in sys.argv:
argv += sys.argv[sys.argv.index('--') + 1:]
parser = argparse.ArgumentParser()
parser.add_argument('--testdir', required=True, type=pathlib.Path)
args, remaining = parser.parse_known_args(argv)
unittest.main(argv=remaining)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,207 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later */
__all__ = (
"BackendType",
"COLOR_BACKEND_TYPES",
"MASK_BACKEND_TYPES",
"AttributeType",
"get_attribute_data",
"set_view3d_context_override",
"generate_stroke",
"generate_monkey"
)
from enum import Enum, unique
@unique
class BackendType(Enum):
MESH = 0
MULTIRES = 1
@unique
class AttributeType(Enum):
POSITION = 0
MASK = 1
FACE_SET = 2
COLOR = 3
COLOR_CORNER = 4
COLOR_BACKEND_TYPES = [BackendType.MESH]
# Applying a multires mesh does not transfer mask values.
# See #153743 for a tracking issue to enable these tests
MASK_BACKEND_TYPES = [BackendType.MESH]
def _get_mesh(backend_type):
import bpy
if backend_type == BackendType.MESH:
return bpy.context.active_object.data
else:
# Duplicate and apply the modifier
bpy.ops.sculpt.sculptmode_toggle()
original_object = bpy.data.objects['Suzanne']
bpy.ops.object.select_all(action='DESELECT')
original_object.select_set(True)
bpy.ops.object.duplicate()
duplicate_object = bpy.context.selected_objects[0]
bpy.context.view_layer.objects.active = bpy.context.selected_objects[0]
bpy.context.active_object.modifiers['Multires'].levels = 1
bpy.ops.object.modifier_apply(modifier='Multires')
# Restore initial object as "active" and return to sculpt mode
bpy.context.view_layer.objects.active = original_object
bpy.ops.sculpt.sculptmode_toggle()
return duplicate_object.data
def get_attribute_data(backend_type, attribute_type):
if attribute_type in {AttributeType.COLOR, AttributeType.COLOR_CORNER} and backend_type == BackendType.MULTIRES:
raise Exception("Multires does not support color attributes")
import numpy as np
mesh = _get_mesh(backend_type)
match attribute_type:
case AttributeType.POSITION:
attribute_name = 'position'
attribute_domain = 'POINT'
attribute_size = 3
attribute_data_type = np.float32
is_color = False
case AttributeType.MASK:
attribute_name = '.sculpt_mask'
attribute_domain = 'POINT'
attribute_size = 1
attribute_data_type = np.float32
is_color = False
case AttributeType.FACE_SET:
attribute_name = '.sculpt_face_set'
attribute_domain = 'FACE'
attribute_size = 1
attribute_data_type = np.int32
is_color = False
case AttributeType.COLOR:
attribute_name = 'Color'
attribute_domain = 'POINT'
attribute_size = 4
attribute_data_type = np.float32
is_color = True
case AttributeType.COLOR_CORNER:
attribute_name = 'Color'
attribute_domain = 'CORNER'
attribute_size = 4
attribute_data_type = np.float32
is_color = True
case _:
raise Exception("Invalid type specified")
num_elements = mesh.attributes.domain_size(attribute_domain)
attribute_data = np.zeros((num_elements * attribute_size), dtype=attribute_data_type)
attribute = mesh.attributes.get(attribute_name)
if is_color:
meta_attribute = 'color'
else:
if attribute_size > 1:
meta_attribute = 'vector'
else:
meta_attribute = 'value'
if attribute:
attribute.data.foreach_get(meta_attribute, np.ravel(attribute_data))
return attribute_data
def set_view3d_context_override(context_override):
"""
Set context override to become the first viewport in the active workspace
The ``context_override`` is expected to be a copy of an actual current context
obtained by `context.copy()`
"""
for area in context_override["screen"].areas:
if area.type != 'VIEW_3D':
continue
for space in area.spaces:
if space.type != 'VIEW_3D':
continue
for region in area.regions:
if region.type != 'WINDOW':
continue
context_override["area"] = area
context_override["region"] = region
def generate_monkey(backend):
"""
Create a dense enough mesh to use for testing.
"""
import bpy
bpy.ops.mesh.primitive_monkey_add()
context_override = bpy.context.copy()
set_view3d_context_override(context_override)
with bpy.context.temp_override(**context_override):
bpy.ops.view3d.view_axis(type='FRONT')
bpy.ops.view3d.view_selected()
if backend == BackendType.MESH:
bpy.ops.object.subdivision_set(level=2, relative=False, ensure_modifier=True)
bpy.ops.object.modifier_apply(modifier="Subdivision")
bpy.ops.ed.undo_push()
bpy.ops.sculpt.sculptmode_toggle()
if backend == BackendType.MULTIRES:
bpy.ops.object.subdivision_set(level=2, relative=False, ensure_modifier=True)
def generate_stroke(context, start_percent=(0.0, 0.0), end_percent=(1.0, 1.0)):
"""
Generate stroke for any of the paint mode operators (e.g. bpy.ops.sculpt.brush_stroke_
The generated stroke coves the full plane diagonal.
"""
import bpy
from mathutils import Vector
template = {
"name": "stroke",
"mouse": (0.0, 0.0),
"mouse_event": (0, 0),
"is_start": True,
"location": (0, 0, 0),
"pressure": 1.0,
"time": 1.0,
"size": 1.0,
"x_tilt": 0,
"y_tilt": 0
}
num_steps = 50
start = Vector((0 * start_percent[0], 0 * start_percent[1]))
end = Vector((context['area'].width * end_percent[0], context['area'].height * end_percent[1]))
delta = (end - start) / (num_steps - 1)
stroke = []
for i in range(num_steps):
step = template.copy()
step["mouse_event"] = start + delta * i
stroke.append(step)
return stroke

View File

@@ -0,0 +1,253 @@
# SPDX-FileCopyrightText: 2025 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later */
"""
blender -b -X -P tests/python/sculpt_paint/multires_operators_test.py -- --testdir tests/files/sculpting/
"""
__all__ = (
"main",
)
import pathlib
import sys
import unittest
import bpy
from mathutils import Vector
args = None
class ApplyBase(unittest.TestCase):
def setUp(self):
bpy.ops.wm.open_mainfile(filepath=str(args.testdir / "apply_base_monkey.blend"), load_ui=False)
def test_subdividing_cube_results_in_same_mesh(self):
bpy.ops.mesh.primitive_monkey_add()
new_cube = bpy.context.object
multires_mod = new_cube.modifiers.new("Multires", 'MULTIRES')
bpy.ops.object.multires_subdivide(modifier="Multires")
bpy.ops.object.multires_base_apply(modifier="Multires")
bpy.ops.object.modifier_remove(modifier="Multires")
expected_mesh = bpy.data.objects['Expected_Base_Mesh']
result = expected_mesh.data.unit_test_compare(mesh=new_cube.data)
self.assertEqual(result, 'Same')
class Unsubdivide(unittest.TestCase):
def setUp(self):
bpy.ops.wm.read_factory_settings(use_empty=True)
@staticmethod
def _sorted_positions(mesh, places=5):
return sorted(tuple(round(c, places) for c in v.co) for v in mesh.vertices)
@staticmethod
def _mesh_volume(mesh):
import bmesh
bm = bmesh.new()
bm.from_mesh(mesh)
volume = bm.calc_volume()
bm.free()
return volume
def test_subdivided_cube_round_trips(self):
# Extracts grid data from the vertices.
bpy.ops.mesh.primitive_cube_add()
ob_cube = bpy.context.object
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.subdivide()
bpy.ops.object.mode_set(mode='OBJECT')
self.assertEqual(len(ob_cube.data.vertices), 26)
self.assertAlmostEqual(self._mesh_volume(ob_cube.data), 8.0, places=5)
subdivided_positions = self._sorted_positions(ob_cube.data)
mod = ob_cube.modifiers.new(name="Multires", type='MULTIRES')
result = bpy.ops.object.multires_unsubdivide(modifier=mod.name)
self.assertEqual(result, {'FINISHED'})
self.assertEqual(len(ob_cube.data.vertices), 8)
self.assertAlmostEqual(self._mesh_volume(ob_cube.data), 8.0, places=5)
mod.levels = 1
mod.sculpt_levels = 1
mod.render_levels = 1
bpy.ops.object.modifier_apply(modifier=mod.name)
self.assertEqual(len(ob_cube.data.vertices), 26)
self.assertAlmostEqual(self._mesh_volume(ob_cube.data), 8.0, places=5)
self.assertEqual(self._sorted_positions(ob_cube.data), subdivided_positions)
def test_twice_subdivided_cube_round_trips(self):
# The second un-subdivide call ensures it extracts grid data from the "grids".
bpy.ops.mesh.primitive_cube_add()
ob_cube = bpy.context.object
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.subdivide(number_cuts=3)
bpy.ops.object.mode_set(mode='OBJECT')
self.assertEqual(len(ob_cube.data.vertices), 98)
self.assertAlmostEqual(self._mesh_volume(ob_cube.data), 8.0, places=5)
subdivided_positions = self._sorted_positions(ob_cube.data)
mod = ob_cube.modifiers.new(name="Multires", type='MULTIRES')
result = bpy.ops.object.multires_unsubdivide(modifier=mod.name)
self.assertEqual(result, {'FINISHED'})
self.assertEqual(len(ob_cube.data.vertices), 26)
self.assertAlmostEqual(self._mesh_volume(ob_cube.data), 8.0, places=5)
result = bpy.ops.object.multires_unsubdivide(modifier=mod.name)
self.assertEqual(result, {'FINISHED'})
self.assertEqual(len(ob_cube.data.vertices), 8)
self.assertAlmostEqual(self._mesh_volume(ob_cube.data), 8.0, places=5)
mod.levels = mod.total_levels
mod.sculpt_levels = mod.total_levels
mod.render_levels = mod.total_levels
bpy.ops.object.modifier_apply(modifier=mod.name)
self.assertEqual(len(ob_cube.data.vertices), 98)
self.assertAlmostEqual(self._mesh_volume(ob_cube.data), 8.0, places=5)
self.assertEqual(self._sorted_positions(ob_cube.data), subdivided_positions)
def test_unsubdivide_with_mirrored_coincident_faces(self):
# Regression test for #158032: when `multires_unsubdivide` runs on topology containing edges shared by 4 faces.
# The mirror plane creates this scenario, then ensure un-subdivide handles this gracefully.
bpy.ops.mesh.primitive_cube_add()
ob_cube = bpy.context.object
mod = ob_cube.modifiers.new(name="Multires", type='MULTIRES')
bpy.ops.object.multires_subdivide(modifier=mod.name)
bpy.ops.object.multires_subdivide(modifier=mod.name)
mod.levels = mod.total_levels
mod.sculpt_levels = mod.total_levels
mod.render_levels = mod.total_levels
bpy.ops.object.modifier_apply(modifier=mod.name)
self.assertEqual(len(ob_cube.data.vertices), 98)
mod_mirror = ob_cube.modifiers.new(name="Mirror", type='MIRROR')
bpy.ops.object.modifier_apply(modifier=mod_mirror.name)
self.assertEqual(len(ob_cube.data.vertices), 180)
mod = ob_cube.modifiers.new(name="Multires", type='MULTIRES')
# First call takes the vertex-extraction path.
result = bpy.ops.object.multires_unsubdivide(modifier=mod.name)
self.assertEqual(result, {'FINISHED'})
self.assertEqual(len(ob_cube.data.vertices), 44)
self.assertEqual(mod.total_levels, 1)
# Second call takes the grid-extraction path.
# Some grids fail to walk along the 4-face-edge topology - the operator must
# detect that and bail out of those grids instead of dereferencing a null edge.
result = bpy.ops.object.multires_unsubdivide(modifier=mod.name)
self.assertEqual(result, {'FINISHED'})
self.assertEqual(len(ob_cube.data.vertices), 18)
self.assertEqual(mod.total_levels, 2)
def test_reshape_cube_to_sphere(self):
# Test grid-data is properly extracted from MDISPS, keeping the shape.
# Subdivide the base so the following `multires_unsubdivide` has a coarser cube to reduce to
# (an 8-vert cube cannot be un-subdivided).
bpy.ops.mesh.primitive_cube_add()
ob_cube = bpy.context.object
bpy.ops.object.mode_set(mode='EDIT')
bpy.ops.mesh.select_all(action='SELECT')
bpy.ops.mesh.subdivide()
bpy.ops.object.mode_set(mode='OBJECT')
# One multi-resolution level above the base, since `multires_reshape` needs
# a top level to project onto.
mod = ob_cube.modifiers.new(name="Multires", type='MULTIRES')
bpy.ops.object.multires_subdivide(modifier=mod.name)
# Build the reshape source by duplicating the cube and make it sphere-shaped it.
# `multires_reshape` requires the source to have the same vert count as the multi-resolution top level,
# which a primitive sphere cannot match exactly.
bpy.ops.object.duplicate()
ob_src = bpy.context.object
mod_src_name = ob_src.modifiers[0].name
ob_src.modifiers[mod_src_name].levels = ob_src.modifiers[mod_src_name].total_levels
bpy.ops.object.modifier_apply(modifier=mod_src_name)
self.assertEqual(len(ob_src.data.vertices), 98)
for v in ob_src.data.vertices:
v.co.normalize()
# Verify the source volume - avoid confusing test failure if it ever changes.
ob_src_volume = self._mesh_volume(ob_src.data)
self.assertAlmostEqual(ob_src_volume, 3.8898128, places=4)
# Reshape projects the cube's multi-resolution top level onto the sphere-shaped source.
# Afterwards the cube's MDISPS encode the sphere displacement.
bpy.ops.object.select_all(action='DESELECT')
ob_src.select_set(True)
ob_cube.select_set(True)
bpy.context.view_layer.objects.active = ob_cube
result = bpy.ops.object.multires_reshape(modifier=mod.name)
self.assertEqual(result, {'FINISHED'})
# Validate reshape on a duplicate (apply modifier, check sphere geometry) so the original
# cube + modifier stay intact for the `multires_unsubdivide` round-trip below.
bpy.ops.object.select_all(action='DESELECT')
ob_cube.select_set(True)
bpy.context.view_layer.objects.active = ob_cube
bpy.ops.object.duplicate()
ob_cube_copy = bpy.context.object
mod_copy = ob_cube_copy.modifiers[0]
mod_copy.levels = mod_copy.total_levels
bpy.ops.object.modifier_apply(modifier=mod_copy.name)
self.assertEqual(len(ob_cube_copy.data.vertices), 98)
for v in ob_cube_copy.data.vertices:
self.assertAlmostEqual(v.co.length, 1.0, places=4)
self.assertAlmostEqual(self._mesh_volume(ob_cube_copy.data), ob_src_volume, places=4)
bpy.data.objects.remove(ob_cube_copy, do_unlink=True)
# Round-trip the MDISPS through `multires_unsubdivide` on a duplicate:
# this tests grid-extraction with the non-trivial sphere-shaped MDISPS populated by reshape.
# Applying at the new top level should reconstruct the same sphere.
bpy.ops.object.select_all(action='DESELECT')
ob_cube.select_set(True)
bpy.context.view_layer.objects.active = ob_cube
bpy.ops.object.duplicate()
ob_unsubdiv = bpy.context.object
mod_unsubdiv = ob_unsubdiv.modifiers[0]
result = bpy.ops.object.multires_unsubdivide(modifier=mod_unsubdiv.name)
self.assertEqual(result, {'FINISHED'})
self.assertEqual(len(ob_unsubdiv.data.vertices), 8)
mod_unsubdiv.levels = mod_unsubdiv.total_levels
mod_unsubdiv.sculpt_levels = mod_unsubdiv.total_levels
mod_unsubdiv.render_levels = mod_unsubdiv.total_levels
bpy.ops.object.modifier_apply(modifier=mod_unsubdiv.name)
self.assertEqual(len(ob_unsubdiv.data.vertices), 98)
for v in ob_unsubdiv.data.vertices:
self.assertAlmostEqual(v.co.length, 1.0, places=4)
self.assertAlmostEqual(self._mesh_volume(ob_unsubdiv.data), ob_src_volume, places=4)
def main():
global args
import argparse
argv = [sys.argv[0]]
if '--' in sys.argv:
argv += sys.argv[sys.argv.index('--') + 1:]
parser = argparse.ArgumentParser()
parser.add_argument('--testdir', required=True, type=pathlib.Path)
args, remaining = parser.parse_known_args(argv)
unittest.main(argv=remaining)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,592 @@
# SPDX-FileCopyrightText: 2025 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later */
"""
blender -b --factory-startup --python tests/python/sculpt_paint/sculpt_brushes_test.py -- --testdir tests/files/mesh_paint/
"""
__all__ = (
"main",
)
import math
import os
import pathlib
import sys
import unittest
import bpy
sys.path.append(os.path.dirname(os.path.realpath(__file__)))
from modules.test_helpers import AttributeType, BackendType, COLOR_BACKEND_TYPES, MASK_BACKEND_TYPES, get_attribute_data, set_view3d_context_override, generate_stroke, generate_monkey
args = None
class MeshBrushTests(unittest.TestCase):
"""
Test that none of the included brushes create NaN or inf valued vertices
"""
def _initialize(self, backend):
"""
Reset the file to the initial working state, unfortunately `setUp` does not work with subTest if using the
latter as parameterized tests.
"""
bpy.ops.wm.read_factory_settings(use_empty=True)
generate_monkey(backend)
def _activate_brush(self, brush):
"""
Activate a specified brush
"""
result = bpy.ops.brush.asset_activate(
asset_library_type='ESSENTIALS',
relative_asset_identifier='brushes/essentials_brushes-mesh_sculpt.blend/Brush/{}'.format(brush))
self.assertEqual({'FINISHED'}, result)
def _check_stroke(self, backend, attribute, *, start_over_mesh=False, opts={}):
"""
Compare the prior and post states of a brush stroke
"""
if start_over_mesh:
start_percent = (0.5, 0.5)
else:
start_percent = (0.0, 0.0)
initial_data = get_attribute_data(backend, attribute)
context_override = bpy.context.copy()
set_view3d_context_override(context_override)
with bpy.context.temp_override(**context_override):
bpy.ops.sculpt.brush_stroke(
stroke=generate_stroke(
context_override,
start_percent=start_percent),
override_location=True, **opts)
new_data = get_attribute_data(backend, attribute)
# Note, depending on if the tests are run with asserts enabled or not, the test may fail before this point
# inside blender itself.
match attribute:
case AttributeType.POSITION:
all_valid = all([not math.isinf(pos) and not math.isnan(pos) for pos in new_data])
any_different = any([orig != new for (orig, new) in zip(initial_data, new_data)])
self.assertTrue(all_valid, "All position components should be rational values")
self.assertTrue(any_different, "At least one position should be different from its original value")
case AttributeType.MASK:
all_valid = all([not math.isinf(mask) and not math.isnan(mask) for mask in new_data])
any_different = any([orig != new for (orig, new) in zip(initial_data, new_data)])
self.assertTrue(all_valid, "All mask values should be rational values")
self.assertTrue(any_different, "At least one mask should be different from its original value")
case AttributeType.FACE_SET:
all_valid = all([face_set_id == 1 or face_set_id == 2 for face_set_id in new_data])
any_different = any([orig != new for (orig, new) in zip(initial_data, new_data)])
self.assertTrue(all_valid, "All face set values should be 1 or 2 valued")
self.assertTrue(any_different, "At least one face set should be different from its original value")
case AttributeType.COLOR:
all_valid = all([not math.isinf(channel) and not math.isnan(channel) for channel in new_data])
any_different = any([orig != new for (orig, new) in zip(initial_data, new_data)])
self.assertTrue(all_valid, "All color components should be rational values")
self.assertTrue(any_different,
"At least one color component should be different from its original value")
case _:
raise Exception("Invalid attribute type")
def test_blob_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Blob")
self._check_stroke(backend, AttributeType.POSITION)
def test_clay_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Clay")
self._check_stroke(backend, AttributeType.POSITION)
def test_clay_strips_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Clay Strips")
self._check_stroke(backend, AttributeType.POSITION)
def test_clay_thumb_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Clay Thumb")
self._check_stroke(backend, AttributeType.POSITION)
def test_crease_polish_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Crease Polish")
self._check_stroke(backend, AttributeType.POSITION)
def test_crease_sharp_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Crease Sharp")
self._check_stroke(backend, AttributeType.POSITION)
def test_draw_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Draw")
self._check_stroke(backend, AttributeType.POSITION)
def test_draw_sharp_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Draw Sharp")
self._check_stroke(backend, AttributeType.POSITION)
def test_inflate_deflate_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Inflate/Deflate")
self._check_stroke(backend, AttributeType.POSITION)
def test_fill_deepen_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Fill/Deepen")
self._check_stroke(backend, AttributeType.POSITION)
def test_flatten_contrast_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Flatten/Contrast")
self._check_stroke(backend, AttributeType.POSITION)
def test_plateau_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Plateau")
self._check_stroke(backend, AttributeType.POSITION)
def test_scrape_multiplane_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Scrape Multiplane")
self._check_stroke(backend, AttributeType.POSITION)
def test_scrape_fill_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Scrape/Fill")
self._check_stroke(backend, AttributeType.POSITION)
def test_smooth_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Smooth")
self._check_stroke(backend, AttributeType.POSITION)
def test_smooth_brush_invert_mode_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Smooth")
self._check_stroke(backend, AttributeType.POSITION, opts={"mode": 'INVERT'})
def test_trim_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Trim")
self._check_stroke(backend, AttributeType.POSITION)
@unittest.skip("Asserts in blender")
def test_boundary_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Boundary")
self._check_stroke(backend, AttributeType.POSITION)
def test_elastic_grab_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Elastic Grab")
self._check_stroke(backend, AttributeType.POSITION)
def test_elastic_snake_hook_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Elastic Snake Hook")
self._check_stroke(backend, AttributeType.POSITION)
def test_grab_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Grab")
self._check_stroke(backend, AttributeType.POSITION)
def test_grab_2d_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Grab 2D")
self._check_stroke(backend, AttributeType.POSITION)
@unittest.skip("Test currently fails")
def test_grab_silhouette_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Grab Silhouette")
self._check_stroke(backend, AttributeType.POSITION, start_over_mesh=True)
def test_nudge_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Nudge")
self._check_stroke(backend, AttributeType.POSITION)
def test_pinch_magnify_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Pinch/Magnify")
self._check_stroke(backend, AttributeType.POSITION)
def test_pose_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Pose")
self._check_stroke(backend, AttributeType.POSITION, start_over_mesh=True)
def test_pull_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Pull")
self._check_stroke(backend, AttributeType.POSITION)
def test_relax_pinch_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Relax Pinch")
self._check_stroke(backend, AttributeType.POSITION)
def test_relax_slide_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Relax Slide")
self._check_stroke(backend, AttributeType.POSITION)
def test_relax_brush_smooth_mode_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Relax Slide")
self._check_stroke(backend, AttributeType.POSITION, opts={"brush_toggle": 'SMOOTH'})
def test_snake_hook_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Snake Hook")
self._check_stroke(backend, AttributeType.POSITION)
def test_thumb_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Thumb")
self._check_stroke(backend, AttributeType.POSITION)
def test_twist_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Twist")
self._check_stroke(backend, AttributeType.POSITION, start_over_mesh=True)
def test_mask_brush_creates_valid_data(self):
for backend in MASK_BACKEND_TYPES:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Mask")
self._check_stroke(backend, AttributeType.MASK)
def test_mask_brush_smooth_mode_creates_valid_data(self):
for backend in MASK_BACKEND_TYPES:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Mask")
self._check_stroke(backend, AttributeType.MASK)
self._check_stroke(backend, AttributeType.MASK, opts={"brush_toggle": 'SMOOTH'})
def test_face_set_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Face Set Paint")
self._check_stroke(backend, AttributeType.FACE_SET)
def test_face_set_brush_smooth_mode_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Face Set Paint")
self._check_stroke(backend, AttributeType.FACE_SET)
self._check_stroke(backend, AttributeType.POSITION, opts={"brush_toggle": 'SMOOTH'})
def test_airbrush_brush_creates_valid_data(self):
for backend in COLOR_BACKEND_TYPES:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Airbrush")
self._check_stroke(backend, AttributeType.COLOR)
def test_blend_hard_brush_creates_valid_data(self):
for backend in COLOR_BACKEND_TYPES:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Blend Hard")
self._check_stroke(backend, AttributeType.COLOR)
def test_blend_soft_brush_creates_valid_data(self):
for backend in COLOR_BACKEND_TYPES:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Blend Soft")
self._check_stroke(backend, AttributeType.COLOR)
def test_blend_square_brush_creates_valid_data(self):
for backend in COLOR_BACKEND_TYPES:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Blend Square")
self._check_stroke(backend, AttributeType.COLOR)
def test_paint_blend_brush_creates_valid_data(self):
for backend in COLOR_BACKEND_TYPES:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Paint Blend")
self._check_stroke(backend, AttributeType.COLOR)
def test_paint_hard_brush_creates_valid_data(self):
for backend in COLOR_BACKEND_TYPES:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Paint Hard")
self._check_stroke(backend, AttributeType.COLOR)
def test_paint_hard_pressure_brush_creates_valid_data(self):
for backend in COLOR_BACKEND_TYPES:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Paint Hard Pressure")
self._check_stroke(backend, AttributeType.COLOR)
def test_paint_soft_brush_creates_valid_data(self):
for backend in COLOR_BACKEND_TYPES:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Paint Soft")
self._check_stroke(backend, AttributeType.COLOR)
def test_paint_soft_pressure_brush_creates_valid_data(self):
for backend in COLOR_BACKEND_TYPES:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Paint Soft Pressure")
self._check_stroke(backend, AttributeType.COLOR)
def test_paint_square_brush_creates_valid_data(self):
for backend in COLOR_BACKEND_TYPES:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Paint Square")
self._check_stroke(backend, AttributeType.COLOR)
def test_sharpen_brush_creates_valid_data(self):
for backend in COLOR_BACKEND_TYPES:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Paint Hard")
self._check_stroke(backend, AttributeType.COLOR)
self._activate_brush("Sharpen")
self._check_stroke(backend, AttributeType.COLOR)
def test_smear_brush_creates_valid_data(self):
for backend in COLOR_BACKEND_TYPES:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Paint Hard")
self._check_stroke(backend, AttributeType.COLOR)
self._activate_brush("Smear")
self._check_stroke(backend, AttributeType.COLOR)
def test_blur_brush_creates_valid_data(self):
for backend in COLOR_BACKEND_TYPES:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Paint Hard")
self._check_stroke(backend, AttributeType.COLOR)
self._activate_brush("Blur")
self._check_stroke(backend, AttributeType.COLOR)
@unittest.skip("Asserts in blender")
def test_bend_boundary_cloth_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Bend Boundary Cloth")
self._check_stroke(backend, AttributeType.POSITION)
def test_bend_twist_cloth_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Bend/Twist Cloth")
self._check_stroke(backend, AttributeType.POSITION)
def test_drag_cloth_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Drag Cloth")
self._check_stroke(backend, AttributeType.POSITION)
def test_expand_contract_cloth_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Expand/Contract Cloth")
self._check_stroke(backend, AttributeType.POSITION)
@unittest.skip("Test currently fails")
def test_grab_cloth_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Grab Cloth")
self._check_stroke(backend, AttributeType.POSITION, start_over_mesh=True)
@unittest.skip("Brush has a typo currently in the name, 'Grab Planar Cloth '")
def test_grab_planar_cloth_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Grab Planar Cloth")
self._check_stroke(backend, AttributeType.POSITION, start_over_mesh=True)
@unittest.skip("Test currently fails")
def test_grab_random_cloth_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Grab Random Cloth")
self._check_stroke(backend, AttributeType.POSITION, start_over_mesh=True)
def test_inflate_cloth_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Inflate Cloth")
self._check_stroke(backend, AttributeType.POSITION, start_over_mesh=True)
def test_pinch_folds_cloth_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Pinch Folds Cloth")
self._check_stroke(backend, AttributeType.POSITION, start_over_mesh=True)
def test_pinch_point_cloth_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Pinch Point Cloth")
self._check_stroke(backend, AttributeType.POSITION, start_over_mesh=True)
def test_push_cloth_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Push Cloth")
self._check_stroke(backend, AttributeType.POSITION, start_over_mesh=True)
def test_stretch_move_cloth_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Stretch/Move Cloth")
self._check_stroke(backend, AttributeType.POSITION, start_over_mesh=True)
@unittest.skip("Asserts in blender")
def test_twist_boundary_cloth_brush_creates_valid_data(self):
for backend in BackendType:
with self.subTest(backend):
self._initialize(backend)
self._activate_brush("Twist Boundary Cloth")
self._check_stroke(backend, AttributeType.POSITION)
def test_multires_smear_brush_creates_valid_data(self):
self._initialize(BackendType.MULTIRES)
self._activate_brush("Draw")
self._check_stroke(BackendType.MULTIRES, AttributeType.POSITION)
self._activate_brush("Smear Multires Displacement")
self._check_stroke(BackendType.MULTIRES, AttributeType.POSITION)
def test_multires_erase_brush_creates_valid_data(self):
self._initialize(BackendType.MULTIRES)
self._activate_brush("Draw")
self._check_stroke(BackendType.MULTIRES, AttributeType.POSITION)
self._activate_brush("Erase Multires Displacement")
self._check_stroke(BackendType.MULTIRES, AttributeType.POSITION)
def main():
global args
import argparse
argv = [sys.argv[0]]
if '--' in sys.argv:
argv += sys.argv[sys.argv.index('--') + 1:]
parser = argparse.ArgumentParser()
parser.add_argument('--testdir', required=True, type=pathlib.Path)
args, remaining = parser.parse_known_args(argv)
unittest.main(argv=remaining, verbosity=2)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,112 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later */
"""
blender -b --factory-startup --python tests/python/sculpt_paint/texture_paint_brushes_test.py -- --testdir tests/files/mesh_paint/
"""
__all__ = (
"main",
)
import enum
import math
import os
import pathlib
import unittest
import sys
import bpy
sys.path.append(os.path.dirname(os.path.realpath(__file__)))
from modules.test_helpers import set_view3d_context_override, generate_stroke, generate_monkey, BackendType
args = None
@enum.unique
class DataType(enum.Enum):
BYTE = 0
FLOAT = 1
def get_image_data():
return list(bpy.data.images["Untitled"].pixels)
class MeshBrushTests(unittest.TestCase):
"""
Test that none of the included brushes create NaN or inf valued vertices
"""
def _initialize(self, data_type: DataType):
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.context.preferences.experimental.use_sculpt_texture_paint = True
bpy.ops.ed.undo_push()
generate_monkey(BackendType.MESH)
bpy.ops.paint.add_texture_paint_slot(
type='BASE_COLOR',
slot_type='IMAGE',
name="Untitled",
color=(
1.0,
1.0,
1.0,
1.0),
width=512,
height=512,
alpha=True,
generated_type='BLANK',
float=data_type == DataType.FLOAT)
def _activate_brush(self, brush):
result = bpy.ops.brush.asset_activate(
asset_library_type='ESSENTIALS',
relative_asset_identifier='brushes/essentials_brushes-mesh_sculpt.blend/Brush/{}'.format(brush))
self.assertEqual({'FINISHED'}, result)
def _check_paint_stroke(self):
initial_data = get_image_data()
context_override = bpy.context.copy()
set_view3d_context_override(context_override)
with bpy.context.temp_override(**context_override):
bpy.ops.sculpt.brush_stroke(stroke=generate_stroke(context_override), override_location=True)
new_data = get_image_data()
# Note, depending on if the tests are run with asserts enabled or not, the test may fail before this point
# inside blender itself.
all_valid = all([not math.isinf(channel) and not math.isnan(channel) for channel in new_data])
any_different = any([orig != new for (orig, new) in zip(initial_data, new_data)])
self.assertTrue(all_valid, "All color components should be rational values")
self.assertTrue(any_different, "At least one color component should be different from its original value")
@unittest.skipIf(bpy.app.version_cycle != 'alpha', "Experimental features are only testable in alpha")
def test_paint_hard_brush_creates_valid_data(self):
for data_type in DataType:
with self.subTest(data_type):
self._initialize(data_type)
self._activate_brush("Paint Hard")
self._check_paint_stroke()
def main():
global args
import argparse
argv = [sys.argv[0]]
if '--' in sys.argv:
argv += sys.argv[sys.argv.index('--') + 1:]
parser = argparse.ArgumentParser()
parser.add_argument('--testdir', required=True, type=pathlib.Path)
args, remaining = parser.parse_known_args(argv)
unittest.main(argv=remaining)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,138 @@
# SPDX-FileCopyrightText: 2025 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later */
"""
blender -b --factory-startup --python tests/python/sculpt_paint/vertex_paint_brushes_test.py -- --testdir tests/files/mesh_paint/
"""
__all__ = (
"main",
)
import numpy as np
import math
import os
import pathlib
import unittest
import sys
import bpy
sys.path.append(os.path.dirname(os.path.realpath(__file__)))
from modules.test_helpers import set_view3d_context_override, generate_stroke
args = None
def get_attribute_data(
attribute_name='Attribute',
attribute_domain='CORNER',
attribute_size=4,
attribute_type=np.float32):
mesh = bpy.context.object.data
num_elements = mesh.attributes.domain_size(attribute_domain)
attribute_data = np.zeros((num_elements * attribute_size), dtype=attribute_type)
attribute = mesh.attributes.get(attribute_name)
meta_attribute = 'color'
if attribute:
attribute.data.foreach_get(meta_attribute, np.ravel(attribute_data))
return attribute_data
class MeshBrushTests(unittest.TestCase):
"""
Test that none of the included brushes create NaN or inf valued vertices
"""
def setUp(self):
bpy.ops.wm.open_mainfile(filepath=str(args.testdir / "30k_monkey.blend"), load_ui=False)
bpy.ops.ed.undo_push()
bpy.ops.paint.vertex_paint_toggle()
def _activate_brush(self, brush):
result = bpy.ops.brush.asset_activate(
asset_library_type='ESSENTIALS',
relative_asset_identifier='brushes/essentials_brushes-mesh_vertex.blend/Brush/{}'.format(brush))
self.assertEqual({'FINISHED'}, result)
def _check_paint_stroke(self):
initial_data = get_attribute_data()
context_override = bpy.context.copy()
set_view3d_context_override(context_override)
with bpy.context.temp_override(**context_override):
bpy.ops.paint.vertex_paint(stroke=generate_stroke(context_override), override_location=True)
new_data = get_attribute_data()
# Note, depending on if the tests are run with asserts enabled or not, the test may fail before this point
# inside blender itself.
all_valid = all([not math.isinf(channel) and not math.isnan(channel) for channel in new_data])
any_different = any([orig != new for (orig, new) in zip(initial_data, new_data)])
self.assertTrue(all_valid, "All color components should be rational values")
self.assertTrue(any_different, "At least one color component should be different from its original value")
def test_airbrush_brush_creates_valid_data(self):
self._activate_brush("Airbrush")
self._check_paint_stroke()
def test_paint_hard_brush_creates_valid_data(self):
self._activate_brush("Paint Hard")
self._check_paint_stroke()
def test_paint_hard_pressure_brush_creates_valid_data(self):
self._activate_brush("Paint Hard Pressure")
self._check_paint_stroke()
def test_paint_soft_brush_creates_valid_data(self):
self._activate_brush("Paint Soft")
self._check_paint_stroke()
def test_paint_soft_pressure_brush_creates_valid_data(self):
self._activate_brush("Paint Soft Pressure")
self._check_paint_stroke()
def test_average_brush_creates_valid_data(self):
self._activate_brush("Paint Hard")
self._check_paint_stroke()
self._activate_brush("Average")
self._check_paint_stroke()
def test_blur_brush_creates_valid_data(self):
self._activate_brush("Paint Hard")
self._check_paint_stroke()
self._activate_brush("Blur")
self._check_paint_stroke()
def test_smear_brush_creates_valid_data(self):
self._activate_brush("Paint Hard")
self._check_paint_stroke()
self._activate_brush("Smear")
self._check_paint_stroke()
def main():
global args
import argparse
argv = [sys.argv[0]]
if '--' in sys.argv:
argv += sys.argv[sys.argv.index('--') + 1:]
parser = argparse.ArgumentParser()
parser.add_argument('--testdir', required=True, type=pathlib.Path)
args, remaining = parser.parse_known_args(argv)
unittest.main(argv=remaining)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,36 @@
# SPDX-FileCopyrightText: 2026 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later
"""
blender -b --factory-startup tests/files/sculpting/voxel_remesh_compare --python tests/python/sculpt_paint/voxel_remesh_compare_test.py
"""
__all__ = (
"main",
)
import os
import sys
import bpy
BASE_DIR = os.path.dirname(os.path.realpath(__file__))
sys.path.append(os.path.join(BASE_DIR, ".."))
from modules.mesh_test import RunTest, SpecMeshTest, OperatorSpec
def main():
tests = [
SpecMeshTest("Color Interpolation", "testCube", "expectedCube",
[
OperatorSpec('SCULPT', 'ed.undo_push', {}),
OperatorSpec('SCULPT', 'object.voxel_remesh', {}),
]),
]
modifiers_test = RunTest(tests)
modifiers_test.main()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,53 @@
# SPDX-FileCopyrightText: 2025 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later */
"""
blender -b --factory-startup --python tests/python/sculpt_paint/voxel_remesh_test.py
"""
__all__ = (
"main",
)
import os
import sys
import unittest
import bpy
sys.path.append(os.path.dirname(os.path.realpath(__file__)))
class RemeshTest(unittest.TestCase):
def setUp(self):
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.ed.undo_push()
bpy.ops.mesh.primitive_cube_add()
bpy.ops.sculpt.sculptmode_toggle()
def test_operator_remeshes_basic_cube(self):
"""Test that using the operator with default settings creates a mesh with the expected amount of vertices."""
mesh = bpy.context.object.data
mesh.remesh_voxel_size = 0.1
ret_val = bpy.ops.object.voxel_remesh()
self.assertEqual({'FINISHED'}, ret_val)
num_vertices = mesh.attributes.domain_size('POINT')
self.assertEqual(num_vertices, 2648)
def test_operator_doesnt_run_with_0_voxel_size(self):
"""Test that using the operator returns an error to the user with a voxel size of 0."""
mesh = bpy.context.object.data
mesh.remesh_voxel_size = 0
with self.assertRaises(RuntimeError):
bpy.ops.object.voxel_remesh()
if __name__ == "__main__":
import sys
sys.argv = [__file__] + (sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else [])
unittest.main()

View File

@@ -0,0 +1,127 @@
# SPDX-FileCopyrightText: 2025 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later */
"""
blender -b --factory-startup --python tests/python/sculpt_paint/weight_paint_brushes_test.py -- --testdir tests/files/mesh_paint/
"""
__all__ = (
"main",
)
import math
import numpy as np
import os
import pathlib
import sys
import unittest
import bpy
sys.path.append(os.path.dirname(os.path.realpath(__file__)))
from modules.test_helpers import set_view3d_context_override, generate_stroke
args = None
def get_weights(ob, vgroup):
group_index = vgroup.index
for i, v in enumerate(ob.data.vertices):
for g in v.groups:
if g.group == group_index:
yield (i, g.weight)
break
def get_attribute_data():
obj = bpy.context.object
mesh = bpy.context.object.data
num_elements = mesh.attributes.domain_size('POINT')
attribute_data = np.zeros(num_elements, dtype=np.float32)
if obj.vertex_groups.get('Group'):
vgroup = obj.vertex_groups[0]
for (idx, weight) in list(get_weights(obj, vgroup)):
attribute_data[idx] = weight
return attribute_data
class MeshBrushTests(unittest.TestCase):
"""
Test that none of the included brushes create NaN or inf valued vertices
"""
def setUp(self):
bpy.ops.wm.open_mainfile(filepath=str(args.testdir / "30k_monkey.blend"), load_ui=False)
bpy.ops.ed.undo_push()
bpy.ops.paint.weight_paint_toggle()
def _activate_brush(self, brush):
result = bpy.ops.brush.asset_activate(
asset_library_type='ESSENTIALS',
relative_asset_identifier='brushes/essentials_brushes-mesh_weight.blend/Brush/{}'.format(brush))
self.assertEqual({'FINISHED'}, result)
def _check_stroke(self):
initial_data = get_attribute_data()
context_override = bpy.context.copy()
set_view3d_context_override(context_override)
with bpy.context.temp_override(**context_override):
bpy.ops.paint.weight_paint(stroke=generate_stroke(context_override), override_location=True)
new_data = get_attribute_data()
# Note, depending on if the tests are run with asserts enabled or not, the test may fail before this point
# inside blender itself.
all_valid = all([not math.isinf(weight) and not math.isnan(weight) for weight in new_data])
any_different = any([orig != new for (orig, new) in zip(initial_data, new_data)])
self.assertTrue(all_valid, "All weights should be rational values")
self.assertTrue(any_different, "At least one weight should be different from its original value")
def test_paint_brush_creates_valid_data(self):
self._activate_brush("Paint")
self._check_stroke()
def test_average_brush_creates_valid_data(self):
self._activate_brush("Paint")
self._check_stroke()
self._activate_brush("Average")
self._check_stroke()
def test_blur_brush_creates_valid_data(self):
self._activate_brush("Paint")
self._check_stroke()
self._activate_brush("Blur")
self._check_stroke()
def test_smear_brush_creates_valid_data(self):
self._activate_brush("Paint")
self._check_stroke()
self._activate_brush("Smear")
self._check_stroke()
def main():
global args
import argparse
argv = [sys.argv[0]]
if '--' in sys.argv:
argv += sys.argv[sys.argv.index('--') + 1:]
parser = argparse.ArgumentParser()
parser.add_argument('--testdir', required=True, type=pathlib.Path)
args, remaining = parser.parse_known_args(argv)
unittest.main(argv=remaining)
if __name__ == "__main__":
main()