Add Chromium-only Blender WebEngine parity work
This commit is contained in:
171
blender-5.2.0/tests/python/sculpt_paint/automask_test.py
Normal file
171
blender-5.2.0/tests/python/sculpt_paint/automask_test.py
Normal 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()
|
||||
72
blender-5.2.0/tests/python/sculpt_paint/brush_asset_test.py
Normal file
72
blender-5.2.0/tests/python/sculpt_paint/brush_asset_test.py
Normal 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 []))
|
||||
@@ -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()
|
||||
79
blender-5.2.0/tests/python/sculpt_paint/dyntopo_test.py
Normal file
79
blender-5.2.0/tests/python/sculpt_paint/dyntopo_test.py
Normal 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()
|
||||
157
blender-5.2.0/tests/python/sculpt_paint/face_set_test.py
Normal file
157
blender-5.2.0/tests/python/sculpt_paint/face_set_test.py
Normal 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()
|
||||
230
blender-5.2.0/tests/python/sculpt_paint/mask_test.py
Normal file
230
blender-5.2.0/tests/python/sculpt_paint/mask_test.py
Normal 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()
|
||||
137
blender-5.2.0/tests/python/sculpt_paint/mesh_filter_test.py
Normal file
137
blender-5.2.0/tests/python/sculpt_paint/mesh_filter_test.py
Normal 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()
|
||||
207
blender-5.2.0/tests/python/sculpt_paint/modules/test_helpers.py
Normal file
207
blender-5.2.0/tests/python/sculpt_paint/modules/test_helpers.py
Normal 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
|
||||
@@ -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()
|
||||
592
blender-5.2.0/tests/python/sculpt_paint/sculpt_brushes_test.py
Normal file
592
blender-5.2.0/tests/python/sculpt_paint/sculpt_brushes_test.py
Normal 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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
53
blender-5.2.0/tests/python/sculpt_paint/voxel_remesh_test.py
Normal file
53
blender-5.2.0/tests/python/sculpt_paint/voxel_remesh_test.py
Normal 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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user