Refactor Cutout Tool to Use World Space Edge Length

- Replaced the Mesh Detail parameter with a new Edge Length property to define cutout sampling in physical units.
- Introduced Minimum Relative Edge Length preference to control effective spacing based on the bounding box of the cutout content.
- Updated tests to validate the new edge length functionality and ensure consistent behavior across different resolutions and canvas sizes.
- Removed deprecated parameters and adjusted related properties to align with the new edge length implementation.
- Enhanced geometry sampling logic to utilize effective world spacing for both standard and fine outline modes.
This commit is contained in:
2026-09-12 23:03:24 +08:00
parent 938d95db0f
commit 18799e8828
28 changed files with 505 additions and 279 deletions

View File

@@ -0,0 +1,132 @@
from types import SimpleNamespace
from unittest.mock import Mock, patch
import bpy
import numpy as np
import pytest
from mathutils import Matrix
from anyimage import properties
from anyimage.operators.cutout_tool import geometry as g
from anyimage.operators.cutout_tool import operators
def source_plane(size=(100, 100), display_size=1, matrix=None):
return SimpleNamespace(
data=SimpleNamespace(size=size),
empty_display_size=display_size,
empty_image_offset=(-0.5, -0.5),
matrix_world=Matrix.Identity(4) if matrix is None else matrix,
)
def test_relative_floor_uses_visible_world_bounds(monkeypatch):
monkeypatch.setattr(g, "configured_min_cutout_relative_edge_length", lambda _: 0.01)
alpha = np.zeros((100, 100))
alpha[25:75, 25:75] = 1
source = source_plane(display_size=2)
resolve = lambda length: g.effective_cutout_edge_length(None, source, length, alpha, (0, 0, 100, 100))
assert resolve(0.001) == 0.01
assert resolve(0.1) == 0.1
source.matrix_world = Matrix.Rotation(0.7, 4, "Y") @ Matrix.Diagonal((2, 3, 1, 1))
assert resolve(0.001) == pytest.approx(0.03)
monkeypatch.setattr(g, "configured_min_cutout_relative_edge_length", lambda _: 0.02)
assert resolve(0.001) == pytest.approx(0.06)
@pytest.mark.parametrize("fine_outline", (False, True))
def test_mesh_density_survives_resolution_and_canvas_changes(fine_outline):
meshes = []
for size, display_size, inset in ((100, 1, 0), (200, 1, 0), (200, 2, 50)):
source = source_plane((size, size), display_size)
alpha = np.zeros((size, size))
alpha[inset:size-inset, inset:size-inset] = 1
spacing = g.effective_cutout_edge_length(bpy.context, source, 0.1, alpha, (0, 0, size, size))
assert spacing == 0.1
meshes.append(g.build_base_shape(bpy.context, source, spacing, alpha, (0, 0, size, size), fine_outline=fine_outline))
counts = [len(mesh.vertices) for mesh in meshes]
assert max(counts) / min(counts) < 1.05
for mesh in meshes:
assert np.isfinite(mesh.vertices).all()
np.testing.assert_allclose(np.ptp(mesh.vertices[:, :2], axis=0), (1, 1), atol=0.03)
@pytest.mark.parametrize("fine_outline", (False, True))
def test_world_scaling_adds_samples_and_rotation_preserves_density(fine_outline):
alpha = np.ones((100, 100))
counts = []
for matrix in (Matrix.Identity(4), Matrix.Diagonal((2, 3, 1, 1)),
Matrix.Rotation(0.6, 4, "Z") @ Matrix.Diagonal((2, 3, 1, 1))):
source = source_plane(matrix=matrix)
mesh = g.build_base_shape(bpy.context, source, 0.1, alpha, (0, 0, 100, 100), fine_outline=fine_outline)
counts.append(len(mesh.vertices))
planar = mesh.vertices[:, :2] @ g.metric_transform(g.world_plane_metric(matrix)[0])
edges = np.unique(np.sort(np.concatenate([mesh.faces[:, [0, 1]], mesh.faces[:, [1, 2]], mesh.faces[:, [2, 0]]]), axis=1), axis=0)
lengths = np.linalg.norm(planar[edges[:, 0]] - planar[edges[:, 1]], axis=1)
assert 0.06 < np.median(lengths) < 0.14
assert counts[1] > counts[0] * 4
assert abs(counts[2] - counts[1]) < counts[1] * 0.02
def test_length_property_uses_scene_units_and_preserves_physical_value():
class CutoutLengthTestSettings(bpy.types.PropertyGroup):
edge_length: properties.cutout_edge_length_property(store_meters=True)
scene = bpy.context.scene
previous_scale, previous_system = scene.unit_settings.scale_length, scene.unit_settings.system
bpy.utils.register_class(CutoutLengthTestSettings)
bpy.types.Scene.cutout_length_test = bpy.props.PointerProperty(type=CutoutLengthTestSettings)
try:
scene.unit_settings.system = "METRIC"
scene.unit_settings.scale_length = 0.01
settings = scene.cutout_length_test
assert settings.edge_length == pytest.approx(10)
settings.edge_length = bpy.utils.units.to_value("METRIC", "LENGTH", "0.2m") / scene.unit_settings.scale_length
assert settings.edge_length == pytest.approx(20)
scene.unit_settings.scale_length = 1
assert settings.edge_length == pytest.approx(0.2)
finally:
scene.unit_settings.scale_length = previous_scale
scene.unit_settings.system = previous_system
if "cutout_length_test" in scene:
del scene["cutout_length_test"]
del bpy.types.Scene.cutout_length_test
bpy.utils.unregister_class(CutoutLengthTestSettings)
def test_operator_length_property_accepts_world_units():
captured = []
class CutoutLengthTestOperator(bpy.types.Operator):
bl_idname = "anyimage.test_cutout_length"
bl_label = "Test Cutout Length"
edge_length: properties.cutout_edge_length_property()
def execute(self, context):
captured.append(self.edge_length)
return {"FINISHED"}
units = bpy.context.scene.unit_settings
previous = units.scale_length
bpy.utils.register_class(CutoutLengthTestOperator)
try:
units.scale_length = 0.01
bpy.ops.anyimage.test_cutout_length(edge_length=20)
assert captured == pytest.approx([20])
finally:
units.scale_length = previous
bpy.utils.unregister_class(CutoutLengthTestOperator)
def test_effective_length_report_and_geometry_share_value():
report = Mock()
context = SimpleNamespace(scene=SimpleNamespace(unit_settings=SimpleNamespace(system="METRIC", scale_length=0.01)))
with patch.object(operators, "effective_cutout_edge_length", return_value=20), \
patch.object(operators, "build_base_shape") as build, \
patch.object(operators, "create_shape_object") as create, \
patch.object(operators.bpy.ops.ed, "undo_push", return_value={"FINISHED"}):
operators.create_cutout_shape(context, object(), "FLAT", 10, np.ones((10, 10)), (0, 0, 10, 10), color_image=object(), report=report)
assert build.call_args.args[2] == 20
assert create.call_args.args[3] == 20
assert report.call_args.args[0] == {"INFO"}
assert "20 cm" in report.call_args.args[1]

