File size: 5,255 Bytes
37e3d5a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
#!/usr/bin/env python3
"""Tests for analyze_texture.py — finish classification + recipe. Pure stdlib, zero token.
Run: python3 forge/tests/test_analyze_texture.py
"""
import struct
import sys
import tempfile
import unittest
import zlib
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "stage1_intake"))
from analyze_texture import RECIPES, analyze  # noqa: E402

PNG_SIG = b"\x89PNG\r\n\x1a\n"


def write_png(path, w, h, fn):
    def chunk(t, d):
        return struct.pack(">I", len(d)) + t + d + struct.pack(">I", zlib.crc32(t + d) & 0xFFFFFFFF)
    raw = bytearray()
    for y in range(h):
        raw.append(0)
        for x in range(w):
            raw += bytes(fn(x, y))
    ihdr = struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0)
    path.write_bytes(PNG_SIG + chunk(b"IHDR", ihdr) + chunk(b"IDAT", zlib.compress(bytes(raw), 9)) + chunk(b"IEND", b""))


def cl(v):
    return max(0, min(255, int(v)))


class AnalyzeTextureTest(unittest.TestCase):
    def setUp(self):
        self.d = Path(tempfile.mkdtemp())
        self.S = 160

    def _mk(self, name, fn):
        p = self.d / name
        write_png(p, self.S, self.S, fn)
        return p

    def test_pigment_dominant_doppler_is_candy_coat(self):
        # doppler-like blue->purple gradient + smoky mottle, NO chrome specular (colour survives
        # into mid-tones) -> candy-coat (dielectric). This is the M9-Doppler case that previously
        # mis-classed as high-metalness gem-metal and rendered blue (env stole the hue).
        def fn(x, y):
            noise = ((x * 5 + y * 9) % 23) - 11  # smoke variance
            return (cl(20 + x * 1.2 + noise), cl(25 + noise), cl(130 + noise))
        r = analyze(self._mk("candy.png", fn))
        self.assertEqual(r["finishClass"], "candy-coat")
        self.assertEqual(r["recipe"]["procedural"], "gradient-smoke")
        self.assertLessEqual(r["recipe"]["metalness"], 0.4)   # dielectric-led → hue survives
        self.assertLessEqual(r["recipe"]["envMapIntensity"], 0.9)
        self.assertEqual(len(r["palette"]), 5)
        # blue-leaning stops (B > R) flagged for hue-survival with a magenta-lean suggestion
        self.assertTrue(r["paletteHueRisk"], "expected blue-collapse flag on blue-leaning stops")
        self.assertEqual(r["paletteHueRisk"][0]["hueRisk"], "blue-collapse")

    def test_chrome_specular_doppler_is_gem_metal(self):
        # same chromatic gradient but WITH bright chrome specular hotspots -> genuinely metallic
        # doppler (gem-metal, high metalness). Bright hotspots on ~6% of pixels (lum > 235).
        def fn(x, y):
            noise = ((x * 5 + y * 9) % 23) - 11
            if (x + y) % 17 == 0:
                return (250, 250, 255)  # chrome specular hotspot
            return (cl(20 + x * 1.2 + noise), cl(25 + noise), cl(130 + noise))
        r = analyze(self._mk("gem.png", fn))
        self.assertEqual(r["finishClass"], "gem-metal")
        self.assertGreaterEqual(r["recipe"]["metalness"], 0.6)

    def test_flat_saturated_is_painted_metal(self):
        img = self._mk("paint.png", lambda x, y: (230, 150, 50))
        r = analyze(img)
        self.assertEqual(r["finishClass"], "painted-metal")
        self.assertAlmostEqual(r["recipe"]["clearcoat"], 1.0)

    def test_mottled_grey_is_worn_composite(self):
        # dark neutral grey with isotropic mottle -> worn composite
        def fn(x, y):
            v = 55 + ((x * 7 + y * 13) % 37) - 18
            return (cl(v), cl(v), cl(v + 2))
        r = analyze(self._mk("worn.png", fn))
        self.assertEqual(r["finishClass"], "worn-composite")
        self.assertAlmostEqual(r["recipe"]["roughness"], 0.9)

    def test_directional_streaks_is_brushed_steel(self):
        # coarse bright horizontal grain (bands vary in Y, survive downsample), neutral -> brushed
        def fn(x, y):
            v = 150 + (38 if (y // 8) % 2 == 0 else -38)
            return (cl(v), cl(v), cl(v))
        r = analyze(self._mk("brushed.png", fn))
        self.assertEqual(r["finishClass"], "brushed-steel")
        self.assertAlmostEqual(r["recipe"]["metalness"], 1.0)
        self.assertAlmostEqual(r["recipe"]["anisotropy"], 1.0)

    def test_apply_to_material_writes_recipe(self):
        from analyze_texture import apply_to_material
        img = self._mk("paint2.png", lambda x, y: (230, 150, 50))
        result = analyze(img)
        mat = {"id": "frame", "roughness": {"base": 0.3, "variation": 0.1}}
        apply_to_material(mat, result)
        self.assertEqual(mat["finishClass"], "painted-metal")
        self.assertEqual(mat["roughness"]["base"], result["recipe"]["roughness"])  # layer shape kept
        self.assertEqual(mat["roughness"]["variation"], 0.1)
        self.assertIn("texturePalette", mat)
        self.assertEqual(mat["clearcoat"]["base"], result["recipe"]["clearcoat"])

    def test_all_recipes_have_required_scalars(self):
        keys = {"metalness", "roughness", "clearcoat", "clearcoatRoughness", "transmission",
                "ior", "envMapIntensity", "anisotropy", "procedural"}
        for name, rec in RECIPES.items():
            self.assertTrue(keys <= set(rec), f"{name} missing keys")


if __name__ == "__main__":
    unittest.main(verbosity=2)