267334d543
finger-xynova first design completed
375 lines
16 KiB
Python
375 lines
16 KiB
Python
"""Run with python -m pytest blender/scripts/test_texture_merge.py (no Blender needed).
|
|
|
|
Covers the two things that decide whether the printed marker is inked twice: that the seam
|
|
is a usable cut, and that ownership is a total, disjoint, deterministic function of the
|
|
triangle index.
|
|
"""
|
|
import unittest
|
|
|
|
from texture_merge import (
|
|
MODE_AUTHORED,
|
|
MODE_COMPUTED,
|
|
POLICY_BEST_COVERAGE,
|
|
MergeConfigError,
|
|
MergePolicy,
|
|
ViewFragments,
|
|
assign_ownership,
|
|
component_areas,
|
|
components_from_seam,
|
|
edge_key,
|
|
edge_use_counts,
|
|
ownership_disjoint,
|
|
triangle_edge_keys,
|
|
validate_seam,
|
|
)
|
|
|
|
# A single quad as two triangles. The shared edge (0,2) is the only interior edge, and both
|
|
# of its vertices lie on the mesh boundary, so a seam on it is a valid cut.
|
|
QUAD2 = [(0, 1, 2), (0, 2, 3)]
|
|
|
|
# A 2x2 quad grid, triangulated. Vertex 4 is the interior vertex; 1 and 7 are on the
|
|
# boundary. Used for the dangling / branching / interior-free-end cases.
|
|
GRID2X2 = [
|
|
(0, 1, 4), (0, 4, 3),
|
|
(1, 2, 5), (1, 5, 4),
|
|
(3, 4, 7), (3, 7, 6),
|
|
(4, 5, 8), (4, 8, 7),
|
|
]
|
|
|
|
|
|
class EdgeHelperTests(unittest.TestCase):
|
|
def test_edge_key_is_canonical(self):
|
|
self.assertEqual(edge_key(3, 1), (1, 3))
|
|
self.assertEqual(edge_key(1, 3), (1, 3))
|
|
|
|
def test_triangle_edge_keys(self):
|
|
self.assertEqual(triangle_edge_keys((5, 1, 3)), ((1, 5), (1, 3), (3, 5)))
|
|
|
|
def test_triangle_edge_keys_rejects_non_triangle(self):
|
|
with self.assertRaises(MergeConfigError):
|
|
triangle_edge_keys((1, 2, 3, 4))
|
|
|
|
def test_boundary_edges_of_a_single_quad(self):
|
|
_, boundary, non_manifold = edge_use_counts(QUAD2)
|
|
self.assertEqual(boundary, {(0, 1), (0, 3), (1, 2), (2, 3)})
|
|
self.assertEqual(non_manifold, set())
|
|
|
|
|
|
class ComponentsFromSeamTests(unittest.TestCase):
|
|
def test_no_seam_is_one_component(self):
|
|
self.assertEqual(components_from_seam(QUAD2, []), [0, 0])
|
|
|
|
def test_shared_edge_seam_splits_the_quad(self):
|
|
components = components_from_seam(QUAD2, [(0, 2)])
|
|
self.assertEqual(len(set(components)), 2)
|
|
|
|
def test_valid_path_across_the_grid_splits_it(self):
|
|
components = components_from_seam(GRID2X2, [(1, 4), (4, 7)])
|
|
self.assertGreater(len(set(components)), 1)
|
|
|
|
def test_deterministic_under_edge_order(self):
|
|
self.assertEqual(
|
|
components_from_seam(GRID2X2, [(4, 7), (1, 4)]),
|
|
components_from_seam(GRID2X2, [(1, 4), (4, 7)]),
|
|
)
|
|
|
|
def test_component_areas_sum_to_total(self):
|
|
components = components_from_seam(QUAD2, [(0, 2)])
|
|
areas = [1.0, 1.0]
|
|
self.assertAlmostEqual(sum(component_areas(components, areas).values()), 2.0)
|
|
|
|
|
|
class ValidateSeamTests(unittest.TestCase):
|
|
def test_valid_cut_on_the_quad_has_no_errors(self):
|
|
components = components_from_seam(QUAD2, [(0, 2)])
|
|
self.assertEqual(validate_seam(QUAD2, [(0, 2)], components), [])
|
|
|
|
def test_valid_boundary_to_boundary_path_has_no_errors(self):
|
|
components = components_from_seam(GRID2X2, [(1, 4), (4, 7)])
|
|
self.assertEqual(validate_seam(GRID2X2, [(1, 4), (4, 7)], components), [])
|
|
|
|
def test_no_seam_edges_is_an_error(self):
|
|
errors = validate_seam(QUAD2, [], components_from_seam(QUAD2, []))
|
|
self.assertTrue(any("no seam edges" in e for e in errors), errors)
|
|
|
|
def test_dangling_seam_that_does_not_separate_is_rejected(self):
|
|
"""The load-bearing rule: seam faces in the same component means no separation."""
|
|
components = components_from_seam(GRID2X2, [(1, 4)])
|
|
errors = validate_seam(GRID2X2, [(1, 4)], components)
|
|
self.assertTrue(any("SAME" in e and "component" in e for e in errors), errors)
|
|
|
|
def test_free_end_in_the_interior_is_rejected(self):
|
|
components = components_from_seam(GRID2X2, [(1, 4)])
|
|
errors = validate_seam(GRID2X2, [(1, 4)], components)
|
|
self.assertTrue(any("free seam end" in e for e in errors), errors)
|
|
|
|
def test_branching_seam_is_rejected(self):
|
|
seam = [(1, 4), (4, 7), (4, 5)]
|
|
components = components_from_seam(GRID2X2, seam)
|
|
errors = validate_seam(GRID2X2, seam, components)
|
|
self.assertTrue(any("degree" in e for e in errors), errors)
|
|
|
|
def test_unknown_edge_is_rejected(self):
|
|
seam = [(100, 200)]
|
|
errors = validate_seam(QUAD2, seam, components_from_seam(QUAD2, []))
|
|
self.assertTrue(any("not edges of any triangle" in e for e in errors), errors)
|
|
|
|
def test_component_count_mismatch_is_rejected(self):
|
|
errors = validate_seam(QUAD2, [(0, 2)], [0])
|
|
self.assertTrue(any("one per triangle" in e for e in errors), errors)
|
|
|
|
|
|
class PolicyTests(unittest.TestCase):
|
|
def test_rejects_unknown_mode(self):
|
|
with self.assertRaises(MergeConfigError):
|
|
MergePolicy(mode="guessed")
|
|
|
|
def test_rejects_coverage_out_of_range(self):
|
|
with self.assertRaises(MergeConfigError):
|
|
MergePolicy(min_triangle_coverage=1.5)
|
|
|
|
def test_rejects_incidence_out_of_range(self):
|
|
with self.assertRaises(MergeConfigError):
|
|
MergePolicy(incidence_limit_deg=120.0)
|
|
|
|
def test_defaults_match_the_calibration(self):
|
|
policy = MergePolicy()
|
|
self.assertEqual(policy.incidence_limit_deg, 62.0)
|
|
self.assertAlmostEqual(policy.min_footprint_mm2, 2.0e-3)
|
|
self.assertEqual(policy.min_triangle_coverage, 0.60)
|
|
self.assertEqual(policy.max_hole_fraction, 0.02)
|
|
|
|
|
|
class ViewFragmentsTests(unittest.TestCase):
|
|
def test_rejects_non_parallel_arrays(self):
|
|
with self.assertRaises(MergeConfigError):
|
|
ViewFragments(triangle=[0, 1], incidence_deg=[1.0], footprint_mm2=[1.0, 1.0])
|
|
|
|
def test_groups_by_triangle(self):
|
|
vf = ViewFragments(
|
|
triangle=[0, 1, 0], incidence_deg=[1.0, 2.0, 3.0], footprint_mm2=[0.1, 0.2, 0.3]
|
|
)
|
|
grouped = vf.by_triangle()
|
|
self.assertEqual(len(grouped[0]), 2)
|
|
self.assertEqual(len(grouped[1]), 1)
|
|
|
|
|
|
class ComputedOwnershipTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.area = [1.0, 1.0, 1.0]
|
|
self.fragments = {
|
|
"a": ViewFragments(
|
|
triangle=[0, 1], incidence_deg=[10.0, 10.0], footprint_mm2=[1.0, 0.5]
|
|
),
|
|
"b": ViewFragments(
|
|
triangle=[1, 2], incidence_deg=[10.0, 10.0], footprint_mm2=[1.0, 1.0]
|
|
),
|
|
}
|
|
|
|
def test_priority_then_fall_through(self):
|
|
"""Triangle 1 is only 50% covered by 'a', so it must fall through to 'b'."""
|
|
result = assign_ownership(self.area, self.fragments, ("a", "b"))
|
|
self.assertEqual(result["owner"], {0: "a", 1: "b", 2: "b"})
|
|
|
|
def test_every_triangle_has_at_most_one_owner(self):
|
|
result = assign_ownership(self.area, self.fragments, ("a", "b"))
|
|
self.assertTrue(ownership_disjoint(result["owner"]))
|
|
|
|
def test_deterministic_under_shuffled_fragment_order(self):
|
|
shuffled = {
|
|
"a": ViewFragments(
|
|
triangle=[1, 0], incidence_deg=[10.0, 10.0], footprint_mm2=[0.5, 1.0]
|
|
),
|
|
"b": ViewFragments(
|
|
triangle=[2, 1], incidence_deg=[10.0, 10.0], footprint_mm2=[1.0, 1.0]
|
|
),
|
|
}
|
|
self.assertEqual(
|
|
assign_ownership(self.area, self.fragments, ("a", "b"))["owner"],
|
|
assign_ownership(self.area, shuffled, ("a", "b"))["owner"],
|
|
)
|
|
|
|
def test_incidence_limit_is_inclusive(self):
|
|
fragments = {
|
|
"a": ViewFragments(triangle=[0], incidence_deg=[62.0], footprint_mm2=[1.0]),
|
|
}
|
|
result = assign_ownership([1.0], fragments, ("a",), MergePolicy())
|
|
self.assertEqual(result["owner"], {0: "a"})
|
|
|
|
def test_incidence_just_over_the_limit_is_a_hole(self):
|
|
fragments = {
|
|
"a": ViewFragments(triangle=[0], incidence_deg=[62.0001], footprint_mm2=[1.0]),
|
|
}
|
|
result = assign_ownership([1.0], fragments, ("a",), MergePolicy())
|
|
self.assertEqual(result["owner"], {})
|
|
self.assertAlmostEqual(result["hole_fraction"], 1.0)
|
|
|
|
def test_footprint_floor_is_inclusive(self):
|
|
"""Isolate the footprint gate: coverage is switched off so it cannot bind first."""
|
|
policy = MergePolicy(min_triangle_coverage=0.0)
|
|
at_floor = {
|
|
"a": ViewFragments(triangle=[0], incidence_deg=[1.0], footprint_mm2=[2.0e-3]),
|
|
}
|
|
self.assertEqual(assign_ownership([1.0], at_floor, ("a",), policy)["owner"], {0: "a"})
|
|
|
|
below_floor = {
|
|
"a": ViewFragments(triangle=[0], incidence_deg=[1.0], footprint_mm2=[1.999e-3]),
|
|
}
|
|
self.assertEqual(assign_ownership([1.0], below_floor, ("a",), policy)["owner"], {})
|
|
|
|
def test_footprint_floor_does_not_bypass_the_coverage_gate(self):
|
|
"""A fragment above the footprint floor can still fail the coverage gate."""
|
|
fragments = {
|
|
"a": ViewFragments(triangle=[0], incidence_deg=[1.0], footprint_mm2=[2.0e-3]),
|
|
}
|
|
# 2e-3 mm^2 is only 0.2% of a 1 mm^2 triangle, far below min_triangle_coverage.
|
|
self.assertEqual(assign_ownership([1.0], fragments, ("a",), MergePolicy())["owner"], {})
|
|
|
|
def test_coverage_threshold_is_inclusive(self):
|
|
"""0.6 of a unit triangle is exactly at the threshold and must be accepted."""
|
|
fragments = {
|
|
"a": ViewFragments(triangle=[0], incidence_deg=[1.0], footprint_mm2=[0.6]),
|
|
}
|
|
result = assign_ownership([1.0], fragments, ("a",), MergePolicy())
|
|
self.assertEqual(result["owner"], {0: "a"})
|
|
|
|
def test_coverage_just_below_the_threshold_is_a_hole(self):
|
|
fragments = {
|
|
"a": ViewFragments(triangle=[0], incidence_deg=[1.0], footprint_mm2=[0.599]),
|
|
}
|
|
result = assign_ownership([1.0], fragments, ("a",), MergePolicy())
|
|
self.assertEqual(result["owner"], {})
|
|
|
|
def test_no_view_data_leaves_all_triangles_as_holes(self):
|
|
result = assign_ownership(self.area, {"a": ViewFragments([], [], [])}, ("a",))
|
|
self.assertEqual(result["owner"], {})
|
|
self.assertAlmostEqual(result["hole_mm2"], 3.0)
|
|
|
|
def test_rejects_missing_view_data(self):
|
|
with self.assertRaises(MergeConfigError):
|
|
assign_ownership(self.area, self.fragments, ("a", "missing"))
|
|
|
|
def test_rejects_duplicate_priority(self):
|
|
with self.assertRaises(MergeConfigError):
|
|
assign_ownership(self.area, self.fragments, ("a", "a"))
|
|
|
|
|
|
class AuthoredOwnershipTests(unittest.TestCase):
|
|
def test_component_seam_owns_whole_components(self):
|
|
components = components_from_seam(QUAD2, [(0, 2)])
|
|
area = [1.0, 1.0]
|
|
# Each view qualifies only on its own component.
|
|
fragments = {
|
|
"a": ViewFragments(triangle=[0], incidence_deg=[10.0], footprint_mm2=[1.0]),
|
|
"b": ViewFragments(triangle=[1], incidence_deg=[10.0], footprint_mm2=[1.0]),
|
|
}
|
|
result = assign_ownership(
|
|
area, fragments, ("a", "b"),
|
|
MergePolicy(mode=MODE_AUTHORED), components=components,
|
|
)
|
|
owner_by_component = {components[t]: v for t, v in result["owner"].items()}
|
|
self.assertEqual(len(set(owner_by_component.values())), 2)
|
|
self.assertEqual(set(result["owner"]), {0, 1})
|
|
|
|
def test_best_coverage_uses_qualifying_footprint_not_visible_count(self):
|
|
"""The trap: a view seeing MORE triangles but at grazing incidence must lose.
|
|
|
|
This is the real mesh's situation in miniature — on `sensors/finger/r01` both views
|
|
see ~100% of each component's triangles, so a visible-count rule ties and silently
|
|
hands every component to the first view in priority order.
|
|
"""
|
|
area = [1.0, 1.0]
|
|
fragments = {
|
|
# Sees both triangles, but at 89 deg: nothing qualifies.
|
|
"a": ViewFragments(
|
|
triangle=[0, 1], incidence_deg=[89.0, 89.0], footprint_mm2=[1.0, 1.0]
|
|
),
|
|
# Sees one triangle, frontally.
|
|
"b": ViewFragments(triangle=[0], incidence_deg=[10.0], footprint_mm2=[1.0]),
|
|
}
|
|
result = assign_ownership(
|
|
area, fragments, ("a", "b"),
|
|
MergePolicy(mode=MODE_AUTHORED),
|
|
components=[0, 0],
|
|
)
|
|
self.assertEqual(result["component_owner"], {0: "b"})
|
|
|
|
def test_pinned_component_can_override_the_metric(self):
|
|
area = [1.0, 1.0]
|
|
fragments = {
|
|
"a": ViewFragments(triangle=[0], incidence_deg=[10.0], footprint_mm2=[1.0]),
|
|
"b": ViewFragments(triangle=[0, 1], incidence_deg=[10.0, 10.0], footprint_mm2=[1.0, 1.0]),
|
|
}
|
|
result = assign_ownership(
|
|
area, fragments, ("a", "b"),
|
|
MergePolicy(mode=MODE_AUTHORED, component_policy="pinned"),
|
|
components=[0, 0], pins={0: "a"},
|
|
)
|
|
self.assertEqual(result["component_owner"], {0: "a"})
|
|
# 'a' covers only triangle 0, so triangle 1 is an honest reported hole.
|
|
self.assertEqual(result["owner"], {0: "a"})
|
|
self.assertAlmostEqual(result["hole_fraction"], 0.5)
|
|
|
|
def test_unknown_pin_target_is_rejected(self):
|
|
with self.assertRaises(MergeConfigError):
|
|
assign_ownership(
|
|
[1.0], {"a": ViewFragments(triangle=[0], incidence_deg=[1.0], footprint_mm2=[1.0])},
|
|
("a",), MergePolicy(mode=MODE_AUTHORED), components=[0], pins={0: "nope"},
|
|
)
|
|
|
|
def test_authored_mode_requires_components(self):
|
|
with self.assertRaises(MergeConfigError):
|
|
assign_ownership(
|
|
[1.0], {"a": ViewFragments(triangle=[0], incidence_deg=[1.0], footprint_mm2=[1.0])},
|
|
("a",), MergePolicy(mode=MODE_AUTHORED),
|
|
)
|
|
|
|
def test_disjointness_holds_in_authored_mode(self):
|
|
components = components_from_seam(GRID2X2, [(1, 4), (4, 7)])
|
|
area = [1.0] * len(GRID2X2)
|
|
triangles = list(range(len(GRID2X2)))
|
|
fragments = {
|
|
"a": ViewFragments(triangle=triangles, incidence_deg=[10.0] * len(triangles),
|
|
footprint_mm2=[1.0] * len(triangles)),
|
|
"b": ViewFragments(triangle=triangles, incidence_deg=[20.0] * len(triangles),
|
|
footprint_mm2=[1.0] * len(triangles)),
|
|
}
|
|
result = assign_ownership(
|
|
area, fragments, ("a", "b"), MergePolicy(mode=MODE_AUTHORED), components=components
|
|
)
|
|
self.assertTrue(ownership_disjoint(result["owner"]))
|
|
self.assertEqual(len(result["owner"]), len(GRID2X2))
|
|
self.assertAlmostEqual(result["hole_fraction"], 0.0)
|
|
|
|
|
|
class PartitionTests(unittest.TestCase):
|
|
def test_owned_plus_hole_equals_total_area(self):
|
|
area = [0.5, 1.5, 2.0]
|
|
fragments = {
|
|
"a": ViewFragments(triangle=[0], incidence_deg=[1.0], footprint_mm2=[1.0]),
|
|
}
|
|
result = assign_ownership(area, fragments, ("a",))
|
|
self.assertAlmostEqual(result["owned_mm2"] + result["hole_mm2"], result["total_mm2"])
|
|
self.assertAlmostEqual(result["total_mm2"], 4.0)
|
|
|
|
def test_per_view_areas_account_for_every_owned_triangle(self):
|
|
area = [1.0, 1.0, 1.0]
|
|
fragments = {
|
|
"a": ViewFragments(triangle=[0, 1], incidence_deg=[1.0, 1.0], footprint_mm2=[1.0, 1.0]),
|
|
"b": ViewFragments(triangle=[2], incidence_deg=[1.0], footprint_mm2=[1.0]),
|
|
}
|
|
result = assign_ownership(area, fragments, ("a", "b"))
|
|
self.assertAlmostEqual(sum(result["per_view_mm2"].values()), result["owned_mm2"])
|
|
self.assertEqual(result["per_view_mm2"], {"a": 2.0, "b": 1.0})
|
|
|
|
def test_default_mode_is_computed(self):
|
|
area = [1.0]
|
|
fragments = {
|
|
"a": ViewFragments(triangle=[0], incidence_deg=[1.0], footprint_mm2=[1.0]),
|
|
}
|
|
self.assertEqual(assign_ownership(area, fragments, ("a",))["mode"], MODE_COMPUTED)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|