diff --git a/README.md b/README.md index 6f8e1d0..34bb8aa 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,7 @@ solving remains future work. | Wang diagnostic renderer | Implemented as one presentation-only CLI with byte-stable square default and explicit `--hex` mode in the isolated `renderer/` project | | Square-to-hex presentation port | Implemented as a pure in-memory Basire/Culik mapping with a raster-independent checker; no hex solver, schema, or core model | | Static explainability snapshots | Implemented for parsed formula, canonical tile sheet, and unassigned region, with hash-bound JSON contracts and square/hex diagnostic views | +| Generalized tile presentation | Implemented as an exact presentation-only 14-to-23 mapping with a pure recognizer, semantic sheet, labelled atomic legend, and square-witness overlay | | Reduction construction provenance | Implemented as a separate opt-in native-owned result with exact signal orders, swap-bound gadget spans, manifest v2, and a square overlay view; the compact standard ABI does not allocate it | | Native solver event trace | Implemented as separate opt-in reference/optimized entry points with bounded observed events, full initial state, checkpoints, manifest v3, independent offline replay, and deterministic PNG/GIF views | | Reproducible Z3 summaries | Implemented for fixed seed/thread settings and explicit Boolean/Wang encoding order, result/model, and stable project-owned counts; no internal Z3 trace claim | @@ -185,7 +186,7 @@ make check The imported renderer remains a separate locked Python project. Its Pillow and NumPy dependencies are not installed by the root project or exercised by -`make check`. Run its 262-test combined suite independently: +`make check`. Run its 283-test combined suite independently: ```sh cd renderer diff --git a/docs/plans/2026-08-31-narrative-migration-checklist.md b/docs/plans/2026-08-31-narrative-migration-checklist.md index f007949..8c682e8 100644 --- a/docs/plans/2026-08-31-narrative-migration-checklist.md +++ b/docs/plans/2026-08-31-narrative-migration-checklist.md @@ -171,7 +171,7 @@ checks prove the old files are unreferenced before deletion. ## 8. Shared-asset migration order -1. [ ] Freeze and test the exact generalized 14-to-23 mapping without changing +1. [x] Freeze and test the exact generalized 14-to-23 mapping without changing the atomic tileset or standard square/hex output. 2. [ ] Capture the explicit v2 multi-engine fields once per engine and bind all component identities by SHA-256. diff --git a/renderer/README.md b/renderer/README.md index 88f1e98..10a237e 100644 --- a/renderer/README.md +++ b/renderer/README.md @@ -134,6 +134,69 @@ Reduction spans describe the square construction itself, so this view rejects the signal permutation, and gadget bounds without importing native code or reconstructing builder geometry. +### Generalized Yang--Zhang presentation + +Three explicit square-only views interpret the fixed 23 positional atomic IDs +as the 14 generalized tiles from the Yang--Zhang construction. The semantic +module `wang_generalized.py` contains the exact table and performs no raster, +solver, native, or root-project import. It first guards every canonical +`(N,E,S,W)` tuple, then recognizes only complete, correctly oriented, +adjacent, and non-overlapping compositions. + +Render the 14-tile sheet and the separately labelled 23-tile atomic legend +from an existing hash-bound tileset snapshot: + +```bash +uv run --locked python wang_square.py \ + ../tests/fixtures/pipeline_sat_explain/manifest.json \ + output/generalized-sheet.png \ + --view generalized-sheet + +uv run --locked python wang_square.py \ + ../tests/fixtures/pipeline_sat_explain/manifest.json \ + output/atomic-legend.png \ + --view atomic-legend +``` + +Render an exact generalized overlay on the canonical verified square witness: + +```bash +uv run --locked python wang_square.py \ + ../tests/fixtures/pipeline_sat_solver_trace/solution-2273f58cda026dca73c0dfa25c960e01296ac1e34ae6accbddf5be29034d156a.json \ + output/generalized-overlay.png \ + --view generalized-overlay +``` + +The fixed decomposition is: + +| Generalized tile | Atomic IDs and orientation | +|---|---| +| `V0` | `0` above `1` above `2` | +| `V1` | one copy of `3` | +| `C0` | `4` | +| `C1` | `5` above `6` | +| `F0`, `F1` | `7`, `8` | +| `L0`, `L1` | `9`, `10` | +| `R0`, `R1` | `11|12`, `13|14` from left to right | +| `X00`, `X01`, `X10`, `X11` | `15/16`, `17/18`, `19/20`, `21/22` from top to bottom | + +The symbolic palette keeps the paper colors `b`, `v`, `0`, `1`, `0′` +(rendered as the font-safe label `0-prime`), `l`, and `r` separate from +internal glues `V0:a`, `V0:b`, `C1`, `R0`, `R1`, +`X00`, `X01`, `X10`, and `X11`; numeric color IDs remain visible as secondary +transport labels. Multi-cell shapes receive one outer contour and a muted +internal seam. Every atomic tile keeps its numeric ID. In particular, three +adjacent cells carrying atomic tile `3` are three `V1` occurrences, never one +three-cell `V1`. + +All three generalized views reject `--hex`: the checked Basire/Culik port is a +one-to-one presentation of atomic tiles and does not invent generalized hex +semantics. Generalized recognition checks only the exact atomic composition; +Wang adjacency, boundary, and SAT verification remain explicit upstream +preconditions, just as for the other renderer views. Without one of the new +`--view` values, default square, `--hex`, and `--explain` output remain +byte-for-byte unchanged. + ### Offline trace and algorithm animations The animation modules keep state interpretation separate from image encoding. @@ -339,6 +402,8 @@ Raises `RenderingException` on pipeline errors. ├── wang_trace.py # Strict observed-trace loader and offline replay ├── wang_trace_render.py # Trace frame selection and composition CLI ├── wang_z3_summary.py # Encoding-order summary consumer and compositor +├── wang_generalized.py # Pure exact 14-to-23 mapping and recognizer +├── wang_generalized_render.py # Sheet, atomic legend, and square overlay ├── wang_animation.py # Deterministic PNG/contact-sheet/GIF encoder ├── wang_algorithm_animation.py # Canonical and didactic algorithm views ├── test_wang_square.py # Square loader, raster, isolation, CLI, and failures @@ -346,6 +411,7 @@ Raises `RenderingException` on pipeline errors. ├── test_wang_snapshot.py # Snapshot contracts, views, isolation, and goldens ├── test_wang_trace.py # Hash-bound replay, isolation, and byte stability ├── test_wang_z3_summary.py # Fixed encoding summaries and byte stability +├── test_wang_generalized.py # Exact mapping, rejection, isolation, and goldens ├── test_wang_algorithm_animation.py # Source checks and animation goldens ├── input/ # Example input files (palette, scene, tiles, sprites) ├── output/ # Rendered PNG output goes here @@ -353,6 +419,9 @@ Raises `RenderingException` on pipeline errors. │ ├── wang_solution_v1_square_sat.png # Square golden for the Wang fixture │ ├── wang_solution_v1_hex_sat.png # Pointy-top hex golden for the same fixture │ ├── pipeline_sat_reduction.png # Native reduction-provenance golden +│ ├── pipeline_sat_generalized_sheet.png # Fixed 14-tile semantic sheet +│ ├── pipeline_sat_atomic_semantic_legend.png # Labelled 23-tile legend +│ ├── pipeline_sat_generalized_overlay.png # Canonical witness grouping │ ├── palette_ok.json # Valid 16-color palette │ ├── palette_wrong_count.json # Only 3 colors (invalid) │ ├── palette_wrong_value.json # Component > 255 (invalid) @@ -368,9 +437,10 @@ Raises `RenderingException` on pipeline errors. uv run --locked pytest -q ``` -The complete isolated suite has 262 tests: the 144 preserved legacy tests +The complete isolated suite has 283 tests: the 144 preserved legacy tests below, 45 Wang square tests, 24 square-to-hex/hex-raster tests, 29 static -snapshot/explainability tests, and 20 trace/Z3/algorithm-animation tests. +snapshot/explainability tests, 21 generalized-presentation tests, and 20 +trace/Z3/algorithm-animation tests. To run only the original upstream suite: ```bash diff --git a/renderer/test_data/pipeline_sat_atomic_semantic_legend.png b/renderer/test_data/pipeline_sat_atomic_semantic_legend.png new file mode 100644 index 0000000..c23650c Binary files /dev/null and b/renderer/test_data/pipeline_sat_atomic_semantic_legend.png differ diff --git a/renderer/test_data/pipeline_sat_generalized_overlay.png b/renderer/test_data/pipeline_sat_generalized_overlay.png new file mode 100644 index 0000000..a4ba596 Binary files /dev/null and b/renderer/test_data/pipeline_sat_generalized_overlay.png differ diff --git a/renderer/test_data/pipeline_sat_generalized_sheet.png b/renderer/test_data/pipeline_sat_generalized_sheet.png new file mode 100644 index 0000000..11eb7a7 Binary files /dev/null and b/renderer/test_data/pipeline_sat_generalized_sheet.png differ diff --git a/renderer/test_wang_generalized.py b/renderer/test_wang_generalized.py new file mode 100644 index 0000000..c43ca6e --- /dev/null +++ b/renderer/test_wang_generalized.py @@ -0,0 +1,369 @@ +"""Tests for exact 14-to-23 generalized Wang presentation.""" + +from __future__ import annotations + +from collections import Counter +import json +from pathlib import Path +import subprocess +import sys + +from PIL import Image +import pytest + +import wang_square +from wang_generalized import ( + CANONICAL_ATOMIC_TILE_EDGES, + COLOR_NAMES, + GENERALIZED_TILES, + GeneralizedInstance, + GeneralizedTileError, + check_canonical_atomic_tileset, + check_generalized_instances, + recognize_generalized_tiles, +) +from wang_generalized_render import render_generalized_view +from wang_hex_port import WangSquareRenderError +from wang_snapshot import load_explainability_bundle +from wang_square import load_wang_presentation, render_wang_square + + +RENDERER_DIR = Path(__file__).resolve().parent +ROOT = RENDERER_DIR.parent +MANIFEST = ROOT / "tests/fixtures/pipeline_sat_explain/manifest.json" +SOLUTION = ( + ROOT + / "tests/fixtures/pipeline_sat_solver_trace" + / "solution-2273f58cda026dca73c0dfa25c960e01296ac1e34ae6accbddf5be29034d156a.json" +) +GOLDENS = { + "generalized-sheet": RENDERER_DIR + / "test_data/pipeline_sat_generalized_sheet.png", + "atomic-legend": RENDERER_DIR + / "test_data/pipeline_sat_atomic_semantic_legend.png", + "generalized-overlay": RENDERER_DIR + / "test_data/pipeline_sat_generalized_overlay.png", +} + + +def _recognize(cells, *, width=1, height=None): + if height is None: + height = len(cells) // width + return recognize_generalized_tiles( + CANONICAL_ATOMIC_TILE_EDGES, + tuple(cells), + min_x=0, + min_y=0, + max_x=width - 1, + max_y=height - 1, + ) + + +def test_fixed_generalized_table_partitions_all_23_atomic_ids_exactly(): + assert len(GENERALIZED_TILES) == 14 + assert len(CANONICAL_ATOMIC_TILE_EDGES) == 23 + assert COLOR_NAMES == ( + "b", + "v", + "0", + "1", + "0-prime", + "l", + "r", + "V0:a", + "V0:b", + "C1", + "R0", + "R1", + "X00", + "X01", + "X10", + "X11", + ) + parts = [part.tile_id for tile in GENERALIZED_TILES for part in tile.parts] + assert sorted(parts) == list(range(23)) + assert len(parts) == len(set(parts)) + + by_name = {tile.name: tile for tile in GENERALIZED_TILES} + assert tuple(part.tile_id for part in by_name["R0"].parts) == (11, 12) + assert tuple((part.dx, part.dy) for part in by_name["R0"].parts) == ( + (0, 0), + (1, 0), + ) + assert tuple(part.tile_id for part in by_name["R1"].parts) == (13, 14) + + expected = ( + ("V0", ((0, 0, 0, "top"), (1, 0, 1, "middle"), (2, 0, 2, "bottom"))), + ("V1", ((3, 0, 0, "copy"),)), + ("C0", ((4, 0, 0, "single"),)), + ("C1", ((5, 0, 0, "top"), (6, 0, 1, "bottom"))), + ("F0", ((7, 0, 0, "single"),)), + ("F1", ((8, 0, 0, "single"),)), + ("L0", ((9, 0, 0, "single"),)), + ("L1", ((10, 0, 0, "single"),)), + ("R0", ((11, 0, 0, "left"), (12, 1, 0, "right"))), + ("R1", ((13, 0, 0, "left"), (14, 1, 0, "right"))), + ("X00", ((15, 0, 0, "top"), (16, 0, 1, "bottom"))), + ("X01", ((17, 0, 0, "top"), (18, 0, 1, "bottom"))), + ("X10", ((19, 0, 0, "top"), (20, 0, 1, "bottom"))), + ("X11", ((21, 0, 0, "top"), (22, 0, 1, "bottom"))), + ) + actual = tuple( + ( + tile.name, + tuple( + (part.tile_id, part.dx, part.dy, part.label) + for part in tile.parts + ), + ) + for tile in GENERALIZED_TILES + ) + assert actual == expected + + for tile in GENERALIZED_TILES: + cells = [None] * (tile.width * tile.height) + for part in tile.parts: + cells[part.dy * tile.width + part.dx] = part.tile_id + assert _recognize(cells, width=tile.width, height=tile.height) == ( + GeneralizedInstance(tile.name, 0, 0), + ) + + +def test_recognizer_checks_composition_not_wang_solution_correctness(): + # F0 east is 0 while F1 west is 1. Grouping remains presentation-only; + # verification of adjacency and boundary is an upstream precondition. + assert _recognize((7, 8), width=2) == ( + GeneralizedInstance("F0", 0, 0), + GeneralizedInstance("F1", 1, 0), + ) + + +def test_guard_accepts_only_the_exact_canonical_positional_table(): + bundle = load_explainability_bundle(MANIFEST) + check_canonical_atomic_tileset(bundle.tileset.tile_edges) + assert bundle.tileset.tile_edges == CANONICAL_ATOMIC_TILE_EDGES + + reordered = list(CANONICAL_ATOMIC_TILE_EDGES) + reordered[11], reordered[12] = reordered[12], reordered[11] + with pytest.raises(GeneralizedTileError, match="atomic tile 11"): + check_canonical_atomic_tileset(tuple(reordered)) + + reoriented = list(CANONICAL_ATOMIC_TILE_EDGES) + north, east, south, west = reoriented[13] + reoriented[13] = (west, north, east, south) + with pytest.raises(GeneralizedTileError, match="N/E/S/W orientation"): + check_canonical_atomic_tileset(tuple(reoriented)) + + +def test_canonical_witness_has_one_exact_nonoverlapping_partition(): + presentation = load_wang_presentation(SOLUTION) + instances = recognize_generalized_tiles( + presentation.tile_edges, + presentation.cells, + min_x=presentation.min_x, + min_y=presentation.min_y, + max_x=presentation.max_x, + max_y=presentation.max_y, + ) + + assert len(instances) == 403 + assert Counter(instance.kind for instance in instances) == { + "V0": 2, + "V1": 3, + "C0": 3, + "C1": 3, + "F0": 241, + "F1": 91, + "L0": 20, + "L1": 6, + "R0": 20, + "R1": 8, + "X00": 2, + "X01": 2, + "X10": 2, + } + check_generalized_instances( + presentation.tile_edges, + presentation.cells, + min_x=presentation.min_x, + min_y=presentation.min_y, + max_x=presentation.max_x, + max_y=presentation.max_y, + instances=instances, + ) + + +def test_three_stacked_atomic_tile_3_cells_remain_three_v1_occurrences(): + presentation = load_wang_presentation(SOLUTION) + instances = recognize_generalized_tiles( + presentation.tile_edges, + presentation.cells, + min_x=presentation.min_x, + min_y=presentation.min_y, + max_x=presentation.max_x, + max_y=presentation.max_y, + ) + + assert [ + (instance.origin_x, instance.origin_y) + for instance in instances + if instance.kind == "V1" + ] == [(0, 4), (0, 5), (0, 6)] + + +@pytest.mark.parametrize( + ("cells", "width", "message"), + ( + ((0, 1, None), 1, "incomplete or misoriented V0"), + ((2, 1, 0), 1, "incomplete or misoriented V0"), + ((11, None, 12), 3, "incomplete or misoriented R0"), + ((12, 11), 2, "incomplete or misoriented R0"), + ), +) +def test_recognition_rejects_missing_parts_wrong_order_and_orientation( + cells, width, message +): + with pytest.raises(GeneralizedTileError, match=message): + _recognize(cells, width=width) + + +def test_explicit_partition_checker_rejects_overlap_and_uncovered_cells(): + duplicate = ( + GeneralizedInstance("F0", 0, 0), + GeneralizedInstance("F0", 0, 0), + ) + with pytest.raises(GeneralizedTileError, match=r"overlap at \(0,0\)"): + check_generalized_instances( + CANONICAL_ATOMIC_TILE_EDGES, + (7,), + min_x=0, + min_y=0, + max_x=0, + max_y=0, + instances=duplicate, + ) + + with pytest.raises(GeneralizedTileError, match="uncovered"): + check_generalized_instances( + CANONICAL_ATOMIC_TILE_EDGES, + (7,), + min_x=0, + min_y=0, + max_x=0, + max_y=0, + instances=(), + ) + + +@pytest.mark.parametrize("view", GOLDENS) +def test_generalized_views_match_separate_pixel_stable_goldens(tmp_path, view): + source = SOLUTION if view == "generalized-overlay" else MANIFEST + output = tmp_path / f"{view}.png" + + render_generalized_view(source, output, view=view) + + assert output.read_bytes() == GOLDENS[view].read_bytes() + with Image.open(output) as image: + assert image.mode == "RGB" + + +def test_cli_dispatches_all_three_generalized_views(tmp_path): + for view in GOLDENS: + source = SOLUTION if view == "generalized-overlay" else MANIFEST + output = tmp_path / f"{view}.png" + wang_square.main([str(source), str(output), "--view", view]) + assert output.read_bytes() == GOLDENS[view].read_bytes() + + +@pytest.mark.parametrize("view", GOLDENS) +def test_generalized_views_are_square_only(tmp_path, view): + source = SOLUTION if view == "generalized-overlay" else MANIFEST + with pytest.raises(WangSquareRenderError, match="not meaningful"): + render_generalized_view( + source, + tmp_path / "output.png", + view=view, + hex_mode=True, + ) + + +def test_invalid_atomic_table_preserves_existing_overlay_output(tmp_path): + document = json.loads(SOLUTION.read_text(encoding="utf-8")) + document["tile_table"][11]["edges"], document["tile_table"][12]["edges"] = ( + document["tile_table"][12]["edges"], + document["tile_table"][11]["edges"], + ) + invalid = tmp_path / "invalid.json" + invalid.write_text(json.dumps(document), encoding="utf-8") + output = tmp_path / "overlay.png" + output.write_bytes(b"previous output") + + with pytest.raises(WangSquareRenderError, match="atomic tile 11"): + render_generalized_view(invalid, output, view="generalized-overlay") + + assert output.read_bytes() == b"previous output" + assert not list(tmp_path.glob(".overlay.png.*.png")) + + +def test_existing_square_and_hex_outputs_remain_byte_identical(tmp_path): + expected = { + False: RENDERER_DIR / "test_data/wang_solution_v1_square_sat.png", + True: RENDERER_DIR / "test_data/wang_solution_v1_hex_sat.png", + } + legacy_solution = ROOT / "tests/fixtures/wang_solution_v1_square_sat.json" + for hex_mode in (False, True): + output = tmp_path / f"legacy-{hex_mode}.png" + render_wang_square(legacy_solution, output, hex_mode=hex_mode) + assert output.read_bytes() == expected[hex_mode].read_bytes() + + +def test_pure_semantic_module_imports_no_raster_or_project_code(): + script = f""" +import importlib.util +from pathlib import Path +import sys +module_path = Path({str(RENDERER_DIR / 'wang_generalized.py')!r}) +spec = importlib.util.spec_from_file_location('isolated_wang_generalized', module_path) +module = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = module +spec.loader.exec_module(module) +assert len(module.GENERALIZED_TILES) == 14 +for forbidden in ('numpy', 'PIL', 'z3', 'formats', 'model', 'native'): + assert forbidden not in sys.modules, forbidden +""" + completed = subprocess.run( + [sys.executable, "-I", "-B", "-c", script], + cwd=RENDERER_DIR, + capture_output=True, + text=True, + check=False, + ) + assert completed.returncode == 0, completed.stderr + + +def test_generalized_overlay_consumer_loads_no_solver_z3_or_libwang(tmp_path): + output = tmp_path / "overlay.png" + script = f""" +from pathlib import Path +import sys +sys.path.insert(0, {str(RENDERER_DIR)!r}) +from wang_generalized_render import render_generalized_view +render_generalized_view( + Path({str(SOLUTION)!r}), + Path({str(output)!r}), + view='generalized-overlay', +) +for forbidden in ('z3', 'formats', 'model', 'native'): + assert forbidden not in sys.modules, forbidden +maps = Path('/proc/self/maps').read_text(encoding='utf-8') +assert 'libwang.so' not in maps +""" + completed = subprocess.run( + [sys.executable, "-I", "-B", "-c", script], + cwd=RENDERER_DIR, + capture_output=True, + text=True, + check=False, + ) + assert completed.returncode == 0, completed.stderr + assert output.exists() diff --git a/renderer/wang_generalized.py b/renderer/wang_generalized.py new file mode 100644 index 0000000..95ecea0 --- /dev/null +++ b/renderer/wang_generalized.py @@ -0,0 +1,393 @@ +"""Pure semantic model for the fixed Yang--Zhang generalized tiles. + +The solver and the square solution contract expose only the 23 positional +atomic tile IDs. This module is a presentation-only interpretation of those +IDs: it guards the exact canonical edge table and recognizes the 14 fixed +generalized shapes from ID, orientation, part adjacency, and complete +composition. +It deliberately imports no raster, solver, native, or root-project module. +""" + +from __future__ import annotations + +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Final + + +class GeneralizedTileError(ValueError): + """Raised when an atomic table or generalized composition is not exact.""" + + +@dataclass(frozen=True, slots=True) +class AtomicPart: + tile_id: int + dx: int + dy: int + label: str + + +@dataclass(frozen=True, slots=True) +class GeneralizedTile: + name: str + parts: tuple[AtomicPart, ...] + + @property + def width(self) -> int: + return max(part.dx for part in self.parts) + 1 + + @property + def height(self) -> int: + return max(part.dy for part in self.parts) + 1 + + +@dataclass(frozen=True, slots=True) +class GeneralizedInstance: + kind: str + origin_x: int + origin_y: int + + +PAPER_COLOR_NAMES: Final[tuple[str, ...]] = ( + "b", + "v", + "0", + "1", + "0-prime", + "l", + "r", +) +INTERNAL_COLOR_NAMES: Final[tuple[str, ...]] = ( + "V0:a", + "V0:b", + "C1", + "R0", + "R1", + "X00", + "X01", + "X10", + "X11", +) +COLOR_NAMES: Final[tuple[str, ...]] = PAPER_COLOR_NAMES + INTERNAL_COLOR_NAMES + +# Tuple position is the immutable atomic tile ID; edges are N, E, S, W. +CANONICAL_ATOMIC_TILE_EDGES: Final[ + tuple[tuple[int, int, int, int], ...] +] = ( + (0, 2, 7, 1), + (7, 2, 8, 1), + (8, 2, 0, 1), + (0, 3, 0, 1), + (0, 4, 0, 2), + (0, 4, 9, 3), + (9, 3, 0, 2), + (0, 2, 0, 2), + (0, 3, 0, 3), + (5, 2, 5, 2), + (5, 3, 5, 3), + (0, 10, 6, 2), + (6, 2, 0, 10), + (0, 11, 6, 3), + (6, 3, 0, 11), + (6, 2, 12, 2), + (12, 2, 5, 2), + (6, 2, 13, 3), + (13, 3, 5, 2), + (6, 3, 14, 2), + (14, 2, 5, 3), + (6, 3, 15, 3), + (15, 3, 5, 3), +) + +GENERALIZED_TILES: Final[tuple[GeneralizedTile, ...]] = ( + GeneralizedTile( + "V0", + ( + AtomicPart(0, 0, 0, "top"), + AtomicPart(1, 0, 1, "middle"), + AtomicPart(2, 0, 2, "bottom"), + ), + ), + GeneralizedTile("V1", (AtomicPart(3, 0, 0, "copy"),)), + GeneralizedTile("C0", (AtomicPart(4, 0, 0, "single"),)), + GeneralizedTile( + "C1", + (AtomicPart(5, 0, 0, "top"), AtomicPart(6, 0, 1, "bottom")), + ), + GeneralizedTile("F0", (AtomicPart(7, 0, 0, "single"),)), + GeneralizedTile("F1", (AtomicPart(8, 0, 0, "single"),)), + GeneralizedTile("L0", (AtomicPart(9, 0, 0, "single"),)), + GeneralizedTile("L1", (AtomicPart(10, 0, 0, "single"),)), + GeneralizedTile( + "R0", + (AtomicPart(11, 0, 0, "left"), AtomicPart(12, 1, 0, "right")), + ), + GeneralizedTile( + "R1", + (AtomicPart(13, 0, 0, "left"), AtomicPart(14, 1, 0, "right")), + ), + GeneralizedTile( + "X00", + (AtomicPart(15, 0, 0, "top"), AtomicPart(16, 0, 1, "bottom")), + ), + GeneralizedTile( + "X01", + (AtomicPart(17, 0, 0, "top"), AtomicPart(18, 0, 1, "bottom")), + ), + GeneralizedTile( + "X10", + (AtomicPart(19, 0, 0, "top"), AtomicPart(20, 0, 1, "bottom")), + ), + GeneralizedTile( + "X11", + (AtomicPart(21, 0, 0, "top"), AtomicPart(22, 0, 1, "bottom")), + ), +) + +_GENERALIZED_BY_NAME: Final = {tile.name: tile for tile in GENERALIZED_TILES} +_GENERALIZED_ORDER: Final = { + tile.name: index for index, tile in enumerate(GENERALIZED_TILES) +} +_PART_BY_TILE_ID: Final = { + part.tile_id: (tile, part) + for tile in GENERALIZED_TILES + for part in tile.parts +} + + +def _validate_fixed_specification() -> None: + if len(GENERALIZED_TILES) != 14: + raise RuntimeError("generalized table must contain exactly 14 tiles") + if len(CANONICAL_ATOMIC_TILE_EDGES) != 23: + raise RuntimeError("atomic table must contain exactly 23 tiles") + if len(COLOR_NAMES) != 16: + raise RuntimeError("color vocabulary must contain exactly 16 colors") + if len(_GENERALIZED_BY_NAME) != len(GENERALIZED_TILES): + raise RuntimeError("generalized names must be unique") + if set(_PART_BY_TILE_ID) != set(range(23)): + raise RuntimeError("generalized parts must partition atomic IDs 0..22") + for tile in GENERALIZED_TILES: + coordinates = {(part.dx, part.dy) for part in tile.parts} + if len(coordinates) != len(tile.parts): + raise RuntimeError(f"{tile.name} contains duplicate part coordinates") + + +_validate_fixed_specification() + + +def color_label(color_id: int) -> str: + """Return the symbolic paper/glue name with the numeric ID secondary.""" + if type(color_id) is not int or not 0 <= color_id < len(COLOR_NAMES): + raise GeneralizedTileError(f"invalid canonical color ID {color_id!r}") + return f"{COLOR_NAMES[color_id]} [{color_id}]" + + +def atomic_semantic_label(tile_id: int) -> str: + """Return the generalized name and part role for one positional ID.""" + if type(tile_id) is not int or tile_id not in _PART_BY_TILE_ID: + raise GeneralizedTileError(f"invalid canonical atomic tile ID {tile_id!r}") + tile, part = _PART_BY_TILE_ID[tile_id] + if len(tile.parts) == 1: + return tile.name + return f"{tile.name} {part.label}" + + +def check_canonical_atomic_tileset( + tile_edges: Sequence[Sequence[int]], +) -> None: + """Require the exact positional 23-tile table, including orientation.""" + if isinstance(tile_edges, (str, bytes)): + raise GeneralizedTileError("atomic tile table must be a sequence") + if len(tile_edges) != len(CANONICAL_ATOMIC_TILE_EDGES): + raise GeneralizedTileError( + "atomic tile table must contain exactly 23 positional entries" + ) + for tile_id, (actual, expected) in enumerate( + zip(tile_edges, CANONICAL_ATOMIC_TILE_EDGES, strict=True) + ): + if isinstance(actual, (str, bytes)) or len(actual) != 4: + raise GeneralizedTileError( + f"atomic tile {tile_id} must contain four N/E/S/W edges" + ) + checked = tuple(actual) + if any(type(color) is not int for color in checked): + raise GeneralizedTileError( + f"atomic tile {tile_id} edges must be integer color IDs" + ) + if checked != expected: + raise GeneralizedTileError( + f"atomic tile {tile_id} does not match the canonical " + f"N/E/S/W orientation: expected {expected}, got {checked}" + ) + + +def _check_grid( + cells: Sequence[int | None], + min_x: int, + min_y: int, + max_x: int, + max_y: int, +) -> int: + for name, value in ( + ("min_x", min_x), + ("min_y", min_y), + ("max_x", max_x), + ("max_y", max_y), + ): + if type(value) is not int: + raise GeneralizedTileError(f"{name} must be an integer") + if max_x < min_x or max_y < min_y: + raise GeneralizedTileError("generalized grid bounds must be nonempty") + width = max_x - min_x + 1 + area = width * (max_y - min_y + 1) + if isinstance(cells, (str, bytes)) or len(cells) != area: + raise GeneralizedTileError( + f"generalized grid must contain exactly {area} dense cells" + ) + for index, tile_id in enumerate(cells): + if tile_id is None: + continue + if type(tile_id) is not int or tile_id not in _PART_BY_TILE_ID: + raise GeneralizedTileError( + f"generalized grid cell {index} has invalid tile ID {tile_id!r}" + ) + return width + + +def _cell_at( + cells: Sequence[int | None], + width: int, + min_x: int, + min_y: int, + max_x: int, + max_y: int, + x: int, + y: int, +) -> int | None: + if x < min_x or x > max_x or y < min_y or y > max_y: + return None + return cells[(y - min_y) * width + (x - min_x)] + + +def check_generalized_instances( + tile_edges: Sequence[Sequence[int]], + cells: Sequence[int | None], + *, + min_x: int, + min_y: int, + max_x: int, + max_y: int, + instances: Sequence[GeneralizedInstance], +) -> None: + """Check exact composition, non-overlap, and complete active coverage.""" + check_canonical_atomic_tileset(tile_edges) + width = _check_grid(cells, min_x, min_y, max_x, max_y) + if isinstance(instances, (str, bytes)): + raise GeneralizedTileError("generalized instances must be a sequence") + + covered: dict[tuple[int, int], int] = {} + for instance_index, instance in enumerate(instances): + if not isinstance(instance, GeneralizedInstance): + raise GeneralizedTileError( + f"generalized instance {instance_index} has an invalid type" + ) + tile = _GENERALIZED_BY_NAME.get(instance.kind) + if tile is None: + raise GeneralizedTileError( + f"generalized instance {instance_index} has unknown kind " + f"{instance.kind!r}" + ) + if type(instance.origin_x) is not int or type(instance.origin_y) is not int: + raise GeneralizedTileError( + f"generalized instance {instance_index} origin must be integral" + ) + for part in tile.parts: + x = instance.origin_x + part.dx + y = instance.origin_y + part.dy + actual = _cell_at( + cells, width, min_x, min_y, max_x, max_y, x, y + ) + if actual != part.tile_id: + raise GeneralizedTileError( + f"incomplete or misoriented {tile.name} at " + f"({instance.origin_x},{instance.origin_y}): expected " + f"atomic tile {part.tile_id} at ({x},{y}), got {actual!r}" + ) + coordinate = (x, y) + previous = covered.get(coordinate) + if previous is not None: + raise GeneralizedTileError( + f"generalized instances {previous} and {instance_index} " + f"overlap at ({x},{y})" + ) + covered[coordinate] = instance_index + + active = { + (min_x + index % width, min_y + index // width) + for index, tile_id in enumerate(cells) + if tile_id is not None + } + missing = sorted(active - set(covered), key=lambda item: (item[1], item[0])) + if missing: + x, y = missing[0] + raise GeneralizedTileError( + f"generalized recognition leaves active cell ({x},{y}) uncovered" + ) + extra = set(covered) - active + if extra: + x, y = min(extra, key=lambda item: (item[1], item[0])) + raise GeneralizedTileError( + f"generalized recognition covers inactive cell ({x},{y})" + ) + + +def recognize_generalized_tiles( + tile_edges: Sequence[Sequence[int]], + cells: Sequence[int | None], + *, + min_x: int, + min_y: int, + max_x: int, + max_y: int, +) -> tuple[GeneralizedInstance, ...]: + """Recognize the unique complete generalized partition of a square witness.""" + check_canonical_atomic_tileset(tile_edges) + width = _check_grid(cells, min_x, min_y, max_x, max_y) + candidates: set[tuple[str, int, int]] = set() + for index, tile_id in enumerate(cells): + if tile_id is None: + continue + tile, part = _PART_BY_TILE_ID[tile_id] + x = min_x + index % width + y = min_y + index // width + candidates.add((tile.name, x - part.dx, y - part.dy)) + + instances = tuple( + GeneralizedInstance(kind, origin_x, origin_y) + for kind, origin_x, origin_y in sorted( + candidates, + key=lambda item: ( + item[2], + item[1], + _GENERALIZED_ORDER[item[0]], + ), + ) + ) + check_generalized_instances( + tile_edges, + cells, + min_x=min_x, + min_y=min_y, + max_x=max_x, + max_y=max_y, + instances=instances, + ) + return instances + + +def generalized_tile(kind: str) -> GeneralizedTile: + """Return one immutable canonical generalized specification by name.""" + tile = _GENERALIZED_BY_NAME.get(kind) + if tile is None: + raise GeneralizedTileError(f"unknown generalized tile {kind!r}") + return tile diff --git a/renderer/wang_generalized_render.py b/renderer/wang_generalized_render.py new file mode 100644 index 0000000..b384f74 --- /dev/null +++ b/renderer/wang_generalized_render.py @@ -0,0 +1,557 @@ +"""Deterministic square-only rasters for generalized Wang presentation.""" + +from __future__ import annotations + +from collections import Counter +from pathlib import Path +from typing import Final + +import numpy as np +from PIL import Image, ImageDraw + +from wang_explain import ( + EXPLAIN_MUTED_RGB, + EXPLAIN_OUTLINE_RGB, + EXPLAIN_PANEL_RGB, + EXPLAIN_TEXT_RGB, + draw_explain_heading, + explain_font, + square_explain_tile, + square_inactive_tile, +) +from wang_generalized import ( + COLOR_NAMES, + GENERALIZED_TILES, + GeneralizedInstance, + GeneralizedTile, + GeneralizedTileError, + atomic_semantic_label, + check_canonical_atomic_tileset, + color_label, + generalized_tile, + recognize_generalized_tiles, +) +from wang_hex_port import WangPresentation, WangSquareRenderError +from wang_square import ( + DEFAULT_MARGIN, + DEFAULT_PIXELS_PER_CELL, + MAX_MARGIN, + MAX_PIXELS_PER_CELL, + MIN_PIXELS_PER_CELL, + _build_palette_from_edges, + _check_canvas_limits, + _render_integer, + _save_png_atomic, + load_wang_presentation, +) + + +_HEADER_HEIGHT: Final = 62 +_PANEL_GAP: Final = 18 +_INTERNAL_SEAM_RGB: Final = (184, 191, 202) +_GROUP_COLORS: Final[tuple[tuple[int, int, int], ...]] = ( + (31, 119, 180), + (255, 127, 14), + (44, 160, 44), + (214, 39, 40), + (148, 103, 189), + (140, 86, 75), + (227, 119, 194), + (99, 99, 99), + (188, 189, 34), + (23, 190, 207), + (57, 106, 177), + (218, 124, 48), + (62, 150, 81), + (204, 37, 41), +) +_GROUP_RGB: Final = { + tile.name: _GROUP_COLORS[index] + for index, tile in enumerate(GENERALIZED_TILES) +} + + +def _as_render_error(error: GeneralizedTileError) -> WangSquareRenderError: + return WangSquareRenderError(f"generalized Wang presentation: {error}") + + +def _checked_dimensions( + pixels_per_cell: int, + margin: int, +) -> tuple[int, int]: + ppc = _render_integer( + pixels_per_cell, + "pixels_per_cell", + minimum=MIN_PIXELS_PER_CELL, + maximum=MAX_PIXELS_PER_CELL, + ) + checked_margin = _render_integer( + margin, + "margin", + minimum=0, + maximum=MAX_MARGIN, + ) + return ppc, checked_margin + + +def _draw_group_outline( + draw: ImageDraw.ImageDraw, + tile: GeneralizedTile, + origin: tuple[int, int], + cell_size: int, + *, + color: tuple[int, int, int], +) -> None: + x, y = origin + width = max(2, cell_size // 16) + draw.rectangle( + ( + x, + y, + x + tile.width * cell_size - 1, + y + tile.height * cell_size - 1, + ), + outline=color, + width=width, + ) + + coordinates = {(part.dx, part.dy) for part in tile.parts} + seam_width = max(3, cell_size // 14) + for dx, dy in coordinates: + if (dx + 1, dy) in coordinates: + seam_x = x + (dx + 1) * cell_size + draw.line( + ( + seam_x, + y + dy * cell_size + width, + seam_x, + y + (dy + 1) * cell_size - width - 1, + ), + fill=_INTERNAL_SEAM_RGB, + width=seam_width, + ) + if (dx, dy + 1) in coordinates: + seam_y = y + (dy + 1) * cell_size + draw.line( + ( + x + dx * cell_size + width, + seam_y, + x + (dx + 1) * cell_size - width - 1, + seam_y, + ), + fill=_INTERNAL_SEAM_RGB, + width=seam_width, + ) + + +def _draw_color_vocabulary( + draw: ImageDraw.ImageDraw, + origin: tuple[int, int], + palette: dict[int, tuple[int, int, int]], +) -> int: + x, y = origin + draw.text( + (x, y), + "Paper colors", + font=explain_font(15), + fill=EXPLAIN_TEXT_RGB, + ) + y += 25 + font = explain_font(12) + for color_id in range(7): + draw.rectangle( + (x, y + 2, x + 16, y + 16), + fill=palette[color_id], + outline=EXPLAIN_OUTLINE_RGB, + ) + draw.text( + (x + 24, y), + color_label(color_id), + font=font, + fill=EXPLAIN_TEXT_RGB, + ) + y += 22 + y += 9 + draw.text( + (x, y), + "Internal glues", + font=explain_font(15), + fill=EXPLAIN_TEXT_RGB, + ) + y += 25 + for color_id in range(7, len(COLOR_NAMES)): + draw.rectangle( + (x, y + 2, x + 16, y + 16), + fill=palette[color_id], + outline=EXPLAIN_OUTLINE_RGB, + ) + draw.text( + (x + 24, y), + color_label(color_id), + font=font, + fill=EXPLAIN_TEXT_RGB, + ) + y += 22 + return y + + +def compose_generalized_sheet( + tile_edges: tuple[tuple[int, int, int, int], ...], + *, + pixels_per_cell: int = DEFAULT_PIXELS_PER_CELL, + margin: int = DEFAULT_MARGIN, +) -> np.ndarray: + """Compose the fixed 14-tile sheet from an exact canonical atomic table.""" + try: + check_canonical_atomic_tileset(tile_edges) + except GeneralizedTileError as error: + raise _as_render_error(error) from error + ppc, checked_margin = _checked_dimensions(pixels_per_cell, margin) + cell_size = max(58, ppc * 2) + columns = 4 + rows = (len(GENERALIZED_TILES) + columns - 1) // columns + card_width = 2 * cell_size + 34 + card_height = 3 * cell_size + 66 + gap = 12 + grid_width = columns * card_width + (columns - 1) * gap + grid_height = rows * card_height + (rows - 1) * gap + vocabulary_width = 190 + width = 2 * checked_margin + grid_width + _PANEL_GAP + vocabulary_width + height = 2 * checked_margin + _HEADER_HEIGHT + grid_height + _check_canvas_limits(width, height) + + canvas = Image.new("RGB", (width, height), EXPLAIN_PANEL_RGB) + draw = ImageDraw.Draw(canvas) + draw_explain_heading( + draw, + (checked_margin, checked_margin), + title="Yang-Zhang generalized tile sheet - 14 from 23", + subtitle=( + "outer contour is semantic; muted seams are forced internal glues; " + "center numbers are atomic IDs" + ), + ) + palette = _build_palette_from_edges(tile_edges) + grid_y = checked_margin + _HEADER_HEIGHT + for tile_index, tile in enumerate(GENERALIZED_TILES): + column = tile_index % columns + row = tile_index // columns + card_x = checked_margin + column * (card_width + gap) + card_y = grid_y + row * (card_height + gap) + draw.rounded_rectangle( + ( + card_x, + card_y, + card_x + card_width - 1, + card_y + card_height - 1, + ), + radius=8, + fill=(249, 250, 252), + outline=(214, 219, 227), + ) + draw.text( + (card_x + 10, card_y + 8), + tile.name, + font=explain_font(17), + fill=_GROUP_RGB[tile.name], + ) + shape_x = card_x + (card_width - tile.width * cell_size) // 2 + shape_y = card_y + 34 + for part in tile.parts: + asset = square_explain_tile( + tile_edges[part.tile_id], + palette, + cell_size, + tile_id=part.tile_id, + edge_labels=True, + ) + canvas.paste( + asset, + (shape_x + part.dx * cell_size, shape_y + part.dy * cell_size), + ) + _draw_group_outline( + draw, + tile, + (shape_x, shape_y), + cell_size, + color=_GROUP_RGB[tile.name], + ) + ids = "|".join(str(part.tile_id) for part in tile.parts) + draw.text( + (card_x + 10, card_y + card_height - 24), + f"atomic IDs {ids}", + font=explain_font(11), + fill=EXPLAIN_MUTED_RGB, + ) + _draw_color_vocabulary( + draw, + (checked_margin + grid_width + _PANEL_GAP, grid_y), + palette, + ) + return np.asarray(canvas, dtype=np.uint8) + + +def compose_atomic_semantic_legend( + tile_edges: tuple[tuple[int, int, int, int], ...], + *, + pixels_per_cell: int = DEFAULT_PIXELS_PER_CELL, + margin: int = DEFAULT_MARGIN, +) -> np.ndarray: + """Compose all 23 atomic IDs with generalized part and symbolic edges.""" + try: + check_canonical_atomic_tileset(tile_edges) + except GeneralizedTileError as error: + raise _as_render_error(error) from error + ppc, checked_margin = _checked_dimensions(pixels_per_cell, margin) + cell_size = max(58, ppc * 2) + columns = 3 + rows = (len(tile_edges) + columns - 1) // columns + card_width = 420 + card_height = cell_size + 24 + gap = 10 + width = 2 * checked_margin + columns * card_width + (columns - 1) * gap + height = 2 * checked_margin + _HEADER_HEIGHT + rows * card_height + _check_canvas_limits(width, height) + + canvas = Image.new("RGB", (width, height), EXPLAIN_PANEL_RGB) + draw = ImageDraw.Draw(canvas) + draw_explain_heading( + draw, + (checked_margin, checked_margin), + title="Atomic Wang legend - 23 positional IDs", + subtitle=( + "generalized role and symbolic color are primary; numeric IDs in " + "brackets preserve the transport vocabulary" + ), + ) + palette = _build_palette_from_edges(tile_edges) + grid_y = checked_margin + _HEADER_HEIGHT + for tile_id, edges in enumerate(tile_edges): + column = tile_id % columns + row = tile_id // columns + x = checked_margin + column * (card_width + gap) + y = grid_y + row * card_height + draw.rounded_rectangle( + (x, y, x + card_width - 1, y + card_height - 8), + radius=7, + fill=(249, 250, 252), + outline=(214, 219, 227), + ) + asset = square_explain_tile( + edges, + palette, + cell_size, + tile_id=tile_id, + edge_labels=False, + ) + canvas.paste(asset, (x + 8, y + 8)) + text_x = x + cell_size + 20 + draw.text( + (text_x, y + 9), + f"{atomic_semantic_label(tile_id)} - atomic #{tile_id}", + font=explain_font(14), + fill=EXPLAIN_TEXT_RGB, + ) + draw.text( + (text_x, y + 33), + f"N {color_label(edges[0])} E {color_label(edges[1])}", + font=explain_font(11), + fill=EXPLAIN_TEXT_RGB, + ) + draw.text( + (text_x, y + 53), + f"S {color_label(edges[2])} W {color_label(edges[3])}", + font=explain_font(11), + fill=EXPLAIN_TEXT_RGB, + ) + return np.asarray(canvas, dtype=np.uint8) + + +def _overlay_side_panel( + draw: ImageDraw.ImageDraw, + origin: tuple[int, int], + instances: tuple[GeneralizedInstance, ...], + active_count: int, +) -> None: + x, y = origin + counts = Counter(instance.kind for instance in instances) + draw.text( + (x, y), + "Exact recognized partition", + font=explain_font(15), + fill=EXPLAIN_TEXT_RGB, + ) + y += 27 + font = explain_font(11) + for index, tile in enumerate(GENERALIZED_TILES): + column = index // 7 + row = index % 7 + item_x = x + column * 126 + item_y = y + row * 25 + draw.rectangle( + (item_x, item_y + 2, item_x + 16, item_y + 16), + fill=_GROUP_RGB[tile.name], + outline=EXPLAIN_OUTLINE_RGB, + ) + draw.text( + (item_x + 23, item_y), + f"{tile.name}: {counts[tile.name]}", + font=font, + fill=EXPLAIN_TEXT_RGB, + ) + y += 7 * 25 + 12 + draw.text( + (x, y), + f"{len(instances)} generalized occurrences", + font=explain_font(12), + fill=EXPLAIN_TEXT_RGB, + ) + draw.text( + (x, y + 20), + f"{active_count} atomic cells, no overlap", + font=explain_font(12), + fill=EXPLAIN_TEXT_RGB, + ) + draw.text( + (x, y + 50), + "V1 remains one cell per occurrence.", + font=explain_font(11), + fill=EXPLAIN_MUTED_RGB, + ) + draw.text( + (x, y + 68), + "Adjacent V1 copies are never merged.", + font=explain_font(11), + fill=EXPLAIN_MUTED_RGB, + ) + + +def compose_generalized_overlay( + presentation: WangPresentation, + *, + pixels_per_cell: int = DEFAULT_PIXELS_PER_CELL, + margin: int = DEFAULT_MARGIN, +) -> np.ndarray: + """Overlay the exact generalized partition on one square atomic witness.""" + if not isinstance(presentation, WangPresentation): + raise TypeError("presentation must be a WangPresentation") + try: + instances = recognize_generalized_tiles( + presentation.tile_edges, + presentation.cells, + min_x=presentation.min_x, + min_y=presentation.min_y, + max_x=presentation.max_x, + max_y=presentation.max_y, + ) + except GeneralizedTileError as error: + raise _as_render_error(error) from error + ppc, checked_margin = _checked_dimensions(pixels_per_cell, margin) + grid_width = presentation.width * ppc + grid_height = presentation.height * ppc + side_width = 270 + width = 2 * checked_margin + grid_width + _PANEL_GAP + side_width + height = max( + 2 * checked_margin + _HEADER_HEIGHT + grid_height, + 2 * checked_margin + _HEADER_HEIGHT + 300, + ) + _check_canvas_limits(width, height) + + canvas = Image.new("RGB", (width, height), EXPLAIN_PANEL_RGB) + draw = ImageDraw.Draw(canvas) + draw_explain_heading( + draw, + (checked_margin, checked_margin), + title="Square solution presentation - generalized overlay", + subtitle=( + "upstream verification is a precondition; contours group exact " + "atomic compositions and IDs remain visible" + ), + ) + grid_x = checked_margin + grid_y = checked_margin + _HEADER_HEIGHT + palette = _build_palette_from_edges(presentation.tile_edges) + inactive = square_inactive_tile(ppc) + assets: dict[int, Image.Image] = {} + for index, tile_id in enumerate(presentation.cells): + x = grid_x + (index % presentation.width) * ppc + y = grid_y + (index // presentation.width) * ppc + if tile_id is None: + canvas.paste(inactive, (x, y)) + continue + asset = assets.get(tile_id) + if asset is None: + asset = square_explain_tile( + presentation.tile_edges[tile_id], + palette, + ppc, + tile_id=tile_id, + edge_labels=False, + ) + assets[tile_id] = asset + canvas.paste(asset, (x, y)) + + draw = ImageDraw.Draw(canvas) + for instance in instances: + tile = generalized_tile(instance.kind) + origin_x = grid_x + (instance.origin_x - presentation.min_x) * ppc + origin_y = grid_y + (instance.origin_y - presentation.min_y) * ppc + _draw_group_outline( + draw, + tile, + (origin_x, origin_y), + ppc, + color=_GROUP_RGB[tile.name], + ) + _overlay_side_panel( + draw, + (grid_x + grid_width + _PANEL_GAP, grid_y), + instances, + sum(tile_id is not None for tile_id in presentation.cells), + ) + return np.asarray(canvas, dtype=np.uint8) + + +def render_generalized_view( + input_path: str | Path, + output_path: str | Path, + *, + view: str, + pixels_per_cell: int = DEFAULT_PIXELS_PER_CELL, + margin: int = DEFAULT_MARGIN, + hex_mode: bool = False, +) -> None: + """Render one explicit generalized square view and atomically install it.""" + if hex_mode: + raise WangSquareRenderError( + "--hex is not meaningful for generalized square grouping" + ) + if view == "generalized-overlay": + presentation = load_wang_presentation(input_path) + canvas = compose_generalized_overlay( + presentation, + pixels_per_cell=pixels_per_cell, + margin=margin, + ) + elif view in {"generalized-sheet", "atomic-legend"}: + from wang_snapshot import load_explainability_bundle + + bundle = load_explainability_bundle(input_path) + if view == "generalized-sheet": + canvas = compose_generalized_sheet( + bundle.tileset.tile_edges, + pixels_per_cell=pixels_per_cell, + margin=margin, + ) + else: + canvas = compose_atomic_semantic_legend( + bundle.tileset.tile_edges, + pixels_per_cell=pixels_per_cell, + margin=margin, + ) + else: + raise WangSquareRenderError( + "generalized view must be generalized-sheet, atomic-legend, " + "or generalized-overlay" + ) + _save_png_atomic(canvas, output_path) diff --git a/renderer/wang_square.py b/renderer/wang_square.py index dda119a..f32e1b5 100644 --- a/renderer/wang_square.py +++ b/renderer/wang_square.py @@ -977,7 +977,16 @@ def _parser() -> argparse.ArgumentParser: ) parser.add_argument( "--view", - choices=("solution", "formula", "tileset", "region", "reduction"), + choices=( + "solution", + "formula", + "tileset", + "region", + "reduction", + "generalized-sheet", + "atomic-legend", + "generalized-overlay", + ), default="solution", help="input stage to render (default: solution)", ) @@ -1002,16 +1011,32 @@ def main(argv: list[str] | None = None) -> None: raise WangSquareRenderError( "snapshot views are already explainable; omit --explain" ) - from wang_snapshot import render_pipeline_snapshot - - render_pipeline_snapshot( - args.input, - args.output, - view=args.view, - pixels_per_cell=args.pixels_per_cell, - margin=args.margin, - hex_mode=args.hex, - ) + if args.view in { + "generalized-sheet", + "atomic-legend", + "generalized-overlay", + }: + from wang_generalized_render import render_generalized_view + + render_generalized_view( + args.input, + args.output, + view=args.view, + pixels_per_cell=args.pixels_per_cell, + margin=args.margin, + hex_mode=args.hex, + ) + else: + from wang_snapshot import render_pipeline_snapshot + + render_pipeline_snapshot( + args.input, + args.output, + view=args.view, + pixels_per_cell=args.pixels_per_cell, + margin=args.margin, + hex_mode=args.hex, + ) except (FileNotFoundError, WangSquareRenderError) as error: parser.error(str(error))