"""Tests for multi-view production texture pairs (`orisys-design texture-views`). 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. 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 import sys 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: 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" " camera: cam_uv_1\n" " uv_map: uv_1\n" " - id: uv_2\n" " camera: cam_uv_2\n" " 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: 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 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: u2\n"), "would claim the same surface region", ), ( _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) # ---------------------------------------------------------------- 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) 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"] # `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: """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.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, _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.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: 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 camera: cam_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 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)) 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_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(): 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) 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")] # 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" 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_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) 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( '\n' '\n' ' \n' ' \n' ' \n' ' \n' ' \n' ' \n' ' \n' ' \n' "\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