Each generated prop used one material per part (body, accent, metal...). The model pipeline now packs a prop's palette into one power-of-two colour atlas plus a matching metallic/roughness atlas and emits a single material: - atlas_for(): 8x8 cells per palette role, colours in sRGB, roughness in the G and metallic in the B channel of the second atlas; NEAREST sampling keeps cells flat. - build_glb(): every part merges into one primitive with constant per-vertex atlas UVs; the material uses baseColorTexture + metallicRoughnessTexture with factors 1.0. - audit_glb(): fails unless a prop has exactly one material, both atlas textures, two images, and TEXCOORD_0 on every primitive. - Removed the now-unused sRGB->linear factor helpers; colour travels in the texture. Verification: headless 181 -> 182 (new "one material per prop scene" test), Python tooling 53/53 (atlas determinism/size/UV/palette-override tests replace the old factor tests), model audit OK for all six props, screenshots regenerated and screen_designs.py compare still 6 good / 0 bad. Version 0.3.1.
271 lines
11 KiB
Python
271 lines
11 KiB
Python
import json
|
|
import struct
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
from PIL import Image
|
|
|
|
import model_pipeline
|
|
|
|
|
|
def parse_glb(payload: bytes):
|
|
magic, version, length = struct.unpack_from("<III", payload, 0)
|
|
json_len, json_type = struct.unpack_from("<II", payload, 12)
|
|
gltf = json.loads(payload[20:20 + json_len].decode("utf-8"))
|
|
bin_offset = 20 + json_len
|
|
bin_len, bin_type = struct.unpack_from("<II", payload, bin_offset)
|
|
binary = payload[bin_offset + 8:bin_offset + 8 + bin_len]
|
|
return {
|
|
"magic": magic,
|
|
"version": version,
|
|
"length": length,
|
|
"json_type": json_type,
|
|
"bin_type": bin_type,
|
|
"bin_len": bin_len,
|
|
"binary": binary,
|
|
"gltf": gltf,
|
|
}
|
|
|
|
|
|
def prop(name):
|
|
return model_pipeline.DIRECTORY_BY_NAME[name]
|
|
|
|
|
|
class CatalogueTests(unittest.TestCase):
|
|
def test_props_are_well_formed(self):
|
|
names = [p["name"] for p in model_pipeline.PROPS]
|
|
self.assertEqual(len(names), len(set(names)),
|
|
"prop names must be unique")
|
|
for entry in model_pipeline.PROPS:
|
|
self.assertTrue(entry["concept"].strip())
|
|
self.assertTrue(entry["parts"])
|
|
for part in entry["parts"]:
|
|
self.assertIn(part["shape"], ("box", "cylinder"))
|
|
self.assertEqual(len(part["at"]), 3)
|
|
self.assertIn(part["role"], model_pipeline.PALETTE)
|
|
if part["shape"] == "box":
|
|
self.assertEqual(len(part["size"]), 3)
|
|
self.assertTrue(all(value > 0 for value in part["size"]))
|
|
else:
|
|
self.assertGreater(part["radius"], 0)
|
|
self.assertGreater(part["height"], 0)
|
|
|
|
def test_every_prop_has_triangles(self):
|
|
for entry in model_pipeline.PROPS:
|
|
mesh = model_pipeline.build_prop_mesh(entry)
|
|
self.assertGreater(mesh.triangle_count(), 0, entry["name"])
|
|
self.assertLess(mesh.triangle_count(), 4000, entry["name"])
|
|
self.assertEqual(len(mesh.positions), len(mesh.normals))
|
|
|
|
|
|
class MeshWindingTests(unittest.TestCase):
|
|
def test_box_face_normals_point_outward(self):
|
|
mesh = model_pipeline.MeshData()
|
|
model_pipeline._add_box(mesh, (2.0, 2.0, 2.0), (0.0, 0.0, 0.0))
|
|
for tri in range(0, len(mesh.indices), 3):
|
|
a, b, c = (mesh.positions[mesh.indices[tri + i]] for i in range(3))
|
|
normal = mesh.normals[mesh.indices[tri]]
|
|
centroid = tuple((a[i] + b[i] + c[i]) / 3.0 for i in range(3))
|
|
self.assertGreater(
|
|
model_pipeline._dot(normal, centroid), 0.0,
|
|
"box face normal must point away from the centre")
|
|
|
|
def test_cylinder_caps_face_up_and_down(self):
|
|
mesh = model_pipeline.MeshData()
|
|
model_pipeline._add_cylinder(mesh, 0.5, 1.0, (0.0, 0.5, 0.0), 8)
|
|
ups = [n for n in mesh.normals if n[1] > 0.9]
|
|
downs = [n for n in mesh.normals if n[1] < -0.9]
|
|
self.assertTrue(ups, "cylinder needs an upward cap")
|
|
self.assertTrue(downs, "cylinder needs a downward cap")
|
|
|
|
|
|
class GlbTests(unittest.TestCase):
|
|
def test_glb_container_and_structure(self):
|
|
payload = model_pipeline.build_glb(prop("bench"))
|
|
parsed = parse_glb(payload)
|
|
self.assertEqual(parsed["magic"], 0x46546C67)
|
|
self.assertEqual(parsed["version"], 2)
|
|
self.assertEqual(parsed["length"], len(payload))
|
|
self.assertEqual(parsed["json_type"], 0x4E4F534A)
|
|
self.assertEqual(parsed["bin_type"], 0x004E4942)
|
|
self.assertEqual(len(payload) % 4, 0)
|
|
gltf = parsed["gltf"]
|
|
self.assertEqual(gltf["asset"]["version"], "2.0")
|
|
self.assertEqual(len(gltf["scenes"]), 1)
|
|
primitives = gltf["meshes"][0]["primitives"]
|
|
self.assertEqual(len(primitives), 1,
|
|
"parts share one primitive (one material)")
|
|
self.assertEqual(len(gltf["materials"]), 1,
|
|
"the prop draws with a single material")
|
|
for primitive in primitives:
|
|
self.assertIn("POSITION", primitive["attributes"])
|
|
self.assertIn("NORMAL", primitive["attributes"])
|
|
self.assertIn("TEXCOORD_0", primitive["attributes"],
|
|
"atlas UVs are required")
|
|
self.assertIsNotNone(primitive["indices"])
|
|
self.assertEqual(primitive["material"], 0)
|
|
for accessor in gltf["accessors"]:
|
|
self.assertIn(accessor["bufferView"],
|
|
range(len(gltf["bufferViews"])))
|
|
self.assertGreater(accessor["count"], 0)
|
|
self.assertLessEqual(gltf["buffers"][0]["byteLength"],
|
|
len(parsed["binary"]))
|
|
|
|
def test_glb_is_deterministic(self):
|
|
first = model_pipeline.build_glb(prop("bollard"))
|
|
second = model_pipeline.build_glb(prop("bollard"))
|
|
self.assertEqual(first, second)
|
|
|
|
def test_palette_override_changes_the_atlas_colour(self):
|
|
palette = {role: list(rgb)
|
|
for role, rgb in model_pipeline.PALETTE.items()}
|
|
palette["metal"] = [0.9, 0.1, 0.1]
|
|
atlas = model_pipeline.atlas_for(prop("bollard"), palette)
|
|
self.assertIn((0.9, 0.1, 0.1), atlas["colours"].values())
|
|
default = model_pipeline.build_glb(prop("bollard"))
|
|
overridden = model_pipeline.build_glb(prop("bollard"), palette)
|
|
self.assertNotEqual(default, overridden,
|
|
"the atlas texture carries the palette")
|
|
|
|
def test_material_uses_both_atlas_textures(self):
|
|
gltf = parse_glb(model_pipeline.build_glb(prop("bollard")))["gltf"]
|
|
pbr = gltf["materials"][0]["pbrMetallicRoughness"]
|
|
self.assertEqual(pbr["metallicFactor"], 1.0)
|
|
self.assertEqual(pbr["roughnessFactor"], 1.0)
|
|
self.assertIn("baseColorTexture", pbr)
|
|
self.assertIn("metallicRoughnessTexture", pbr)
|
|
self.assertEqual(len(gltf["images"]), 2)
|
|
self.assertEqual(len(gltf["textures"]), 2)
|
|
for image in gltf["images"]:
|
|
self.assertEqual(image["mimeType"], "image/png")
|
|
self.assertEqual(gltf["samplers"][0]["magFilter"], 9728,
|
|
"nearest sampling keeps palette cells flat")
|
|
|
|
def test_atlas_is_deterministic_and_power_of_two(self):
|
|
first = model_pipeline.atlas_for(prop("planter_box"))
|
|
second = model_pipeline.atlas_for(prop("planter_box"))
|
|
self.assertEqual(first["base_png"], second["base_png"])
|
|
size = first["size"]
|
|
self.assertEqual(size & (size - 1), 0, "atlas size is a power of two")
|
|
for u, v in first["role_uv"].values():
|
|
self.assertTrue(0.0 < u < 1.0 and 0.0 < v < 1.0,
|
|
"UVs stay on the texture")
|
|
self.assertEqual(len(first["roles"]), len(set(first["roles"])))
|
|
|
|
def test_indices_stay_inside_their_primitive(self):
|
|
payload = model_pipeline.build_glb(prop("planter_box"))
|
|
parsed = parse_glb(payload)
|
|
gltf = parsed["gltf"]
|
|
binary = parsed["binary"]
|
|
for primitive in gltf["meshes"][0]["primitives"]:
|
|
position_accessor = gltf["accessors"][primitive["attributes"]["POSITION"]]
|
|
index_accessor = gltf["accessors"][primitive["indices"]]
|
|
view = gltf["bufferViews"][index_accessor["bufferView"]]
|
|
raw = binary[view["byteOffset"] :view["byteOffset"] + view["byteLength"]]
|
|
indices = struct.unpack(f"<{len(raw) // 2}H", raw)
|
|
self.assertLess(max(indices), position_accessor["count"])
|
|
self.assertEqual(index_accessor["count"], len(indices))
|
|
|
|
|
|
class AuditTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temp_dir = tempfile.TemporaryDirectory()
|
|
self.root = Path(self.temp_dir.name)
|
|
|
|
def tearDown(self):
|
|
self.temp_dir.cleanup()
|
|
|
|
def test_audit_accepts_a_written_file(self):
|
|
path = self.root / "bench.glb"
|
|
path.write_bytes(model_pipeline.build_glb(prop("bench")))
|
|
result = model_pipeline.audit_glb(path)
|
|
self.assertTrue(result["ok"], result["issues"])
|
|
self.assertGreater(result["triangles"], 0)
|
|
|
|
def test_audit_reports_missing_file(self):
|
|
result = model_pipeline.audit_glb(self.root / "nope.glb")
|
|
self.assertFalse(result["ok"])
|
|
self.assertIn("missing file", result["issues"])
|
|
|
|
def test_audit_reports_truncated_file(self):
|
|
path = self.root / "cut.glb"
|
|
path.write_bytes(model_pipeline.build_glb(prop("bench"))[:40])
|
|
result = model_pipeline.audit_glb(path)
|
|
self.assertFalse(result["ok"])
|
|
self.assertTrue(result["issues"])
|
|
|
|
def test_build_writes_into_the_models_directory(self):
|
|
old = model_pipeline.MODELS_DIR
|
|
model_pipeline.MODELS_DIR = self.root
|
|
try:
|
|
written = model_pipeline.build("bollard", quiet=True)
|
|
self.assertEqual(len(written), 1)
|
|
self.assertTrue(written[0].is_file())
|
|
self.assertTrue(model_pipeline.audit_glb(written[0])["ok"])
|
|
finally:
|
|
model_pipeline.MODELS_DIR = old
|
|
|
|
|
|
class PaletteTests(unittest.TestCase):
|
|
def setUp(self):
|
|
self.temp_dir = tempfile.TemporaryDirectory()
|
|
self.root = Path(self.temp_dir.name)
|
|
|
|
def tearDown(self):
|
|
self.temp_dir.cleanup()
|
|
|
|
def test_dominant_colors_ignores_white_background(self):
|
|
image = Image.new("RGB", (96, 96), (250, 250, 250))
|
|
for x in range(20, 70):
|
|
for y in range(20, 70):
|
|
image.putpixel((x, y), (150, 40, 40))
|
|
colors = model_pipeline.dominant_colors(image)
|
|
self.assertTrue(colors)
|
|
self.assertLess(max(colors[0]), 0.9,
|
|
"background must not win the vote")
|
|
|
|
def test_palette_uses_concept_image_when_present(self):
|
|
old = model_pipeline.CONCEPTS_DIR
|
|
model_pipeline.CONCEPTS_DIR = self.root
|
|
try:
|
|
image = Image.new("RGB", (96, 96), (250, 250, 250))
|
|
for x in range(20, 70):
|
|
for y in range(20, 70):
|
|
image.putpixel((x, y), (30, 90, 40))
|
|
image.save(self.root / "bench.png")
|
|
palette = model_pipeline.palette_for_prop("bench")
|
|
self.assertLess(palette["body"][1], 0.6)
|
|
self.assertGreater(palette["body"][1], palette["body"][0])
|
|
finally:
|
|
model_pipeline.CONCEPTS_DIR = old
|
|
|
|
def test_palette_falls_back_to_defaults(self):
|
|
old = model_pipeline.CONCEPTS_DIR
|
|
model_pipeline.CONCEPTS_DIR = self.root
|
|
try:
|
|
palette = model_pipeline.palette_for_prop("bench")
|
|
self.assertEqual(palette["wood"], model_pipeline.PALETTE["wood"])
|
|
finally:
|
|
model_pipeline.CONCEPTS_DIR = old
|
|
|
|
def test_assign_roles_separates_wood_from_metal(self):
|
|
colors = [[0.62, 0.37, 0.18], [0.05, 0.05, 0.05], [0.70, 0.70, 0.70]]
|
|
palette = model_pipeline.assign_roles(colors)
|
|
self.assertAlmostEqual(palette["wood"][0], 0.62, places=3)
|
|
self.assertLess(model_pipeline._luminance(palette["metal"]), 0.1)
|
|
|
|
def test_assign_roles_finds_foliage_and_stone(self):
|
|
colors = [[0.55, 0.55, 0.53], [0.18, 0.42, 0.20]]
|
|
palette = model_pipeline.assign_roles(colors)
|
|
self.assertGreater(palette["foliage"][1], palette["foliage"][0])
|
|
self.assertGreater(palette["stone"][0], 0.5)
|
|
|
|
def test_assign_roles_without_colours_keeps_defaults(self):
|
|
palette = model_pipeline.assign_roles([])
|
|
self.assertEqual(palette["body"], model_pipeline.PALETTE["body"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|