Add comprehensive tests for cutout and mesh functionalities

- Introduced `test_cutout.py` to validate cutout pie menu behavior, image lasso options, and mesh detail calculations.
- Added `test_mesh.py` to ensure correct tangent normal rasterization, plane source topology, and depth surface evaluations.
- Created `test_mesh_blender.py` to verify material node groups, tangent baking, and local cutout building from Blender images.
- Implemented tests for handling alpha regions and ensuring geometry nodes maintain expected behavior.
This commit is contained in:
2026-08-26 10:42:29 +08:00
parent 0cf04a13c6
commit 2b79a342b0
46 changed files with 5554 additions and 645 deletions

View File

@@ -0,0 +1,211 @@
def _choose_boundary_edge(start, direction, candidates):
right = (-direction[1], direction[0])
left = (direction[1], -direction[0])
back = (-direction[0], -direction[1])
preference = {right: 0, direction: 1, left: 2, back: 3}
return min(
candidates,
key=lambda end: preference.get(
(end[0] - start[0], end[1] - start[1]),
4,
),
)
def _simplify_axis_aligned_contour(points):
simplified = []
for index, current in enumerate(points):
previous = points[index - 1]
following = points[(index + 1) % len(points)]
first = (current[0] - previous[0], current[1] - previous[1])
second = (following[0] - current[0], following[1] - current[1])
if first[0] * second[1] - first[1] * second[0] != 0:
simplified.append(current)
return simplified
def trace_mask_contours(mask):
import numpy as np
mask = np.asarray(mask, dtype=bool)
if mask.ndim != 2:
raise ValueError("Cutout Alpha must be two-dimensional")
height, width = mask.shape
edges = {}
def add_edge(start, end):
edges.setdefault(start, []).append(end)
top = mask.copy()
top[1:] &= ~mask[:-1]
for y, x in np.argwhere(top):
add_edge((int(x), int(y)), (int(x) + 1, int(y)))
right = mask.copy()
right[:, :-1] &= ~mask[:, 1:]
for y, x in np.argwhere(right):
add_edge((int(x) + 1, int(y)), (int(x) + 1, int(y) + 1))
bottom = mask.copy()
bottom[:-1] &= ~mask[1:]
for y, x in np.argwhere(bottom):
add_edge((int(x) + 1, int(y) + 1), (int(x), int(y) + 1))
left = mask.copy()
left[:, 1:] &= ~mask[:, :-1]
for y, x in np.argwhere(left):
add_edge((int(x), int(y) + 1), (int(x), int(y)))
if not edges:
raise ValueError("The Cutout region has no usable boundary")
contours = []
edge_count = sum(len(values) for values in edges.values())
while edges:
start = min(edges, key=lambda point: (point[1], point[0]))
current = start
direction = (1, 0)
contour = []
for _index in range(edge_count + 1):
contour.append(current)
candidates = edges.get(current)
if not candidates:
raise ValueError("The Cutout boundary is open")
following = _choose_boundary_edge(current, direction, candidates)
candidates.remove(following)
if not candidates:
del edges[current]
direction = (following[0] - current[0], following[1] - current[1])
current = following
if current == start:
break
else:
raise ValueError("The Cutout boundary could not be closed")
contour = _simplify_axis_aligned_contour(contour)
if len(contour) >= 3:
contours.append(contour)
if not contours:
raise ValueError("The Cutout region is too small")
return contours
def _signed_contour_area(contour):
return 0.5 * sum(
start[0] * end[1] - end[0] * start[1]
for start, end in zip(contour, contour[1:] + contour[:1])
)
def _point_inside_contour(point, contour):
inside = False
previous = contour[-1]
for current in contour:
if (current[1] > point[1]) != (previous[1] > point[1]):
crossing_x = current[0] + (
(point[1] - current[1])
* (previous[0] - current[0])
/ (previous[1] - current[1])
)
inside ^= point[0] < crossing_x
previous = current
return inside
def mask_regions(mask):
contours = trace_mask_contours(mask)
outer = [contour for contour in contours if _signed_contour_area(contour) > 0.0]
holes = [contour for contour in contours if _signed_contour_area(contour) < 0.0]
if not outer:
raise ValueError("The Cutout region has no outer boundary")
regions = [[contour] for contour in outer]
outer_areas = [abs(_signed_contour_area(contour)) for contour in outer]
for hole in holes:
start = hole[0]
end = hole[1]
edge_x = end[0] - start[0]
edge_y = end[1] - start[1]
length = max((edge_x * edge_x + edge_y * edge_y) ** 0.5, 1e-12)
sample = (
(start[0] + end[0]) * 0.5 - edge_y * 0.25 / length,
(start[1] + end[1]) * 0.5 + edge_x * 0.25 / length,
)
owners = [
index
for index, contour in enumerate(outer)
if _point_inside_contour(sample, contour)
]
if not owners:
raise ValueError("The Cutout hole has no enclosing region")
owner = min(owners, key=outer_areas.__getitem__)
regions[owner].append(hole)
return regions
def _resize_alpha(values, size):
import numpy as np
target_width, target_height = (int(value) for value in size)
source_height, source_width = values.shape
if (target_width, target_height) == (source_width, source_height):
return values
x = (np.arange(target_width, dtype=np.float32) + 0.5) * (
source_width / target_width
) - 0.5
y = (np.arange(target_height, dtype=np.float32) + 0.5) * (
source_height / target_height
) - 0.5
x0_unclipped = np.floor(x).astype(np.int32)
y0_unclipped = np.floor(y).astype(np.int32)
x_weight = x - x0_unclipped
y_weight = y - y0_unclipped
x0 = np.clip(x0_unclipped, 0, source_width - 1)
y0 = np.clip(y0_unclipped, 0, source_height - 1)
x1 = np.clip(x0_unclipped + 1, 0, source_width - 1)
y1 = np.clip(y0_unclipped + 1, 0, source_height - 1)
horizontal = (
values[:, x0] * (1.0 - x_weight)[None, :]
+ values[:, x1] * x_weight[None, :]
)
return (
horizontal[y0] * (1.0 - y_weight)[:, None]
+ horizontal[y1] * y_weight[:, None]
)
def alpha_regions(alpha, max_resolution=512, threshold=0.5):
import numpy as np
values = np.asarray(alpha)
if values.ndim != 2:
raise ValueError("Cutout Alpha must be two-dimensional")
if np.issubdtype(values.dtype, np.floating):
values = np.clip(values, 0.0, 1.0) * 255.0
else:
values = np.clip(values, 0, 255).astype(np.float32)
source_size = (values.shape[1], values.shape[0])
scale = min(1.0, float(max_resolution) / max(source_size))
sampled_size = tuple(max(1, round(value * scale)) for value in source_size)
if sampled_size != source_size:
values = _resize_alpha(values, sampled_size)
mask = values > round(255.0 * threshold)
regions = mask_regions(mask)
scale_x = source_size[0] / sampled_size[0]
scale_y = source_size[1] / sampled_size[1]
return (
[
[
[[float(x) * scale_x, float(y) * scale_y] for x, y in contour]
for contour in region
]
for region in regions
],
source_size,
)
def image_alpha_regions(path, max_resolution=512, threshold=0.5):
import numpy as np
from PIL import Image
with Image.open(path) as source:
alpha = np.asarray(source.convert("RGBA").getchannel("A"), dtype=np.uint8)
return alpha_regions(alpha, max_resolution=max_resolution, threshold=threshold)