diff --git a/renderer/README.md b/renderer/README.md index dbe89c8..0d5e059 100644 --- a/renderer/README.md +++ b/renderer/README.md @@ -6,6 +6,10 @@ A command-line pixel art renderer that composites a scene from a tile-based back **Status**: feature-complete. The code works and is tested; any future changes will be refactoring, cleanup, or optimization. +Tiling Foundry also supplies a separate presentation-only Wang square backend. +It consumes the versioned JSON boundary without importing the solver or +changing this legacy pixel-art pipeline. + ## Output ![example render](output/example.png) @@ -33,6 +37,29 @@ python main.py 255 (invalid) @@ -192,10 +222,17 @@ Raises `RenderingException` on pipeline errors. ## Tests ```bash -uv run pytest tests.py -v +uv run --locked pytest -q +``` + +The complete isolated suite has 189 tests: the 144 preserved legacy tests +below and 45 Wang square tests. To run only the original upstream suite: + +```bash +uv run --locked pytest tests.py -v ``` -144 tests covering all classes: +The 144 legacy tests cover all original classes: **Palette (18 tests)** - Happy path: load, `__getitem__` first/last, boundary values (0 and 255) diff --git a/renderer/UPSTREAM.md b/renderer/UPSTREAM.md index f4a305f..bd150d4 100644 --- a/renderer/UPSTREAM.md +++ b/renderer/UPSTREAM.md @@ -13,14 +13,21 @@ not contain a nested Git repository. - License: MIT; the upstream license is preserved in `LICENSE` - Import date: 2026-08-25 -All files tracked by that commit are present. Production code, tests, assets, -lockfile, project metadata, and license are byte-for-byte unchanged. The sole -documentary correction to the imported snapshot is in `README.md`: its stale -test totals said 135, while collection at the pinned commit yields 144 tests. -The overall and affected section totals now say 144, 18 Palette, 33 +All files tracked by that commit are present. The imported production code, +tests, assets, lockfile, project metadata, and license remain byte-for-byte +unchanged. At import, the sole documentary correction was in `README.md`: its +stale test totals said 135, while collection at the pinned commit yields 144 +tests. The overall and affected section totals now say 144, 18 Palette, 33 SceneParser, and 49 Blitter tests. This `UPSTREAM.md` file is local provenance, not an upstream file. +Tiling Foundry later added `wang_square.py`, `test_wang_square.py`, and the +decoded-pixel golden `test_data/wang_solution_v1_square_sat.png`, plus clearly +separated usage notes in `README.md`. These additions consume +`wang-solution-v1` as presentation data and do not modify or import the legacy +PAP modules. They also leave `pyproject.toml`, `uv.lock`, and the pinned +dependencies unchanged. + ## Verification The isolated upstream project requires Python 3.14 or newer. Its committed @@ -35,9 +42,10 @@ uv run --locked pytest --collect-only -q uv run --locked pytest -q ``` -The import baseline is 144 collected tests and 144 passing tests. Keep the -renderer environment under `renderer/.venv`; its local `.gitignore` excludes -that environment and generated Python/build files. +The import baseline remains 144 collected tests and 144 passing tests. The +combined local suite is 189 tests: 144 preserved legacy tests and 45 Wang +square tests. Keep the renderer environment under `renderer/.venv`; its local +`.gitignore` excludes that environment and generated Python/build files. The preserved `renderer/.github/workflows/ci.yml` is nested upstream evidence; GitHub does not run it as a workflow of the containing repository. The @@ -53,7 +61,8 @@ Treat an update as a separate reviewed change: instructions, tracked files, and collected test count. 3. Run its locked suite in a temporary clone before changing this directory. 4. Replace the snapshot from `git archive` so no upstream `.git` directory is - copied. Preserve this provenance file and document every local difference. + copied. Preserve this provenance file and reapply or consciously revise the + separately identified Wang files and README sections. 5. Re-run the renderer suite and the Tiling Foundry repository gates, then inspect the complete diff, secrets, and generated artifacts. diff --git a/renderer/test_data/wang_solution_v1_square_sat.png b/renderer/test_data/wang_solution_v1_square_sat.png new file mode 100644 index 0000000..540ad5b Binary files /dev/null and b/renderer/test_data/wang_solution_v1_square_sat.png differ diff --git a/renderer/test_wang_square.py b/renderer/test_wang_square.py new file mode 100644 index 0000000..8bcac33 --- /dev/null +++ b/renderer/test_wang_square.py @@ -0,0 +1,455 @@ +"""Tests for the presentation-only Wang square renderer.""" + +from __future__ import annotations + +import copy +import hashlib +import json +from pathlib import Path +import subprocess +import sys + +import numpy as np +from PIL import Image +import pytest + +import wang_square +from wang_square import ( + BACKGROUND_RGB, + DEFAULT_MARGIN, + DEFAULT_PIXELS_PER_CELL, + GRID_RGB, + HOLE_DARK_RGB, + HOLE_LIGHT_RGB, + WangPresentation, + WangSquareRenderError, + build_palette, + compose_wang_square, + load_wang_presentation, + render_wang_square, +) + + +RENDERER_DIR = Path(__file__).resolve().parent +ROOT_DIR = RENDERER_DIR.parent +FIXTURE = ROOT_DIR / "tests/fixtures/wang_solution_v1_square_sat.json" +GOLDEN = RENDERER_DIR / "test_data/wang_solution_v1_square_sat.png" + + +def _fixture_document() -> dict[str, object]: + return json.loads(FIXTURE.read_text(encoding="utf-8")) + + +def _write_document(tmp_path: Path, document: object) -> Path: + path = tmp_path / "solution.json" + path.write_text(json.dumps(document), encoding="utf-8") + return path + + +def _presentation( + *, + edges: tuple[int, int, int, int] = (0, 1, 2, 3), + cells: tuple[int | None, ...] = (0,), + width: int = 1, + height: int = 1, +) -> WangPresentation: + return WangPresentation( + min_x=-3, + min_y=5, + max_x=-3 + width - 1, + max_y=5 + height - 1, + tile_edges=(edges,), + cells=cells, + ) + + +def test_loads_representative_fixture_with_negative_origin_and_holes(): + presentation = load_wang_presentation(FIXTURE) + + assert (presentation.min_x, presentation.min_y) == (-1, 2) + assert (presentation.width, presentation.height) == (4, 3) + assert len(presentation.tile_edges) == 23 + assert len(presentation.cells) == 12 + assert presentation.cells.count(None) == 2 + + +def test_missing_input_has_descriptive_file_error(tmp_path): + with pytest.raises(FileNotFoundError, match="Wang solution file not found"): + load_wang_presentation(tmp_path / "missing.json") + + +def test_rejects_invalid_utf8(tmp_path): + path = tmp_path / "solution.json" + path.write_bytes(b"\xff") + + with pytest.raises(WangSquareRenderError, match="not valid UTF-8"): + load_wang_presentation(path) + + +def test_rejects_invalid_json(tmp_path): + path = tmp_path / "solution.json" + path.write_text("{", encoding="utf-8") + + with pytest.raises(WangSquareRenderError, match="invalid JSON"): + load_wang_presentation(path) + + +def test_rejects_duplicate_members(tmp_path): + path = tmp_path / "solution.json" + path.write_text('{"schema": 1, "schema": 2}', encoding="utf-8") + + with pytest.raises(WangSquareRenderError, match="duplicate member 'schema'"): + load_wang_presentation(path) + + +def test_rejects_nonfinite_numbers(tmp_path): + path = tmp_path / "solution.json" + path.write_text('{"value": NaN}', encoding="utf-8") + + with pytest.raises(WangSquareRenderError, match="non-finite number NaN"): + load_wang_presentation(path) + + +def test_rejects_integer_beyond_python_json_limit(tmp_path): + path = tmp_path / "solution.json" + path.write_text('{"value": ' + "9" * 5000 + "}", encoding="utf-8") + + with pytest.raises(WangSquareRenderError, match="invalid JSON value"): + load_wang_presentation(path) + + +def test_cli_normalizes_json_value_error_without_traceback(tmp_path, capsys): + source = tmp_path / "solution.json" + source.write_text('{"value": ' + "9" * 5000 + "}", encoding="utf-8") + + with pytest.raises(SystemExit) as error: + wang_square.main([str(source), str(tmp_path / "render.png")]) + + captured = capsys.readouterr() + assert error.value.code == 2 + assert "invalid JSON value" in captured.err + assert "Traceback" not in captured.err + + +def test_normalizes_json_recursion_error(tmp_path, monkeypatch): + source = tmp_path / "solution.json" + source.write_text("{}", encoding="utf-8") + + def fail_load(*args, **kwargs): + raise RecursionError("injected nesting limit") + + monkeypatch.setattr(wang_square.json, "load", fail_load) + + with pytest.raises(WangSquareRenderError, match="injected nesting limit"): + load_wang_presentation(source) + + +@pytest.mark.parametrize( + ("mutation", "message"), + [ + (lambda document: document.pop("metadata"), "missing fields: metadata"), + (lambda document: document.update(extra=True), "unknown fields: extra"), + (lambda document: document.update(schema="v2"), "must equal"), + (lambda document: document.update(status="UNSAT"), "must equal"), + (lambda document: document.update(geometry="hex"), "must equal"), + ], +) +def test_rejects_wrong_top_level_contract(tmp_path, mutation, message): + document = _fixture_document() + mutation(document) + + with pytest.raises(WangSquareRenderError, match=message): + load_wang_presentation(_write_document(tmp_path, document)) + + +def test_rejects_reversed_inclusive_bounds(tmp_path): + document = _fixture_document() + document["bounds"]["max_x_inclusive"] = -2 + + with pytest.raises(WangSquareRenderError, match="max_x_inclusive"): + load_wang_presentation(_write_document(tmp_path, document)) + + +def test_rejects_noncanonical_tile_table_position(tmp_path): + document = _fixture_document() + document["tile_table"][1]["tile_id"] = 7 + + with pytest.raises(WangSquareRenderError, match="canonical table position 1"): + load_wang_presentation(_write_document(tmp_path, document)) + + +@pytest.mark.parametrize("bad_color", [-1, True, 1.5]) +def test_rejects_invalid_tile_edge_color(tmp_path, bad_color): + document = _fixture_document() + document["tile_table"][0]["edges"]["N"] = bad_color + + with pytest.raises(WangSquareRenderError, match="edges.N"): + load_wang_presentation(_write_document(tmp_path, document)) + + +def test_rejects_dense_cell_length_mismatch(tmp_path): + document = _fixture_document() + document["cells"].pop() + + with pytest.raises(WangSquareRenderError, match="inclusive bounds area 12"): + load_wang_presentation(_write_document(tmp_path, document)) + + +def test_rejects_absent_tile_reference(tmp_path): + document = _fixture_document() + document["cells"][0] = 23 + + with pytest.raises(WangSquareRenderError, match="absent tile_id 23"): + load_wang_presentation(_write_document(tmp_path, document)) + + +@pytest.mark.parametrize( + "mutation", + [ + lambda document: document["boundary"].pop(), + lambda document: document["boundary"][0].pop("N"), + lambda document: document["boundary"][0].update(N=-1), + ], +) +def test_rejects_malformed_boundary_transport_shape(tmp_path, mutation): + document = _fixture_document() + mutation(document) + + with pytest.raises(WangSquareRenderError, match="boundary"): + load_wang_presentation(_write_document(tmp_path, document)) + + +def test_rejects_nonobject_metadata(tmp_path): + document = _fixture_document() + document["metadata"] = [] + + with pytest.raises(WangSquareRenderError, match="metadata"): + load_wang_presentation(_write_document(tmp_path, document)) + + +def test_palette_is_deterministic_injective_and_avoids_reserved_colors(): + presentation = WangPresentation( + min_x=0, + min_y=0, + max_x=0, + max_y=0, + tile_edges=((100, 3, 42, 3), (999, 100, 0, 42)), + cells=(0,), + ) + + first = build_palette(presentation) + second = build_palette(presentation) + + assert first == second + assert list(first) == [0, 3, 42, 100, 999] + assert len(set(first.values())) == len(first) + assert not set(first.values()) & { + BACKGROUND_RGB, + GRID_RGB, + HOLE_LIGHT_RGB, + HOLE_DARK_RGB, + } + + +def test_compose_uses_dynamic_canvas_size_and_rgb_dtype(): + presentation = load_wang_presentation(FIXTURE) + + canvas = compose_wang_square(presentation, pixels_per_cell=10, margin=3) + + assert canvas.shape == (36, 46, 3) + assert canvas.dtype == np.uint8 + assert tuple(canvas[0, 0]) == BACKGROUND_RGB + + +def test_tile_raster_uses_nesw_nearest_edge_and_stable_ties(): + presentation = _presentation(edges=(10, 20, 30, 40)) + palette = build_palette(presentation) + canvas = compose_wang_square(presentation, pixels_per_cell=8, margin=0) + + assert tuple(canvas[1, 4]) == palette[10] + assert tuple(canvas[4, 6]) == palette[20] + assert tuple(canvas[6, 4]) == palette[30] + assert tuple(canvas[4, 1]) == palette[40] + assert tuple(canvas[3, 3]) == palette[10] + assert tuple(canvas[0, 4]) == GRID_RGB + + +def test_holes_use_neutral_checkerboard_not_logical_palette(): + presentation = _presentation(cells=(None, 0), width=2) + canvas = compose_wang_square(presentation, pixels_per_cell=8, margin=0) + hole_pixels = {tuple(pixel) for row in canvas[:, :8] for pixel in row} + + assert GRID_RGB in hole_pixels + assert HOLE_LIGHT_RGB in hole_pixels + assert HOLE_DARK_RGB in hole_pixels + assert not hole_pixels & set(build_palette(presentation).values()) + + +def test_boundary_and_metadata_are_pixel_neutral(tmp_path): + original = _fixture_document() + changed = copy.deepcopy(original) + changed["metadata"] = {"display": {"anything": [1, 2, 3]}} + changed["boundary"] = [None] * len(changed["boundary"]) + for index, tile_id in enumerate(changed["cells"]): + if tile_id is not None: + changed["boundary"][index] = { + "N": 999, + "E": 998, + "S": 997, + "W": 996, + } + + first_path = tmp_path / "first.json" + second_path = tmp_path / "second.json" + first_path.write_text(json.dumps(original), encoding="utf-8") + second_path.write_text(json.dumps(changed), encoding="utf-8") + + assert np.array_equal( + compose_wang_square(load_wang_presentation(first_path)), + compose_wang_square(load_wang_presentation(second_path)), + ) + + +@pytest.mark.parametrize( + ("pixels_per_cell", "margin", "message"), + [ + (7, 0, "pixels_per_cell"), + (513, 0, "pixels_per_cell"), + (True, 0, "pixels_per_cell"), + (8, -1, "margin"), + (8, 4097, "margin"), + (8, 1.5, "margin"), + ], +) +def test_rejects_invalid_render_dimensions( + pixels_per_cell, margin, message +): + with pytest.raises(WangSquareRenderError, match=message): + compose_wang_square( + _presentation(), + pixels_per_cell=pixels_per_cell, + margin=margin, + ) + + +def test_rejects_oversized_canvas_before_allocation(): + presentation = _presentation(cells=(0,) * 33, width=33) + + with pytest.raises(WangSquareRenderError, match="canvas side exceeds"): + compose_wang_square( + presentation, + pixels_per_cell=512, + margin=0, + ) + + +def test_fixture_render_matches_decoded_golden_pixels(tmp_path): + output = tmp_path / "render.png" + render_wang_square(FIXTURE, output) + + with Image.open(output) as actual, Image.open(GOLDEN) as expected: + actual.load() + expected.load() + assert actual.mode == expected.mode == "RGB" + assert actual.size == expected.size == (144, 112) + assert actual.tobytes() == expected.tobytes() + assert hashlib.sha256(actual.tobytes()).hexdigest() == ( + "48f0ed543c0c2d91ff26b12db592d2a7ecb737bd5694af4930c4296c99512d6d" + ) + + +def test_render_installs_complete_png(tmp_path): + output = tmp_path / "render.png" + render_wang_square(FIXTURE, output, pixels_per_cell=8, margin=1) + + with Image.open(output) as image: + image.verify() + assert not list(tmp_path.glob(".render.png.*.png")) + + +def test_failed_replace_preserves_output_and_removes_temporary( + tmp_path, monkeypatch +): + output = tmp_path / "render.png" + output.write_bytes(b"previous output") + + def fail_replace(source, destination): + raise OSError("injected replace failure") + + monkeypatch.setattr(wang_square.os, "replace", fail_replace) + + with pytest.raises(WangSquareRenderError, match="replace failure"): + render_wang_square(FIXTURE, output) + + assert output.read_bytes() == b"previous output" + assert not list(tmp_path.glob(".render.png.*.png")) + + +def test_invalid_input_never_changes_existing_output(tmp_path): + output = tmp_path / "render.png" + output.write_bytes(b"previous output") + invalid = tmp_path / "invalid.json" + invalid.write_text("{}", encoding="utf-8") + + with pytest.raises(WangSquareRenderError): + render_wang_square(invalid, output) + + assert output.read_bytes() == b"previous output" + + +def test_cli_renders_fixture(tmp_path): + output = tmp_path / "render.png" + wang_square.main( + [str(FIXTURE), str(output), "--pixels-per-cell", "8", "--margin", "0"] + ) + + with Image.open(output) as image: + assert image.mode == "RGB" + assert image.size == (32, 24) + + +def test_cli_reports_invalid_input_without_traceback(tmp_path, capsys): + output = tmp_path / "render.png" + + with pytest.raises(SystemExit) as error: + wang_square.main([str(tmp_path / "missing.json"), str(output)]) + + assert error.value.code == 2 + assert "Wang solution file not found" in capsys.readouterr().err + assert not output.exists() + + +def test_consumer_process_does_not_import_core_z3_or_load_libwang(tmp_path): + output = tmp_path / "render.png" + script = f""" +import importlib.util +from pathlib import Path +import sys + +module_path = Path({str(RENDERER_DIR / 'wang_square.py')!r}) +spec = importlib.util.spec_from_file_location('isolated_wang_square', module_path) +module = importlib.util.module_from_spec(spec) +sys.modules[spec.name] = module +spec.loader.exec_module(module) +module.render_wang_square({str(FIXTURE)!r}, {str(output)!r}) +for forbidden in ('z3', 'formats', 'model', 'native', 'oracles'): + 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, "-B", "-c", script], + cwd=tmp_path, + text=True, + capture_output=True, + check=False, + ) + + assert completed.returncode == 0, completed.stderr + assert output.exists() + + +def test_legacy_modules_are_not_imported_by_wang_backend(): + assert "Palette" not in wang_square.__dict__ + assert "RenderingPipeline" not in wang_square.__dict__ diff --git a/renderer/wang_square.py b/renderer/wang_square.py new file mode 100644 index 0000000..ce7bf9e --- /dev/null +++ b/renderer/wang_square.py @@ -0,0 +1,538 @@ +"""Presentation-only renderer for ``wang-solution-v1`` square tilings. + +This module deliberately does not verify Wang adjacency or boundary semantics. +It accepts the structural fields needed for presentation, projects away +``boundary`` and ``metadata``, and renders the selected tile edge colors. +Successful rendering is not a correctness claim. +""" + +from __future__ import annotations + +import argparse +from dataclasses import dataclass +import json +import os +from pathlib import Path +import tempfile +from typing import Final + +import numpy as np +from PIL import Image + + +SCHEMA_NAME: Final = "wang-solution-v1" +GEOMETRY: Final = "square" +STATUS: Final = "SAT" +DIRECTIONS: Final = ("N", "E", "S", "W") + +DEFAULT_PIXELS_PER_CELL: Final = 32 +DEFAULT_MARGIN: Final = 8 +MIN_PIXELS_PER_CELL: Final = 8 +MAX_PIXELS_PER_CELL: Final = 512 +MAX_MARGIN: Final = 4096 +MAX_CANVAS_SIDE: Final = 16384 +MAX_CANVAS_PIXELS: Final = 64 * 1024 * 1024 + +BACKGROUND_RGB: Final = (248, 248, 244) +GRID_RGB: Final = (32, 35, 42) +HOLE_LIGHT_RGB: Final = (214, 218, 224) +HOLE_DARK_RGB: Final = (166, 173, 184) +_RESERVED_RGB: Final = frozenset( + {BACKGROUND_RGB, GRID_RGB, HOLE_LIGHT_RGB, HOLE_DARK_RGB} +) +_RGB_SPACE_SIZE: Final = 1 << 24 +_PALETTE_OFFSET: Final = 0x51ED27 +_PALETTE_STEP: Final = 0x9E3779 # Odd, hence invertible modulo 2**24. + +_TOP_LEVEL_FIELDS: Final = frozenset( + { + "schema", + "status", + "geometry", + "bounds", + "tile_table", + "cells", + "boundary", + "metadata", + } +) +_BOUND_FIELDS: Final = frozenset( + { + "min_x_inclusive", + "min_y_inclusive", + "max_x_inclusive", + "max_y_inclusive", + } +) + + +class WangSquareRenderError(ValueError): + """Raised when an input cannot be presented as a v1 square image.""" + + +@dataclass(frozen=True, slots=True) +class WangPresentation: + """Immutable projection of only the fields that determine the pixels.""" + + min_x: int + min_y: int + max_x: int + max_y: int + tile_edges: tuple[tuple[int, int, int, int], ...] + cells: tuple[int | None, ...] + + @property + def width(self) -> int: + return self.max_x - self.min_x + 1 + + @property + def height(self) -> int: + return self.max_y - self.min_y + 1 + + +def _fail(path: str, message: str) -> None: + raise WangSquareRenderError(f"{path}: {message}") + + +def _require_object(value: object, path: str) -> dict[str, object]: + if type(value) is not dict: + _fail(path, "must be an object") + return value + + +def _require_array(value: object, path: str) -> list[object]: + if type(value) is not list: + _fail(path, "must be an array") + return value + + +def _require_integer(value: object, path: str, *, nonnegative: bool) -> int: + if type(value) is not int: + _fail(path, "must be an integer") + if nonnegative and value < 0: + _fail(path, "must be nonnegative") + return value + + +def _require_exact_fields( + value: dict[str, object], expected: frozenset[str], path: str +) -> None: + actual = frozenset(value) + missing = sorted(expected - actual) + extra = sorted(actual - expected) + if missing: + _fail(path, f"missing fields: {', '.join(missing)}") + if extra: + _fail(path, f"unknown fields: {', '.join(extra)}") + + +def _reject_duplicate_members( + pairs: list[tuple[str, object]], +) -> dict[str, object]: + result: dict[str, object] = {} + for key, value in pairs: + if key in result: + raise WangSquareRenderError( + f"JSON object contains duplicate member {key!r}" + ) + result[key] = value + return result + + +def _reject_nonfinite_constant(value: str) -> None: + raise WangSquareRenderError( + f"JSON document contains non-finite number {value}" + ) + + +def _validate_optional_edges(value: object, path: str) -> None: + if value is None: + return + edges = _require_object(value, path) + _require_exact_fields(edges, frozenset(DIRECTIONS), path) + for direction in DIRECTIONS: + color = edges[direction] + if color is not None: + _require_integer( + color, + f"{path}.{direction}", + nonnegative=True, + ) + + +def _project_wang_presentation(document: object) -> WangPresentation: + root = _require_object(document, "$") + _require_exact_fields(root, _TOP_LEVEL_FIELDS, "$") + + for field, expected in ( + ("schema", SCHEMA_NAME), + ("status", STATUS), + ("geometry", GEOMETRY), + ): + if type(root[field]) is not str or root[field] != expected: + _fail(f"$.{field}", f"must equal {expected!r}") + + bounds = _require_object(root["bounds"], "$.bounds") + _require_exact_fields(bounds, _BOUND_FIELDS, "$.bounds") + min_x = _require_integer( + bounds["min_x_inclusive"], + "$.bounds.min_x_inclusive", + nonnegative=False, + ) + min_y = _require_integer( + bounds["min_y_inclusive"], + "$.bounds.min_y_inclusive", + nonnegative=False, + ) + max_x = _require_integer( + bounds["max_x_inclusive"], + "$.bounds.max_x_inclusive", + nonnegative=False, + ) + max_y = _require_integer( + bounds["max_y_inclusive"], + "$.bounds.max_y_inclusive", + nonnegative=False, + ) + if max_x < min_x: + _fail("$.bounds", "max_x_inclusive must be at least min_x_inclusive") + if max_y < min_y: + _fail("$.bounds", "max_y_inclusive must be at least min_y_inclusive") + + raw_table = _require_array(root["tile_table"], "$.tile_table") + if not raw_table: + _fail("$.tile_table", "must not be empty") + tile_edges: list[tuple[int, int, int, int]] = [] + for expected_id, raw_tile in enumerate(raw_table): + tile_path = f"$.tile_table[{expected_id}]" + tile = _require_object(raw_tile, tile_path) + _require_exact_fields(tile, frozenset({"tile_id", "edges"}), tile_path) + tile_id = _require_integer( + tile["tile_id"], f"{tile_path}.tile_id", nonnegative=True + ) + if tile_id != expected_id: + _fail( + f"{tile_path}.tile_id", + f"must equal its canonical table position {expected_id}", + ) + raw_edges = _require_object(tile["edges"], f"{tile_path}.edges") + _require_exact_fields( + raw_edges, frozenset(DIRECTIONS), f"{tile_path}.edges" + ) + tile_edges.append( + tuple( + _require_integer( + raw_edges[direction], + f"{tile_path}.edges.{direction}", + nonnegative=True, + ) + for direction in DIRECTIONS + ) + ) + + width = max_x - min_x + 1 + height = max_y - min_y + 1 + area = width * height + raw_cells = _require_array(root["cells"], "$.cells") + if len(raw_cells) != area: + _fail("$.cells", f"length must equal inclusive bounds area {area}") + cells: list[int | None] = [] + for index, value in enumerate(raw_cells): + if value is None: + cells.append(None) + continue + tile_id = _require_integer( + value, f"$.cells[{index}]", nonnegative=True + ) + if tile_id >= len(tile_edges): + _fail(f"$.cells[{index}]", f"references absent tile_id {tile_id}") + cells.append(tile_id) + + # Boundary data is checked only for v1 transport shape. It is not compared + # with cells or edges, and it never reaches the presentation projection. + boundary = _require_array(root["boundary"], "$.boundary") + if len(boundary) != area: + _fail("$.boundary", f"length must equal inclusive bounds area {area}") + for index, sides in enumerate(boundary): + _validate_optional_edges(sides, f"$.boundary[{index}]") + + _require_object(root["metadata"], "$.metadata") + return WangPresentation( + min_x=min_x, + min_y=min_y, + max_x=max_x, + max_y=max_y, + tile_edges=tuple(tile_edges), + cells=tuple(cells), + ) + + +def load_wang_presentation(path: str | Path) -> WangPresentation: + """Load strict JSON and return only the fields that determine pixels.""" + source = Path(path) + try: + with source.open(encoding="utf-8") as stream: + document = json.load( + stream, + object_pairs_hook=_reject_duplicate_members, + parse_constant=_reject_nonfinite_constant, + ) + except FileNotFoundError as error: + raise FileNotFoundError(f"Wang solution file not found: {source!s}") from error + except UnicodeDecodeError as error: + raise WangSquareRenderError( + f"Wang solution is not valid UTF-8: {error}" + ) from error + except json.JSONDecodeError as error: + raise WangSquareRenderError( + f"invalid JSON at line {error.lineno}, column {error.colno}: {error.msg}" + ) from error + except WangSquareRenderError: + raise + except (ValueError, RecursionError) as error: + raise WangSquareRenderError( + f"invalid JSON value: {error}" + ) from error + except OSError as error: + raise WangSquareRenderError( + f"cannot read Wang solution {source!s}: {error}" + ) from error + return _project_wang_presentation(document) + + +def build_palette( + presentation: WangPresentation, +) -> dict[int, tuple[int, int, int]]: + """Assign a deterministic, injective RGB color to each logical color.""" + if not isinstance(presentation, WangPresentation): + raise TypeError("presentation must be a WangPresentation") + logical_colors = sorted( + {color for edges in presentation.tile_edges for color in edges} + ) + available = _RGB_SPACE_SIZE - len(_RESERVED_RGB) + if len(logical_colors) > available: + raise WangSquareRenderError( + f"logical color count exceeds the {available} available RGB values" + ) + + palette: dict[int, tuple[int, int, int]] = {} + candidate_index = 0 + for logical_color in logical_colors: + while candidate_index < _RGB_SPACE_SIZE: + packed = ( + _PALETTE_OFFSET + candidate_index * _PALETTE_STEP + ) & 0xFFFFFF + candidate_index += 1 + rgb = (packed >> 16, (packed >> 8) & 0xFF, packed & 0xFF) + if rgb not in _RESERVED_RGB: + palette[logical_color] = rgb + break + else: # Defensive: the cardinality check above makes this unreachable. + raise WangSquareRenderError("RGB palette space exhausted") + return palette + + +def _render_integer( + value: object, + name: str, + *, + minimum: int, + maximum: int, +) -> int: + if type(value) is not int: + raise WangSquareRenderError(f"{name} must be an integer") + if not minimum <= value <= maximum: + raise WangSquareRenderError( + f"{name} must be in [{minimum}, {maximum}], got {value}" + ) + return value + + +def _canvas_dimensions( + presentation: WangPresentation, + pixels_per_cell: object, + margin: object, +) -> tuple[int, int, 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 + ) + width = presentation.width * ppc + 2 * checked_margin + height = presentation.height * ppc + 2 * checked_margin + if width > MAX_CANVAS_SIDE or height > MAX_CANVAS_SIDE: + raise WangSquareRenderError( + f"canvas side exceeds limit {MAX_CANVAS_SIDE}: {width}x{height}" + ) + if width * height > MAX_CANVAS_PIXELS: + raise WangSquareRenderError( + f"canvas area exceeds limit {MAX_CANVAS_PIXELS}: {width}x{height}" + ) + return width, height, ppc, checked_margin + + +def _tile_asset( + edges: tuple[int, int, int, int], + palette: dict[int, tuple[int, int, int]], + pixels_per_cell: int, +) -> np.ndarray: + coordinates = np.arange(pixels_per_cell, dtype=np.int32) + x = np.broadcast_to(coordinates, (pixels_per_cell, pixels_per_cell)) + y = x.T + last = pixels_per_cell - 1 + distances = np.stack((y, last - x, last - y, x), axis=0) + # np.argmin uses the first minimum, fixing diagonal/centre ties as N,E,S,W. + direction = np.argmin(distances, axis=0) + edge_rgb = np.asarray([palette[color] for color in edges], dtype=np.uint8) + asset = edge_rgb[direction] + asset[0, :, :] = GRID_RGB + asset[-1, :, :] = GRID_RGB + asset[:, 0, :] = GRID_RGB + asset[:, -1, :] = GRID_RGB + return asset + + +def _hole_asset(pixels_per_cell: int) -> np.ndarray: + coordinates = np.arange(pixels_per_cell, dtype=np.int32) + x = np.broadcast_to(coordinates, (pixels_per_cell, pixels_per_cell)) + y = x.T + checker_size = max(2, pixels_per_cell // 4) + checker = ((x // checker_size) + (y // checker_size)) % 2 + colors = np.asarray((HOLE_LIGHT_RGB, HOLE_DARK_RGB), dtype=np.uint8) + asset = colors[checker] + asset[0, :, :] = GRID_RGB + asset[-1, :, :] = GRID_RGB + asset[:, 0, :] = GRID_RGB + asset[:, -1, :] = GRID_RGB + return asset + + +def compose_wang_square( + presentation: WangPresentation, + *, + pixels_per_cell: int = DEFAULT_PIXELS_PER_CELL, + margin: int = DEFAULT_MARGIN, +) -> np.ndarray: + """Compose one deterministic RGB canvas without writing it.""" + if not isinstance(presentation, WangPresentation): + raise TypeError("presentation must be a WangPresentation") + width, height, ppc, checked_margin = _canvas_dimensions( + presentation, pixels_per_cell, margin + ) + canvas = np.empty((height, width, 3), dtype=np.uint8) + canvas[:, :, :] = BACKGROUND_RGB + palette = build_palette(presentation) + tile_assets: dict[int, np.ndarray] = {} + hole = _hole_asset(ppc) + + for index, tile_id in enumerate(presentation.cells): + local_x = index % presentation.width + local_y = index // presentation.width + x = checked_margin + local_x * ppc + y = checked_margin + local_y * ppc + if tile_id is None: + asset = hole + else: + asset = tile_assets.get(tile_id) + if asset is None: + asset = _tile_asset( + presentation.tile_edges[tile_id], palette, ppc + ) + tile_assets[tile_id] = asset + canvas[y : y + ppc, x : x + ppc] = asset + return canvas + + +def _save_png_atomic(canvas: np.ndarray, output_path: str | Path) -> None: + destination = Path(output_path) + temporary: Path | None = None + try: + with tempfile.NamedTemporaryFile( + mode="wb", + dir=destination.parent, + prefix=f".{destination.name}.", + suffix=".png", + delete=False, + ) as stream: + temporary = Path(stream.name) + Image.fromarray(canvas).save( + temporary, + format="PNG", + optimize=False, + compress_level=9, + ) + os.replace(temporary, destination) + temporary = None + except (OSError, ValueError) as error: + raise WangSquareRenderError( + f"cannot save output PNG to {destination!s}: {error}" + ) from error + finally: + if temporary is not None: + try: + temporary.unlink() + except FileNotFoundError: + pass + + +def render_wang_square( + input_path: str | Path, + output_path: str | Path, + *, + pixels_per_cell: int = DEFAULT_PIXELS_PER_CELL, + margin: int = DEFAULT_MARGIN, +) -> None: + """Load a v1 presentation, compose it, and atomically write one PNG.""" + presentation = load_wang_presentation(input_path) + canvas = compose_wang_square( + presentation, + pixels_per_cell=pixels_per_cell, + margin=margin, + ) + _save_png_atomic(canvas, output_path) + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Render a wang-solution-v1 square tiling as a diagnostic PNG" + ) + parser.add_argument("input", help="path to a wang-solution-v1 JSON file") + parser.add_argument("output", help="path to the output PNG") + parser.add_argument( + "--pixels-per-cell", + type=int, + default=DEFAULT_PIXELS_PER_CELL, + help=( + "square cell size in pixels " + f"(default: {DEFAULT_PIXELS_PER_CELL})" + ), + ) + parser.add_argument( + "--margin", + type=int, + default=DEFAULT_MARGIN, + help=f"canvas margin in pixels (default: {DEFAULT_MARGIN})", + ) + return parser + + +def main(argv: list[str] | None = None) -> None: + parser = _parser() + args = parser.parse_args(argv) + try: + render_wang_square( + args.input, + args.output, + pixels_per_cell=args.pixels_per_cell, + margin=args.margin, + ) + except (FileNotFoundError, WangSquareRenderError) as error: + parser.error(str(error)) + + +if __name__ == "__main__": + main()