Tests: Add sculpt automask test for 'Face Set' and 'Topology' modes

Adds a new file for testing automask modes, specifically testing the
'Face Set' and 'Topology' modes.

This is achieved by performing a stroke on a Suzanne mesh with the face
sets initialized by loose parts (i.e. the eyes of the mesh).

Additionally, minor changes are included to the common paint test
helper to make testing easier.

Pull Request: https://projects.blender.org/blender/blender/pulls/154342
This commit is contained in:
Sean Kim 2026-02-18 00:50:18 +01:00 • committed by Sean Kim
parent 2a547ab59e
commit ac17d71d27
5 changed files with 192 additions and 8 deletions

View file

@ -1404,6 +1404,13 @@ if(TEST_SRC_DIR_EXISTS)
--testdir "${TEST_SRC_DIR}/sculpting"
)
add_blender_test(
bl_sculpt_automasking
--python ${CMAKE_CURRENT_LIST_DIR}/sculpt_paint/automask_test.py
--
--testdir "${TEST_SRC_DIR}/sculpting"
)
add_blender_test(
bl_sculpt_brushes
--python ${CMAKE_CURRENT_LIST_DIR}/sculpt_paint/sculpt_brushes_test.py

View file

@ -0,0 +1,173 @@
# SPDX-FileCopyrightText: 2025 Blender Authors
#
# SPDX-License-Identifier: GPL-2.0-or-later */
__all__ = (
"main",
)
import os
import math
import unittest
import sys
import pathlib
import numpy as np
import bpy
"""
blender -b --factory-startup --python tests/python/sculpt_paint/sculpt_brushes_test.py -- --testdir tests/files/mesh_paint/
"""
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, generate_monkey
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.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.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('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

@ -170,12 +170,11 @@ def generate_monkey(backend):
bpy.ops.object.subdivision_set(level=2, relative=False, ensure_modifier=True)
def generate_stroke(context, start_over_mesh=False):
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.
:param start_over_mesh start the stroke in the center of the screen (over the object)
"""
import bpy
from mathutils import Vector
@ -194,12 +193,9 @@ def generate_stroke(context, start_over_mesh=False):
}
num_steps = 50
if start_over_mesh:
start = Vector((context['area'].width // 2, context['area'].height // 2))
else:
start = Vector((context['area'].width, context['area'].height))
end = Vector((0, 0))
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 = []

View file

@ -51,6 +51,11 @@ class MeshBrushTests(unittest.TestCase):
"""
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()
@ -59,7 +64,7 @@ class MeshBrushTests(unittest.TestCase):
bpy.ops.sculpt.brush_stroke(
stroke=generate_stroke(
context_override,
start_over_mesh),
start_percent=start_percent),
override_location=True, **opts)
new_data = get_attribute_data(backend, attribute)