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(" 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()