fix cli redundant images

finger-xynova first design completed
This commit is contained in:
ykcheungab
2026-09-28 16:18:17 +08:00
parent 88922f21ef
commit 267334d543
40 changed files with 618899 additions and 263563 deletions
+382 -37
View File
@@ -1,7 +1,8 @@
"""Tests for multi-view production texture pairs (`orisys-design texture-views`).
Step 1 scope: one independent texture per ``(camera, UV layout)`` pair. Nothing here needs
Blender — the host module is stdlib-only, which is the point: the pair validation and the job
Each pair writes its own texture, and every mesh triangle belongs to exactly one pair, so the
two files cannot ink the same surface. Nothing here needs Blender — the host module is
stdlib-only, which is the point: the pair validation, the ownership policy and the job
contract are checkable in CI, and the Blender side consumes a job it did not have to
interpret.
@@ -14,6 +15,7 @@ from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
import pytest
@@ -79,17 +81,9 @@ def _block(pairs_yaml: str, enabled: str = " enabled: true\n") -> str:
return "texture_patch_views:\n" + enabled + " pairs:\n" + pairs_yaml
#: The shape the r01 revision uses: layouts only. The pattern camera defaults to
#: `texture_patch.camera`, so both layouts carry the same surface pattern as `compute`.
#: The shape the r01 revision uses: a REGION camera per layout. The pattern camera defaults
#: to `texture_patch.camera`, so both layouts carry the same surface pattern as `compute`.
TWO_PAIRS = _block(
" - id: uv_1\n"
" uv_map: uv_1\n"
" - id: uv_2\n"
" uv_map: uv_2\n"
)
#: Explicit per-pair pattern cameras — an override, not the default.
TWO_PAIRS_EXPLICIT_CAMERAS = _block(
" - id: uv_1\n"
" camera: cam_uv_1\n"
" uv_map: uv_1\n"
@@ -98,6 +92,28 @@ TWO_PAIRS_EXPLICIT_CAMERAS = _block(
" uv_map: uv_2\n"
)
#: Explicit per-pair pattern cameras — an override, not the default.
TWO_PAIRS_EXPLICIT_PATTERN = _block(
" - id: uv_1\n"
" camera: cam_uv_1\n"
" pattern_camera: cam_alt\n"
" uv_map: uv_1\n"
" - id: uv_2\n"
" camera: cam_uv_2\n"
" uv_map: uv_2\n"
)
#: A merge/seam policy block appended after the pairs.
AUTHORED_MERGE = (
" merge:\n"
" mode: authored\n"
" incidence_limit_deg: 62.0\n"
" min_triangle_coverage: 0.60\n"
" seam:\n"
" boundary: use_seam\n"
" pins: []\n"
)
@pytest.fixture()
def real_revision() -> Path:
@@ -151,14 +167,17 @@ def test_views_block_must_be_a_mapping(tmp_path: Path) -> None:
"duplicate pair id",
),
(
# Two pairs, both defaulting to the sensing camera, writing the same layout.
_block(" - id: a\n uv_map: u\n - id: b\n uv_map: u\n"),
# Two pairs that both default to the sensing camera pattern, writing one layout:
# same pattern + same layout means byte-identical textures.
_block(" - id: a\n camera: c\n uv_map: u\n"
" - id: b\n camera: c2\n uv_map: u\n"),
"would be identical",
),
(
# Two pairs sharing one region camera.
_block(" - id: a\n camera: c\n uv_map: u\n"
" - id: b\n camera: c\n uv_map: u\n"),
"would be identical",
" - id: b\n camera: c\n uv_map: u2\n"),
"would claim the same surface region",
),
(
_block(" - id: a\n camera: c\n uv_map: u\n"
@@ -186,6 +205,269 @@ def test_validation_rejects_bad_blocks(tmp_path: Path, views_yaml: str, expected
assert expected in str(excinfo.value)
# ---------------------------------------------------------------- merge and seam blocks
@pytest.mark.parametrize(
("merge_yaml", "expected"),
[
(" merge:\n mode: guessed\n", "mode"),
(" merge:\n component_policy: guessing\n", "component_policy"),
(" merge:\n incidence_limit_deg: 200\n", "incidence_limit_deg"),
(" merge:\n min_triangle_coverage: 2\n", "min_triangle_coverage"),
(" merge:\n max_hole_fraction: -1\n", "max_hole_fraction"),
(" merge:\n nope: 1\n", "unknown key"),
(" merge: nope\n", "must be a mapping"),
],
)
def test_merge_block_rejects_bad_values(tmp_path: Path, merge_yaml: str, expected: str) -> None:
revision = _write_revision(tmp_path, TWO_PAIRS + merge_yaml)
paths = RevisionPaths.from_revision_dir(revision)
with pytest.raises(TextureConfigError) as excinfo:
texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
assert expected in str(excinfo.value)
def test_merge_block_rejects_non_numeric_threshold(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, TWO_PAIRS + " merge:\n min_footprint_mm2: thick\n")
paths = RevisionPaths.from_revision_dir(revision)
with pytest.raises(TextureConfigError) as excinfo:
texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
assert "min_footprint_mm2" in str(excinfo.value)
@pytest.mark.parametrize(
("seam_yaml", "expected"),
[
(" seam:\n boundary: guesswork\n", "boundary"),
(" seam:\n nope: 1\n", "unknown key"),
(" seam: nope\n", "must be a mapping"),
(" seam:\n pins: nope\n", "pins"),
(
" seam:\n pins:\n"
" - component: -1\n view: uv_1\n reason: x\n",
"component",
),
(" seam:\n pins:\n - component: 0\n view: uv_1\n", "reason"),
(
" seam:\n pins:\n"
" - component: 0\n view: ''\n reason: x\n",
"view",
),
(
" seam:\n pins:\n"
" - component: 0\n view: uv_1\n reason: a\n"
" - component: 0\n view: uv_2\n reason: b\n",
"duplicate pin",
),
(
" seam:\n pins:\n"
" - component: 0\n view: uv_1\n reason: a\n extra: 1\n",
"unknown key",
),
],
)
def test_seam_block_rejects_bad_values(tmp_path: Path, seam_yaml: str, expected: str) -> None:
revision = _write_revision(tmp_path, TWO_PAIRS + seam_yaml)
paths = RevisionPaths.from_revision_dir(revision)
with pytest.raises(TextureConfigError) as excinfo:
texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
assert expected in str(excinfo.value)
@pytest.mark.parametrize("mode", ["none", "inked", "owned"])
def test_print_block_accepts_every_base_fill_mode(tmp_path: Path, mode: str) -> None:
revision = _write_revision(tmp_path, TWO_PAIRS + f" print:\n base_fill: {mode}\n")
paths = RevisionPaths.from_revision_dir(revision)
settings = resolve_texture_settings(paths)
block = texture_views.load_views_block(paths)
pairs = texture_views.resolve_view_pairs(paths, settings, block)
job = texture_views.build_multiview_job(paths, settings, pairs, tmp_path / "o", block)
assert job["print"] == {"base_fill": mode}
def test_print_block_defaults_to_owned(tmp_path: Path) -> None:
"""The white ground is kept and split by the seam, like the pattern is."""
revision = _write_revision(tmp_path, TWO_PAIRS)
paths = RevisionPaths.from_revision_dir(revision)
settings = resolve_texture_settings(paths)
pairs = texture_views.resolve_view_pairs(paths, settings)
job = texture_views.build_multiview_job(paths, settings, pairs, tmp_path / "o")
assert job["print"] == {"base_fill": "owned"}
def test_print_block_rejects_unknown_base_fill(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, TWO_PAIRS + " print:\n base_fill: guess\n")
paths = RevisionPaths.from_revision_dir(revision)
with pytest.raises(TextureConfigError) as excinfo:
texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
assert "base_fill" in str(excinfo.value)
def test_print_block_rejects_unknown_key(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, TWO_PAIRS + " print:\n nope: 1\n")
paths = RevisionPaths.from_revision_dir(revision)
with pytest.raises(TextureConfigError) as excinfo:
texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
assert "unknown key" in str(excinfo.value)
# ---------------------------------------------------------------- seam margin
def test_seam_margin_defaults_to_twice_the_alignment_tolerance(tmp_path: Path) -> None:
"""Two passes each placed to within t can close on each other by 2t, so the band is 2t."""
revision = _write_revision(tmp_path, TWO_PAIRS + AUTHORED_MERGE)
paths = RevisionPaths.from_revision_dir(revision)
gap = texture_views.resolve_seam_settings(
texture_views.load_views_block(paths)
)["gap"]
assert gap["enabled"] is True
assert gap["treatment"] == "no_print"
assert gap["alignment_tolerance_mm"] == 0.1
assert gap["total_mm"] == pytest.approx(0.2)
assert gap["inset_mm"] == pytest.approx(0.1)
def test_seam_margin_is_enabled_even_without_a_gap_block(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, TWO_PAIRS)
paths = RevisionPaths.from_revision_dir(revision)
gap = texture_views.resolve_seam_settings(
texture_views.load_views_block(paths)
)["gap"]
assert gap["enabled"] is True
assert gap["total_mm"] == pytest.approx(0.2)
def test_seam_margin_explicit_total_overrides_the_derivation(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, TWO_PAIRS + (
" seam:\n"
" gap:\n"
" alignment_tolerance_mm: 0.05\n"
" total_mm: 0.5\n"
))
paths = RevisionPaths.from_revision_dir(revision)
gap = texture_views.resolve_seam_settings(
texture_views.load_views_block(paths)
)["gap"]
assert gap["total_mm"] == pytest.approx(0.5)
assert gap["inset_mm"] == pytest.approx(0.25)
def test_seam_margin_can_be_disabled(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, TWO_PAIRS + AUTHORED_MERGE + " gap: false\n")
paths = RevisionPaths.from_revision_dir(revision)
gap = texture_views.resolve_seam_settings(
texture_views.load_views_block(paths)
)["gap"]
assert gap["enabled"] is False
assert gap["total_mm"] == 0.0
assert gap["inset_mm"] == 0.0
def test_unimplemented_gap_treatment_is_refused_not_silently_ignored(tmp_path: Path) -> None:
"""`reduced_density` is the planned blending mode; until it exists it must not no-op."""
revision = _write_revision(tmp_path, TWO_PAIRS + AUTHORED_MERGE + (
" gap:\n"
" treatment: reduced_density\n"
))
paths = RevisionPaths.from_revision_dir(revision)
settings = resolve_texture_settings(paths)
block = texture_views.load_views_block(paths)
# Passes schema validation as a known value...
assert texture_views.validate_views_block(block) == []
# ...but is refused when the job is built, before any Blender run.
with pytest.raises(TextureConfigError) as excinfo:
texture_views.resolve_seam_settings(block)
message = str(excinfo.value)
assert "reduced_density" in message and "not implemented" in message
with pytest.raises(TextureConfigError):
pairs = texture_views.resolve_view_pairs(paths, settings, block)
texture_views.build_multiview_job(paths, settings, pairs, tmp_path / "o", block)
@pytest.mark.parametrize(
("gap_yaml", "expected"),
[
(" gap:\n treatment: guess\n", "treatment"),
(" gap:\n alignment_tolerance_mm: 0\n", "must be > 0"),
(" gap:\n alignment_tolerance_mm: -1\n", "must be > 0"),
(" gap:\n total_mm: 0\n", "must be > 0"),
(" gap:\n nope: 1\n", "unknown key"),
(" gap: nope\n", "must be a mapping"),
],
)
def test_seam_margin_rejects_bad_values(tmp_path: Path, gap_yaml: str, expected: str) -> None:
revision = _write_revision(tmp_path, TWO_PAIRS + AUTHORED_MERGE + gap_yaml)
paths = RevisionPaths.from_revision_dir(revision)
with pytest.raises(TextureConfigError) as excinfo:
texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
assert expected in str(excinfo.value)
def test_seam_margin_travels_in_the_job(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, TWO_PAIRS + AUTHORED_MERGE)
paths = RevisionPaths.from_revision_dir(revision)
settings = resolve_texture_settings(paths)
block = texture_views.load_views_block(paths)
pairs = texture_views.resolve_view_pairs(paths, settings, block)
job = texture_views.build_multiview_job(paths, settings, pairs, tmp_path / "o", block)
gap = job["seam"]["gap"]
assert gap["enabled"] is True
assert gap["treatment"] == "no_print"
assert gap["total_mm"] == pytest.approx(0.2)
assert gap["inset_mm"] == pytest.approx(0.1)
def test_real_revisions_declare_the_seam_margin(real_revision: Path) -> None:
"""Both shipped revisions carry a 0.1 mm tolerance, so the band is 0.2 mm."""
paths = RevisionPaths.from_revision_dir(real_revision)
gap = texture_views.resolve_seam_settings(
texture_views.load_views_block(paths)
)["gap"]
assert gap["enabled"] is True
assert gap["alignment_tolerance_mm"] == 0.1
assert gap["total_mm"] == pytest.approx(0.2)
def test_authored_merge_block_resolves_into_the_job(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, TWO_PAIRS + AUTHORED_MERGE)
paths = RevisionPaths.from_revision_dir(revision)
settings = resolve_texture_settings(paths)
block = texture_views.load_views_block(paths)
pairs = texture_views.resolve_view_pairs(paths, settings, block)
job = texture_views.build_multiview_job(paths, settings, pairs, tmp_path / "o", block)
assert job["merge"]["mode"] == "authored"
assert job["merge"]["incidence_limit_deg"] == 62.0
assert job["seam"]["boundary"] == "use_seam"
def test_seam_pins_are_carried_into_the_job(tmp_path: Path) -> None:
pins = (
" pins:\n"
" - component: 1\n view: uv_2\n reason: rim is usable here\n"
)
revision = _write_revision(tmp_path, TWO_PAIRS + AUTHORED_MERGE + pins)
paths = RevisionPaths.from_revision_dir(revision)
settings = resolve_texture_settings(paths)
block = texture_views.load_views_block(paths)
pairs = texture_views.resolve_view_pairs(paths, settings, block)
job = texture_views.build_multiview_job(paths, settings, pairs, tmp_path / "o", block)
assert job["seam"]["pins"] == [
{"component": 1, "view": "uv_2", "reason": "rim is usable here"}
]
def test_absent_merge_block_uses_the_calibrated_defaults(tmp_path: Path) -> None:
"""Zero authoring burden: the defaults alone produce a partition."""
revision = _write_revision(tmp_path, TWO_PAIRS)
paths = RevisionPaths.from_revision_dir(revision)
settings = resolve_texture_settings(paths)
pairs = texture_views.resolve_view_pairs(paths, settings)
job = texture_views.build_multiview_job(paths, settings, pairs, tmp_path / "o")
assert job["merge"] == texture_views.MERGE_DEFAULTS
def test_validation_accepts_the_two_view_block(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, TWO_PAIRS)
paths = RevisionPaths.from_revision_dir(revision)
@@ -193,8 +475,10 @@ def test_validation_accepts_the_two_view_block(tmp_path: Path) -> None:
pairs = texture_views.resolve_view_pairs(paths, settings)
assert [p.id for p in pairs] == ["uv_1", "uv_2"]
assert [p.uv_map for p in pairs] == ["uv_1", "uv_2"]
# The pattern source is the sensing camera, not the layout's authoring camera.
assert [p.camera for p in pairs] == ["cam_sensor", "cam_sensor"]
# `camera` is the REGION camera: the production view that decides what this pair owns.
assert [p.camera for p in pairs] == ["cam_uv_1", "cam_uv_2"]
# The pattern source is separate, and defaults to the sensing camera.
assert [p.pattern_camera for p in pairs] == ["cam_sensor", "cam_sensor"]
def test_pattern_camera_defaults_to_the_compute_source(tmp_path: Path) -> None:
@@ -203,24 +487,36 @@ def test_pattern_camera_defaults_to_the_compute_source(tmp_path: Path) -> None:
paths = RevisionPaths.from_revision_dir(revision)
settings = resolve_texture_settings(paths)
pairs = texture_views.resolve_view_pairs(paths, settings)
assert all(p.camera == settings.camera for p in pairs)
assert all(p.pattern_camera == settings.camera for p in pairs)
def test_pattern_camera_can_be_overridden_at_block_or_pair_level(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, (
"texture_patch_views:\n"
" enabled: true\n"
" pattern_camera: cam_alt\n"
" pairs:\n"
" - id: uv_1\n"
" uv_map: uv_1\n"
" - id: uv_2\n"
" camera: cam_uv_2\n"
" uv_map: uv_2\n"
revision = _write_revision(tmp_path, _block(
" - id: uv_1\n camera: cam_uv_1\n uv_map: uv_1\n"
" - id: uv_2\n camera: cam_uv_2\n uv_map: uv_2\n",
).replace(
" enabled: true\n",
" enabled: true\n pattern_camera: cam_alt_block\n",
).replace(
" camera: cam_uv_2\n",
" camera: cam_uv_2\n pattern_camera: cam_alt_pair\n",
))
paths = RevisionPaths.from_revision_dir(revision)
pairs = texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
assert [p.camera for p in pairs] == ["cam_alt", "cam_uv_2"]
assert [p.pattern_camera for p in pairs] == ["cam_alt_block", "cam_alt_pair"]
# The region cameras are unaffected by the pattern override.
assert [p.camera for p in pairs] == ["cam_uv_1", "cam_uv_2"]
def test_region_camera_is_required(tmp_path: Path) -> None:
"""A pair without a region camera has no authority to own anything."""
revision = _write_revision(tmp_path, _block(
" - id: uv_1\n uv_map: uv_1\n",
))
paths = RevisionPaths.from_revision_dir(revision)
with pytest.raises(TextureConfigError) as excinfo:
texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
assert "camera" in str(excinfo.value)
def test_bad_pattern_camera_is_rejected(tmp_path: Path) -> None:
@@ -235,7 +531,7 @@ def test_bad_pattern_camera_is_rejected(tmp_path: Path) -> None:
def test_disabled_block_is_refused(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, _block(
" - id: uv_1\n uv_map: uv_1\n",
" - id: uv_1\n camera: cam_uv_1\n uv_map: uv_1\n",
enabled=" enabled: false\n",
))
paths = RevisionPaths.from_revision_dir(revision)
@@ -260,8 +556,8 @@ def test_the_colour_seed_is_shared_across_layouts(tmp_path: Path) -> None:
def test_explicit_pair_seed_wins(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, _block(
" - id: uv_1\n uv_map: uv_1\n seed: 99\n"
" - id: uv_2\n uv_map: uv_2\n",
" - id: uv_1\n camera: cam_uv_1\n uv_map: uv_1\n seed: 99\n"
" - id: uv_2\n camera: cam_uv_2\n uv_map: uv_2\n",
))
paths = RevisionPaths.from_revision_dir(revision)
pairs = texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
@@ -309,7 +605,15 @@ def test_job_has_one_entry_per_pair_with_absolute_artifacts(tmp_path: Path) -> N
assert "camera" not in job["objects"] # cameras are per pair, not global
assert job["pattern_camera"] == "cam_sensor"
assert [p["id"] for p in job["pairs"]] == ["uv_1", "uv_2"]
assert [p["camera"] for p in job["pairs"]] == ["cam_sensor", "cam_sensor"]
assert [p["camera"] for p in job["pairs"]] == ["cam_uv_1", "cam_uv_2"]
assert [p["pattern_camera"] for p in job["pairs"]] == ["cam_sensor", "cam_sensor"]
# The merge policy travels with the job so the Blender side never invents a default.
assert job["merge"]["mode"] == "computed"
assert job["merge"]["incidence_limit_deg"] == 62.0
assert job["merge"]["min_triangle_coverage"] == 0.60
assert job["seam"]["boundary"] == "use_seam"
assert job["seam"]["pins"] == []
assert job["seam"]["gap"]["enabled"] is True
for entry in job["pairs"]:
assert set(entry["artifacts"]) == set(TEXTURE_ARTIFACT_FORMATS)
for path in entry["artifacts"].values():
@@ -372,11 +676,13 @@ def test_real_revision_declares_exactly_the_two_production_views(
"""
paths = RevisionPaths.from_revision_dir(real_revision)
settings = resolve_texture_settings(paths)
pairs = texture_views.resolve_view_pairs(paths, settings)
block = texture_views.load_views_block(paths)
pairs = texture_views.resolve_view_pairs(paths, settings, block)
assert [(p.id, p.uv_map) for p in pairs] == [("uv_1", "uv_1"), ("uv_2", "uv_2")]
assert [p.camera for p in pairs] == [settings.camera, settings.camera]
# Region cameras are the production views; the pattern comes from the sensing camera.
assert [p.camera for p in pairs] == ["cam_uv_1", "cam_uv_2"]
assert [p.pattern_camera for p in pairs] == [settings.camera, settings.camera]
assert settings.camera == "cam_sensor"
assert all(p.camera not in {"cam_uv_1", "cam_uv_2"} for p in pairs)
filenames = sorted(p.target.filename for p in pairs)
assert filenames == ["patch_texture_uv_1.png", "patch_texture_uv_2.png"]
# The runtime layout must never be a production view target.
@@ -385,6 +691,45 @@ def test_real_revision_declares_exactly_the_two_production_views(
assert [p.seed for p in pairs] == [settings.seed, settings.seed]
def test_real_revision_is_configured_for_the_authored_seam(real_revision: Path) -> None:
"""Pins the on-disk policy: the marked seam is the authority, at the calibrated limit."""
paths = RevisionPaths.from_revision_dir(real_revision)
block = texture_views.load_views_block(paths)
policy = texture_views.resolve_merge_policy(block)
assert policy["mode"] == "authored"
assert policy["incidence_limit_deg"] == 62.0
assert policy["component_policy"] == "best_coverage"
assert policy["max_hole_fraction"] == 0.02
seam = texture_views.resolve_seam_settings(block)
assert seam["boundary"] == "use_seam"
assert seam["pins"] == []
assert seam["gap"]["treatment"] == "no_print"
assert seam["gap"]["alignment_tolerance_mm"] == 0.1
assert seam["gap"]["total_mm"] == pytest.approx(0.2)
def test_host_defaults_match_the_blender_module(real_revision: Path) -> None:
"""The host duplicates the defaults; this is the test that keeps them honest.
Without it, `texture_views.MERGE_DEFAULTS` and `texture_merge.MergePolicy` could drift and
the job would silently carry thresholds different from the ones the policy was tested at.
"""
sys.path.insert(0, str(repo_root() / "blender" / "scripts"))
try:
import texture_merge # type: ignore
finally:
sys.path.pop(0)
policy = texture_merge.MergePolicy()
assert texture_views.MERGE_DEFAULTS["mode"] == policy.mode
assert texture_views.MERGE_DEFAULTS["incidence_limit_deg"] == policy.incidence_limit_deg
assert texture_views.MERGE_DEFAULTS["min_footprint_mm2"] == policy.min_footprint_mm2
assert (
texture_views.MERGE_DEFAULTS["min_triangle_coverage"] == policy.min_triangle_coverage
)
assert texture_views.MERGE_DEFAULTS["max_hole_fraction"] == policy.max_hole_fraction
assert texture_views.MERGE_DEFAULTS["component_policy"] == policy.component_policy
def test_views_block_does_not_leak_into_the_single_view_path(real_revision: Path) -> None:
"""`orisys-design texture` must still see only texture_patch's own outputs."""
paths = RevisionPaths.from_revision_dir(real_revision)