Files
security-game/tools/tests/test_model_pipeline.py
T
zwitschi e4e1566e55
Full build / Import + headless tests (push) Successful in 38s
Full build / Full desktop build + optional release (push) Skipped
Organize tools and move Python tests into tools/tests
2026-11-03 19:45:00 +01:00

308 lines
13 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_audit_enforces_the_triangle_budget(self):
old = model_pipeline.MAX_TRIANGLES
model_pipeline.MAX_TRIANGLES = 10
try:
path = self.root / "bench.glb"
path.write_bytes(model_pipeline.build_glb(prop("bench")))
result = model_pipeline.audit_glb(path)
finally:
model_pipeline.MAX_TRIANGLES = old
self.assertFalse(result["ok"])
self.assertTrue(any("triangle budget" in issue for issue in result["issues"]),
result["issues"])
def test_degenerate_triangle_helper(self):
self.assertEqual(
model_pipeline._triangle_area2((0, 0, 0), (1, 0, 0), (2, 0, 0)), 0.0)
self.assertGreater(
model_pipeline._triangle_area2((0, 0, 0), (1, 0, 0), (0, 1, 0)), 0.0)
def test_audit_reads_positions_and_uvs_from_the_binary(self):
parsed = parse_glb(model_pipeline.build_glb(prop("bollard")))
gltf, binary = parsed["gltf"], parsed["binary"]
primitive = gltf["meshes"][0]["primitives"][0]
positions = model_pipeline._read_float_accessor(
gltf, binary, primitive["attributes"]["POSITION"])
uvs = model_pipeline._read_float_accessor(
gltf, binary, primitive["attributes"]["TEXCOORD_0"])
indices = model_pipeline._read_index_accessor(
gltf, binary, primitive["indices"])
self.assertEqual(len(positions), gltf["accessors"][
primitive["attributes"]["POSITION"]]["count"])
self.assertEqual(len(uvs), len(positions))
self.assertEqual(len(indices) % 3, 0)
self.assertLess(min(p[1] for p in positions), 0.01)
for u, v in uvs:
self.assertTrue(0.0 <= u <= 1.0 and 0.0 <= v <= 1.0)
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()