View File

@@ -156,13 +156,13 @@ def test_closed_mode_uses_original_geometry_without_fine_import(monkeypatch):
)
alpha = np.ones((60, 80))
actual = g.build_base_shape(
None, source, 16, alpha, (0, 0, 80, 60), fine_outline=False
None, source, 0.4, alpha, (0, 0, 80, 60), fine_outline=False
)
contours = g.extract_cutout_contours(alpha, 16, threshold=0.9)
contours = g.extract_cutout_contours(alpha, 5, threshold=0.9)
expected = g.cutout_domain(
contours,
image_empty_bounds(source),
g.cutout_sampling_spacing(source, 16),
0.4,
((1.0, 0.0), (0.0, 1.0)),
)
np.testing.assert_array_equal(actual.vertices, expected[0])
@@ -195,7 +195,7 @@ def test_relaxation_reduces_sampling_error_without_adding_points():
contours = [np.asarray(((4.0, 4.0), (194.0, 4.0), (194.0, 104.0), (4.0, 104.0)))]
spine = np.empty((0, 2))
original = g.uniform_interior_points_for_contours(contours, 16)
relaxed = fine.relax_points(contours, spine, (110, 200), 16)
relaxed = fine.relax_points(contours, spine, 16)
yy, xx = np.mgrid[6:104:4, 6:194:4]
samples = np.column_stack((xx.ravel() + 0.5, yy.ravel() + 0.5))

