Files
orisys-sensor-design/tests/test_texture_views.py
T
2026-09-28 10:47:00 +08:00

517 lines
20 KiB
Python

"""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
contract are checkable in CI, and the Blender side consumes a job it did not have to
interpret.
The last group is an isolation guard: the revision on disk now carries a
``texture_patch_views`` block, so these tests pin that the block does not leak into the
existing single-view ``texture_patch`` path or its job.
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
import pytest
from orisys_sensor_design import texture_views
from orisys_sensor_design.manifest import RevisionPaths, repo_root
from orisys_sensor_design.texture import (
TEXTURE_ARTIFACT_FORMATS,
TextureConfigError,
build_texture_job,
resolve_texture_output_dir,
resolve_texture_settings,
)
SENSOR = "finger"
REVISION = "r01"
def _write_revision(
tmp_path: Path,
views_yaml: str = "",
*,
texture_patch_yaml: str | None = None,
) -> Path:
"""Minimal revision with a texture_patch block and an optional views block."""
revision = tmp_path / "sensors" / SENSOR / REVISION
(revision / "profiles").mkdir(parents=True)
(revision / "source").mkdir(parents=True)
(revision / "source" / "assembly.blend").write_bytes(b"")
design = (
"sensor: finger\n"
f"revision: {REVISION}\n"
"meters_per_source_unit: 0.001\n"
"source_blend: source/assembly.blend\n"
)
if texture_patch_yaml is None:
texture_patch_yaml = (
"texture_patch:\n"
" mesh: sensing_surface\n"
" camera: cam_sensor\n"
" uv_outputs:\n"
" produce: patch_texture_produce.png\n"
" compute: patch_texture_compute.png\n"
" width: 512\n"
" height: 256\n"
" nx: 80\n"
" ny: 45\n"
" seed: 7\n"
" vector:\n"
" width_mm: 16.4\n"
" height_mm: 16.0\n"
)
design += texture_patch_yaml + views_yaml
(revision / "design.yaml").write_text(design, encoding="utf-8")
for name in ("compute.yaml", "studio.yaml"):
(revision / "profiles" / name).write_text(
f"profile: {name.split('.')[0]}\n", encoding="utf-8"
)
return revision
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`.
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"
" - id: uv_2\n"
" camera: cam_uv_2\n"
" uv_map: uv_2\n"
)
@pytest.fixture()
def real_revision() -> Path:
return repo_root() / "sensors" / SENSOR / REVISION
# ---------------------------------------------------------------- block loading
def test_revision_without_a_views_block_returns_none(tmp_path: Path) -> None:
paths = RevisionPaths.from_revision_dir(_write_revision(tmp_path))
assert texture_views.load_views_block(paths) is None
def test_missing_views_block_says_the_single_view_path_is_unaffected(tmp_path: Path) -> None:
paths = RevisionPaths.from_revision_dir(_write_revision(tmp_path))
settings = resolve_texture_settings(paths)
with pytest.raises(TextureConfigError) as excinfo:
texture_views.resolve_view_pairs(paths, settings)
message = str(excinfo.value)
assert "texture_patch_views" in message
assert "orisys-design texture" in message
def test_views_block_must_be_a_mapping(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, "texture_patch_views: nope\n")
paths = RevisionPaths.from_revision_dir(revision)
with pytest.raises(TextureConfigError):
texture_views.load_views_block(paths)
# ---------------------------------------------------------------- validation
@pytest.mark.parametrize(
("views_yaml", "expected"),
[
("texture_patch_views: {}\n", "pairs"),
(_block("", enabled=" enabled: maybe\n"), "enabled"),
("texture_patch_views:\n pairs: {}\n", "pairs"),
("texture_patch_views:\n pairs: []\n", "pairs"),
(_block(" - nope\n"), "mapping"),
(_block(" - id: a\n camera: c\n"), "uv_map"),
(
_block(" - id: a\n camera: c\n uv_map: u\n extra: 1\n"),
"unknown key",
),
(
_block(" - id: a\n camera: c\n uv_map: u\n"
" - id: a\n camera: c2\n uv_map: u2\n"),
"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"),
"would be identical",
),
(
_block(" - id: a\n camera: c\n uv_map: u\n"
" - id: b\n camera: c\n uv_map: u\n"),
"would be identical",
),
(
_block(" - id: a\n camera: c\n uv_map: u\n"
" - id: b\n camera: c2\n uv_map: u2\n"
" filename: patch_texture_a.png\n"),
"collides",
),
(
_block(" - id: a\n camera: c\n uv_map: u\n seed: 1.5\n"),
"seed",
),
(
_block(" - id: a\n camera: c\n uv_map: u\n filename: x.svg\n"),
".png",
),
("texture_patch_views:\n bogus: 1\n pairs: []\n", "unknown key"),
],
)
def test_validation_rejects_bad_blocks(tmp_path: Path, views_yaml: str, expected: str) -> None:
revision = _write_revision(tmp_path, views_yaml)
paths = RevisionPaths.from_revision_dir(revision)
settings = resolve_texture_settings(paths)
with pytest.raises(TextureConfigError) as excinfo:
texture_views.resolve_view_pairs(paths, settings)
assert expected in str(excinfo.value)
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)
settings = resolve_texture_settings(paths)
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"]
def test_pattern_camera_defaults_to_the_compute_source(tmp_path: Path) -> None:
"""uv_1/uv_2 must carry the same surface pattern as `compute`."""
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)
assert all(p.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"
))
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"]
def test_bad_pattern_camera_is_rejected(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, _block(
" - id: uv_1\n uv_map: uv_1\n",
).replace(" enabled: true\n", " enabled: true\n pattern_camera: ''\n"))
paths = RevisionPaths.from_revision_dir(revision)
with pytest.raises(TextureConfigError) as excinfo:
texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
assert "pattern_camera" in str(excinfo.value)
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",
enabled=" enabled: false\n",
))
paths = RevisionPaths.from_revision_dir(revision)
settings = resolve_texture_settings(paths)
with pytest.raises(TextureConfigError) as excinfo:
texture_views.resolve_view_pairs(paths, settings)
assert "enabled is false" in str(excinfo.value)
# ---------------------------------------------------------------- seeds and filenames
def test_the_colour_seed_is_shared_across_layouts(tmp_path: Path) -> None:
"""One pattern, one palette: a per-layout seed would paint two patterns on one surface."""
revision = _write_revision(tmp_path, TWO_PAIRS)
paths = RevisionPaths.from_revision_dir(revision)
settings = resolve_texture_settings(paths)
assert settings.seed == 7
pairs = texture_views.resolve_view_pairs(paths, settings)
assert [p.seed for p in pairs] == [7, 7]
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",
))
paths = RevisionPaths.from_revision_dir(revision)
pairs = texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
assert [p.seed for p in pairs] == [99, 7]
def test_no_base_seed_leaves_pair_seeds_none(tmp_path: Path) -> None:
texture_patch = (
"texture_patch:\n"
" mesh: sensing_surface\n"
" camera: cam_sensor\n"
" uv_outputs:\n"
" produce: patch_texture_produce.png\n"
)
revision = _write_revision(tmp_path, TWO_PAIRS, texture_patch_yaml=texture_patch)
paths = RevisionPaths.from_revision_dir(revision)
pairs = texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
assert [p.seed for p in pairs] == [None, None]
def test_default_filenames_come_from_the_pair_id(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, TWO_PAIRS)
paths = RevisionPaths.from_revision_dir(revision)
pairs = texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
assert [p.target.filename for p in pairs] == [
"patch_texture_uv_1.png",
"patch_texture_uv_2.png",
]
# ---------------------------------------------------------------- job contract
def test_job_has_one_entry_per_pair_with_absolute_artifacts(tmp_path: Path) -> None:
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)
out = tmp_path / "out"
job = texture_views.build_multiview_job(paths, settings, pairs, out)
assert job["schema"] == texture_views.MULTIVIEW_JOB_SCHEMA
assert job["mode"] == "patches-multiview"
assert job["objects"]["mesh"] == "sensing_surface"
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"]
for entry in job["pairs"]:
assert set(entry["artifacts"]) == set(TEXTURE_ARTIFACT_FORMATS)
for path in entry["artifacts"].values():
assert Path(path).is_absolute()
# Settings come from texture_patch, so the printed page is decided in one place.
assert job["settings"]["nx"] == 80
assert job["settings"]["vector"]["width_mm"] == pytest.approx(16.4)
def test_job_is_deterministic(tmp_path: Path) -> None:
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)
first = texture_views.build_multiview_job(paths, settings, pairs, tmp_path / "o")
second = texture_views.build_multiview_job(paths, settings, pairs, tmp_path / "o")
assert json.dumps(first, sort_keys=True) == json.dumps(second, sort_keys=True)
def test_write_job_round_trips(tmp_path: Path) -> None:
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)
out = tmp_path / "out"
job = texture_views.build_multiview_job(paths, settings, pairs, out)
written = texture_views.write_multiview_job(job, out)
assert written.name == texture_views.MULTIVIEW_JOB_FILENAME
assert json.loads(written.read_text(encoding="utf-8")) == job
def test_empty_pair_list_is_refused(tmp_path: Path) -> None:
paths = RevisionPaths.from_revision_dir(_write_revision(tmp_path, TWO_PAIRS))
settings = resolve_texture_settings(paths)
with pytest.raises(TextureConfigError):
texture_views.build_multiview_job(paths, settings, (), tmp_path)
def test_output_paths_are_absolute_and_ordered(tmp_path: Path) -> None:
revision = _write_revision(tmp_path, TWO_PAIRS)
paths = RevisionPaths.from_revision_dir(revision)
pairs = texture_views.resolve_view_pairs(paths, resolve_texture_settings(paths))
targets = texture_views.multiview_output_paths(tmp_path / "o", pairs)
assert [pair.id for pair, _ in targets] == ["uv_1", "uv_2"]
for _pair, artifacts in targets:
assert list(artifacts) == list(TEXTURE_ARTIFACT_FORMATS)
assert all(path.is_absolute() for path in artifacts.values())
# ---------------------------------------------------------------- isolation guard
def test_real_revision_declares_exactly_the_two_production_views(
real_revision: Path,
) -> None:
"""Guards the on-disk wiring: uv_1/uv_2, patterned from the sensing camera.
`cam_uv_1`/`cam_uv_2` authored the layouts in Blender; they must NOT appear as pattern
sources, or the printed marker would carry two different patterns.
"""
paths = RevisionPaths.from_revision_dir(real_revision)
settings = resolve_texture_settings(paths)
pairs = texture_views.resolve_view_pairs(paths, settings)
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]
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.
assert all(p.uv_map not in {"compute", "produce"} for p in pairs)
# ...and the palette must match compute's, so the marker is one pattern.
assert [p.seed for p in pairs] == [settings.seed, settings.seed]
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)
settings = resolve_texture_settings(paths)
assert [t.uv_map for t in settings.outputs] == ["produce", "compute"]
assert settings.camera == "cam_sensor"
job = build_texture_job(paths, settings, real_revision / "build")
assert "pairs" not in job
assert [entry["uv_map"] for entry in job["outputs"]] == ["produce", "compute"]
assert job["objects"]["camera"] == "cam_sensor"
def test_views_and_single_view_share_the_page_settings(real_revision: Path) -> None:
"""A page decision lives in texture_patch, so both paths must agree on it."""
paths = RevisionPaths.from_revision_dir(real_revision)
settings = resolve_texture_settings(paths)
views_settings = texture_views.resolve_views_settings(paths)
assert views_settings.nx == settings.nx
assert views_settings.ny == settings.ny
assert (views_settings.vector_width_mm, views_settings.vector_height_mm) == (
settings.vector_width_mm,
settings.vector_height_mm,
)
assert views_settings.outputs == settings.outputs
def test_views_overrides_do_not_touch_design_yaml(tmp_path: Path) -> None:
"""CLI overrides go into the job only, exactly like the single-view path."""
revision = _write_revision(tmp_path, TWO_PAIRS)
paths = RevisionPaths.from_revision_dir(revision)
before = paths.design_yaml.read_text(encoding="utf-8")
namespace = argparse.Namespace(nx=31, ny=31, width=256, height=256)
settings = texture_views.resolve_views_settings(
paths, {"nx": namespace.nx, "ny": namespace.ny, "width": namespace.width,
"height": namespace.height}
)
assert (settings.nx, settings.ny) == (31, 31)
assert paths.design_yaml.read_text(encoding="utf-8") == before
def test_output_dir_override_wins_for_views(real_revision: Path, tmp_path: Path) -> None:
paths = RevisionPaths.from_revision_dir(real_revision)
assert resolve_texture_output_dir(repo_root(), paths, tmp_path / "elsewhere") == (
tmp_path / "elsewhere"
).resolve()
# ---------------------------------------------------------------- inspect --svg
def _inspect_namespace(revision: Path, output_dir: Path, **overrides: object):
namespace = argparse.Namespace(
revision_dir=revision,
output_dir=output_dir,
uv_map=None,
out=None,
csv=None,
csv_all=None,
top=5,
printer_dpi=None,
min_dots=None,
min_feature_mm=None,
min_edge_mm=None,
max_sliver_ratio=None,
min_patch_area_mm2=None,
svg=None,
)
for key, value in overrides.items():
setattr(namespace, key, value)
return namespace
def _minimal_svg(path: Path) -> Path:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
'<?xml version="1.0" encoding="UTF-8"?>\n'
'<svg xmlns="http://www.w3.org/2000/svg" width="16.4mm" height="16.0mm" '
'viewBox="0 0 3280 3200" preserveAspectRatio="none">\n'
' <g id="artwork" transform="translate(0 3200) scale(1 -1)">\n'
' <g id="mesh">\n'
' <polygon id="mesh-1" points="0,0 3280,0 3280,3200 0,3200" fill="#ffffff"/>\n'
' </g>\n'
' <g id="patches">\n'
' <polygon id="patch-1" points="10,10 210,10 210,210 10,210" fill="#000000"/>\n'
' </g>\n'
' </g>\n'
"</svg>\n",
encoding="utf-8",
)
return path
def test_inspect_svg_option_grades_an_unconfigured_layout(tmp_path: Path) -> None:
"""The per-view textures are not in texture_patch.uv_outputs; --svg grades them anyway.
``texture-views`` deliberately does not extend ``uv_outputs``, because extending it
would make the single-view ``orisys-design texture`` generate those layouts too.
"""
from orisys_sensor_design import cli
revision = _write_revision(tmp_path, TWO_PAIRS)
output_dir = tmp_path / "textures"
svg = _minimal_svg(output_dir / "patch_texture_uv_1.svg")
args = _inspect_namespace(revision, output_dir, svg=[svg])
assert cli.cmd_inspect(revision, args) == 0
report = json.loads((output_dir / "texture_report.json").read_text(encoding="utf-8"))
assert [entry["uv_map"] for entry in report["maps"]] == ["patch_texture_uv_1"]
def test_inspect_svg_option_reports_a_missing_file_as_exit_three(tmp_path: Path) -> None:
from orisys_sensor_design import cli
revision = _write_revision(tmp_path, TWO_PAIRS)
args = _inspect_namespace(
revision, tmp_path / "textures", svg=[tmp_path / "textures" / "nope.svg"]
)
assert cli.cmd_inspect(revision, args) == 3
def test_inspect_without_svg_still_uses_the_configured_maps(tmp_path: Path) -> None:
"""Additive: absent --svg, the configured-map path is unchanged."""
revision = _write_revision(tmp_path, TWO_PAIRS)
output_dir = tmp_path / "textures"
# No configured-map SVGs written at all -> the configured path reports them missing.
from orisys_sensor_design import cli
assert cli.cmd_inspect(revision, _inspect_namespace(revision, output_dir)) == 3