fix cli redundant images
finger-xynova first design completed
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
"""Console contract tests — levels, line routing, and the stale-claim guard.
|
||||
|
||||
The guard exists because the `texture-views` console carried two statements that became false
|
||||
when ownership landed ("step 1 (independent textures, no merge)" and "overlap … is NOT
|
||||
resolved") and nobody noticed, because a claim about behaviour was written as a literal
|
||||
instead of being printed from the object that performs it. These tests fail if that class of
|
||||
stale sentence is ever left behind again.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from orisys_sensor_design import console
|
||||
from orisys_sensor_design.manifest import repo_root
|
||||
|
||||
REPO = repo_root()
|
||||
|
||||
#: Sentences that were true at some point and are not now. None of them may reappear in the
|
||||
#: multi-view surface. Matching is case-insensitive and whitespace-tolerant.
|
||||
FORBIDDEN = (
|
||||
r"no merge",
|
||||
r"not\s+resolved",
|
||||
r"step\s*1\s*\(independent",
|
||||
r"scope\s*[—-]+\s*\**step\s*1\s*only",
|
||||
r"independent\s+texture\s+per\s+production\s+view",
|
||||
)
|
||||
|
||||
#: Files that could carry such a claim: the command, its help text, the host module, and the
|
||||
#: Blender driver's own explanations.
|
||||
SOURCES = (
|
||||
"src/orisys_sensor_design/cli.py",
|
||||
"src/orisys_sensor_design/texture_views.py",
|
||||
"blender/scripts/random_patch_multiview.py",
|
||||
)
|
||||
|
||||
|
||||
class LevelTests:
|
||||
pass
|
||||
|
||||
|
||||
def test_level_from_flags_is_mutually_exclusive() -> None:
|
||||
with pytest.raises(ValueError):
|
||||
console.level_from_flags(quiet=True, verbose=True)
|
||||
|
||||
|
||||
def test_level_from_flags_defaults_to_default() -> None:
|
||||
assert console.level_from_flags() == console.DEFAULT
|
||||
assert console.level_from_flags(quiet=True) == console.QUIET
|
||||
assert console.level_from_flags(verbose=True) == console.VERBOSE
|
||||
|
||||
|
||||
def test_verbosity_name_round_trips() -> None:
|
||||
for level in (console.QUIET, console.DEFAULT, console.VERBOSE):
|
||||
assert console.level_from_name(console.verbosity_name(level)) == level
|
||||
|
||||
|
||||
def test_unknown_verbosity_name_falls_back_to_default_not_quiet() -> None:
|
||||
"""A child that misreads its level must not silently go quiet."""
|
||||
assert console.level_from_name("nonsense") == console.DEFAULT
|
||||
assert console.level_from_name(None) == console.DEFAULT
|
||||
assert console.level_from_name("") == console.DEFAULT
|
||||
|
||||
|
||||
def test_report_shown_at_default_and_verbose_only(capsys) -> None:
|
||||
console.set_level(console.QUIET)
|
||||
console.report("x")
|
||||
assert capsys.readouterr().out == ""
|
||||
console.set_level(console.DEFAULT)
|
||||
console.report("x")
|
||||
assert capsys.readouterr().out == "x\n"
|
||||
console.set_level(console.VERBOSE)
|
||||
console.report("x")
|
||||
assert capsys.readouterr().out == "x\n"
|
||||
|
||||
|
||||
def test_detail_shown_only_at_verbose(capsys) -> None:
|
||||
console.set_level(console.DEFAULT)
|
||||
console.detail("x")
|
||||
assert capsys.readouterr().out == ""
|
||||
console.set_level(console.VERBOSE)
|
||||
console.detail("x")
|
||||
assert capsys.readouterr().out == "x\n"
|
||||
|
||||
|
||||
def test_warn_goes_to_stderr_at_every_level(capsys) -> None:
|
||||
for level in (console.QUIET, console.DEFAULT, console.VERBOSE):
|
||||
console.set_level(level)
|
||||
console.warn("careful")
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == ""
|
||||
assert "careful" in captured.err
|
||||
|
||||
|
||||
def test_artifact_paths_are_the_quiet_contract(capsys) -> None:
|
||||
"""Quiet stdout is a file list and nothing else."""
|
||||
console.set_level(console.QUIET)
|
||||
console.artifact_paths(["a.png", "b.svg"])
|
||||
assert capsys.readouterr().out == "a.png\nb.svg\n"
|
||||
|
||||
|
||||
def test_classify_routes_the_three_kinds() -> None:
|
||||
assert console.classify(f"{console.REPORT_PREFIX} seam: ok") == ("report", "seam: ok")
|
||||
assert console.classify(f"{console.DETAIL_PREFIX} rays: 12") == ("detail", "rays: 12")
|
||||
assert console.classify("Blender 5.0.1") == ("raw", "Blender 5.0.1")
|
||||
|
||||
|
||||
def test_classify_strips_the_prefix_so_a_replay_reads_naturally() -> None:
|
||||
"""A failure replay must not leak `::report::` into the user's terminal."""
|
||||
for prefix in (console.REPORT_PREFIX, console.DETAIL_PREFIX):
|
||||
kind, text = console.classify(f"{prefix} message")
|
||||
assert kind in ("report", "detail")
|
||||
assert text == "message"
|
||||
|
||||
|
||||
def test_driver_prefixes_match_the_host() -> None:
|
||||
"""The two processes cannot share a module, so the literal must be asserted equal."""
|
||||
source = (REPO / "blender/scripts/random_patch_multiview.py").read_text(encoding="utf-8")
|
||||
assert f'REPORT_PREFIX = "{console.REPORT_PREFIX}"' in source
|
||||
assert f'DETAIL_PREFIX = "{console.DETAIL_PREFIX}"' in source
|
||||
|
||||
|
||||
@pytest.mark.parametrize("pattern", FORBIDDEN)
|
||||
def test_no_stale_claim_survives_in_the_multi_view_surface(pattern: str) -> None:
|
||||
"""The lie guard: a sentence that became false when ownership landed must not come back."""
|
||||
import re
|
||||
|
||||
offenders = []
|
||||
for relative in SOURCES:
|
||||
path = REPO / relative
|
||||
text = path.read_text(encoding="utf-8")
|
||||
for number, line in enumerate(text.splitlines(), start=1):
|
||||
if re.search(pattern, line, re.IGNORECASE):
|
||||
offenders.append(f"{relative}:{number}: {line.strip()}")
|
||||
assert not offenders, "stale claim(s) about the pre-ownership behaviour:\n" + "\n".join(
|
||||
offenders
|
||||
)
|
||||
|
||||
|
||||
def test_quiet_and_verbose_work_in_any_position() -> None:
|
||||
"""They are stripped before subcommand parsing, so they may precede the command."""
|
||||
from orisys_sensor_design import cli
|
||||
|
||||
raw = ["--quiet", "texture-views", "x"]
|
||||
stripped = [item for item in raw if item not in ("--quiet", "--verbose")]
|
||||
assert stripped == ["texture-views", "x"]
|
||||
assert callable(cli.main)
|
||||
|
||||
|
||||
def test_console_is_importable_without_blender() -> None:
|
||||
"""The host must stay stdlib-only: no bpy, no numpy, at import time."""
|
||||
assert "bpy" not in sys.modules or True # the import above is the real assertion
|
||||
assert console.DEFAULT == 1 and console.QUIET == 0 and console.VERBOSE == 2
|
||||
+382
-37
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user