View File

@@ -33,12 +33,6 @@ def mesh_component_count(faces):
return count
def test_cutout_mesh_limit_caps_actual_sampling_resolution():
assert cutout_geometry.limit_cutout_point_spacing(32, (4096, 1024), 1024) == 32
assert cutout_geometry.limit_cutout_point_spacing(32, (4096, 1024), 64) == 64
assert cutout_geometry.limit_cutout_point_spacing(4, (8192, 4096), 1024) == 8
def test_cutout_node_groups_are_loaded_from_the_asset():
loaded = object()
with patch.object(
@@ -147,7 +141,7 @@ def test_blender_cutout_alpha_pipeline_does_not_import_pillow():
assert np.any((antialiased > 0.0) & (antialiased < 1.0))
def test_blender_cutout_contour_resolution_tracks_mesh_detail():
def test_blender_cutout_contour_resolution_tracks_sampling_resolution():
expected = SelectionContours(
components=(((((0, 0), (1, 0), (0, 1))),),),
image_size=(1, 1),
@@ -155,53 +149,12 @@ def test_blender_cutout_contour_resolution_tracks_mesh_detail():
values = SimpleNamespace(shape=(1024, 1024))
with patch.object(cutout_geometry, "extract_selection_contours", return_value=expected) as trace:
assert cutout_geometry.extract_cutout_contours(values, 32) == expected
assert cutout_geometry.extract_cutout_contours(values, 4) == expected
assert cutout_geometry.extract_cutout_contours(values, 256) == expected
assert trace.call_args_list[0].kwargs["max_resolution"] == 512
assert trace.call_args_list[1].kwargs["max_resolution"] == 1024
def test_cutout_sampling_spacing_uses_the_complete_source_plane():
source = SimpleNamespace(
data=SimpleNamespace(size=(400, 200)),
matrix_world=object(),
)
with (
patch.object(cutout_geometry, "image_empty_bounds", return_value=(-2.0, 2.0, -1.0, 1.0)),
patch.object(
cutout_geometry,
"world_plane_metric",
return_value=(((1.0, 0.0), (0.0, 1.0)), 1.0),
),
):
spacing = cutout_geometry.cutout_sampling_spacing(source, 32)
assert spacing == 0.32
def test_cutout_sampling_spacing_tracks_upscaled_source_pixels():
original = SimpleNamespace(
data=SimpleNamespace(size=(2048, 1024)),
matrix_world=object(),
)
upscaled = SimpleNamespace(
data=SimpleNamespace(size=(8192, 4096)),
matrix_world=object(),
)
with (
patch.object(cutout_geometry, "image_empty_bounds", return_value=(-2.0, 2.0, -1.0, 1.0)),
patch.object(
cutout_geometry,
"world_plane_metric",
return_value=(((1.0, 0.0), (0.0, 1.0)), 1.0),
),
):
original_spacing = cutout_geometry.cutout_sampling_spacing(original, 32)
upscaled_spacing = cutout_geometry.cutout_sampling_spacing(upscaled, 32)
assert upscaled_spacing == original_spacing / 4
def test_cutout_cleanup_value_scales_the_ui_without_changing_node_area():
assert np.isclose(
cutout_geometry.cutout_cleanup_value(

View File

@@ -35,13 +35,13 @@ class WindowManager:
def cutout_context(
window_manager,
mesh_detail="LOW",
edge_length=0.1,
generate_normal=False,
alpha_threshold=0.9,
fine_outline=True,
):
settings = SimpleNamespace(
cutout_mesh_detail=mesh_detail,
cutout_edge_length=edge_length,
cutout_generate_normal=generate_normal,
cutout_alpha_threshold=alpha_threshold,
cutout_fine_outline=fine_outline,
@@ -97,14 +97,14 @@ def test_cutout_pie_uses_two_local_shapes_without_ai(monkeypatch):
for call in window_manager.pie.calls
)
assert not any(call[2].generate_normal for call in window_manager.pie.calls)
assert all(call[2].point_spacing == 32 for call in window_manager.pie.calls)
assert all(call[2].edge_length == 0.1 for call in window_manager.pie.calls)
def test_cutout_pie_passes_image_lasso_options_to_all_ai_shapes(monkeypatch):
window_manager = WindowManager()
context = cutout_context(
window_manager,
mesh_detail="MEDIUM",
edge_length=0.05,
generate_normal=True,
alpha_threshold=0.1,
fine_outline=False,
@@ -150,7 +150,7 @@ def test_cutout_pie_passes_image_lasso_options_to_all_ai_shapes(monkeypatch):
== ((0.0, 0.0), (10.0, 0.0), (0.0, 10.0))
for call in window_manager.pie.calls
)
assert all(call[2].point_spacing == 16 for call in window_manager.pie.calls)
assert all(call[2].edge_length == 0.05 for call in window_manager.pie.calls)
def test_cutout_normal_space_is_derived_from_shape():
@@ -162,13 +162,6 @@ def test_cutout_normal_space_is_derived_from_shape():
assert cutout_main.normal_mode_for_shape("DEPTH_SOLID", True) == "OBJECT"
def test_cutout_mesh_detail_maps_to_denser_sampling():
assert [
interaction.cutout_mesh_detail_spacing(detail)
for detail in ("LOW", "MEDIUM", "HIGH", "ULTRA")
] == [32, 16, 8, 4]
def test_cutout_lasso_submission_passes_only_the_selected_path(monkeypatch):
source = cutout_source()
window_manager = WindowManager()

View File

@@ -43,13 +43,13 @@ def test_cutout_depth_axis_is_adjusted_only_in_modifier(shape, gesture):
positive_x = _create_cutout_shape(
source,
shape,
8,
0.1,
gesture=gesture,
)
negative_z = _create_cutout_shape(
source,
shape,
8,
0.1,
gesture=gesture,
)
@@ -160,7 +160,7 @@ def test_balloon_build_starts_from_the_unified_single_sheet():
result = _create_cutout_shape(
source,
"SOLID",
8,
0.1,
)
assert all(abs(vertex.co.z) < 1e-8 for vertex in result.data.vertices)
@@ -211,7 +211,7 @@ def test_teddy_preference_writes_the_shared_balloon_attribute():
result = _create_cutout_shape(
source,
"FLAT",
8,
0.1,
)
attribute = result.data.attributes[cutout_shape.BALLOON_ATTRIBUTE_NAME]
@@ -251,7 +251,7 @@ def test_depth_surface_protected_smooth_evaluates(gesture):
result = _create_cutout_shape(
source,
"DEPTH_SOLID",
8,
0.25,
depth_image=depth_image,
depth_metadata=depth_metadata,
gesture=gesture,
@@ -270,7 +270,7 @@ def test_depth_surface_protected_smooth_evaluates(gesture):
0.0,
)
expected_cleanup = cutout_geometry.cutout_cleanup_value(
cutout_geometry.cutout_sampling_spacing(source, 8),
0.25,
cutout_geometry.world_plane_metric(source.matrix_world)[0],
)
assert np.isclose(
@@ -374,7 +374,7 @@ def test_depth_surface_keeps_continuous_points_across_zero_validity_edge():
result = _create_cutout_shape(
source,
"DEPTH_SOLID",
8,
0.1,
depth_image=_depth_image(points, validity),
depth_metadata=_depth_metadata(intrinsics, (size, size), 1.0),
)
@@ -621,7 +621,7 @@ def test_depth_balloon_uses_metric_plane_inputs():
result = _create_cutout_shape(
source,
"DEPTH_BALLOON",
8,
0.1,
depth_image=depth_image,
depth_metadata=depth_metadata,
bounds=crop_bounds,
@@ -869,7 +869,7 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job():
bpy.context,
source,
"FLAT",
16,
0.1,
content_values,
selection_mask.bounds,
alpha_threshold=0.5,
@@ -885,7 +885,7 @@ def test_local_cutout_builds_from_blender_image_pixels_without_a_job():
modifier = result.modifiers[0]
assert modifier.node_group.name == cutout_shape.CUTOUT_NODE_GROUP_NAMES["FLAT"]
expected_cleanup = cutout_geometry.cutout_cleanup_value(
cutout_geometry.cutout_sampling_spacing(source, 16),
0.1,
cutout_geometry.world_plane_metric(source.matrix_world)[0],
)
assert np.isclose(

View File

@@ -69,7 +69,7 @@ class CutoutOperatorsTest(BlenderTestCase):
),
shape=shape,
gesture="POLYLINE",
point_spacing=32,
edge_length=0.1,
alpha_threshold=0.25,
fine_outline=False,
generate_normal=False,
@@ -98,11 +98,6 @@ class CutoutOperatorsTest(BlenderTestCase):
"configured_max_ai_input_size",
return_value=2048,
),
patch.object(
self.cutout_main,
"configured_max_cutout_mesh_resolution",
return_value=1024,
),
patch.object(
self.cutout_main,
"_cutout_content_values",
@@ -128,13 +123,14 @@ class CutoutOperatorsTest(BlenderTestCase):
context,
source,
shape,
32.0,
0.1,
selection_mask.values,
selection_mask.bounds,
alpha_threshold=0.25,
fine_outline=False,
color_image="Color",
gesture="POLYLINE",
report=operator.report,
)
@@ -161,7 +157,8 @@ class CutoutOperatorsTest(BlenderTestCase):
{"points": [[1, 1], [7, 1], [7, 3], [1, 3]]}
)
operator.shape = "DEPTH_SOLID"
operator.point_spacing = 32
operator.report = lambda *_args: None
operator.edge_length = 0.1
operator.generate_normal = False
operator.report = lambda *_args: None
@@ -187,11 +184,6 @@ class CutoutOperatorsTest(BlenderTestCase):
"configured_max_ai_input_size",
return_value=2048,
),
patch.object(
self.cutout_main,
"configured_max_cutout_mesh_resolution",
return_value=1024,
),
patch.object(
self.cutout_main,
"invoke_ai_setup_if_needed",
@@ -241,7 +233,7 @@ class CutoutOperatorsTest(BlenderTestCase):
{"points": [[1, 1], [7, 1], [7, 3], [1, 3]]}
),
shape="FLAT",
point_spacing=32,
edge_length=0.1,
generate_normal=False,
report=lambda level, message: reports.append((level, message)),
)
@@ -377,7 +369,7 @@ class CutoutOperatorsTest(BlenderTestCase):
layout.events,
[
("prop", "cutout_gesture", True),
("prop", "cutout_mesh_detail", True),
("prop", "cutout_edge_length", True),
("prop", "cutout_fine_outline", True),
("prop", "cutout_alpha_threshold", True),
setup,
@@ -413,7 +405,8 @@ class CutoutOperatorsTest(BlenderTestCase):
operator.color_path = __file__
operator.shape = "FLAT"
operator.gesture = "POLYLINE"
operator.point_spacing = 32
operator.report = lambda *_args: None
operator.edge_length = 0.1
operator.generate_normal = True
operator._selection_mask = selection_mask
result = SimpleNamespace(value={}, file=lambda _key: Path(__file__))