import json import unittest import blender_assets as ba import npc_pipeline class CatalogueTests(unittest.TestCase): def test_contract_matches_npc_pipeline(self): self.assertEqual(ba.SLOTS, npc_pipeline.SLOTS) self.assertEqual(sorted(ba.BONES), sorted(npc_pipeline.BONES)) # The character cap was raised to 24000/24000 for the MPFB body and the head # overhaul; the 13 core bones stay required and extra bones are optional. self.assertLessEqual(ba.CHAR_MAX_TRIS, 24000) self.assertLessEqual(ba.CHAR_MAX_VERTS, 24000) def test_gear_uses_known_attach_points(self): for name, attach in ba.GEAR.items(): self.assertIn(attach, ba.GEAR_ATTACH, name) self.assertIn(attach, ba.ATTACH_BLENDER, name) def test_character_gear_exists(self): layers = set() for name, spec in ba.CHARACTERS.items(): layers.add(spec["layer"]) for gear in spec.get("gear", []): self.assertIn(gear, ba.GEAR, f"{name} -> {gear}") # Every NpcAvatar.layer_for() value has a character. for layer in ("none", "jacket", "coat", "vest", "dress"): self.assertIn(layer, layers) def test_emissive_props_use_emissive_palette_entries(self): for name, spec in ba.PROPS.items(): for mat in spec.get("emissive", []): self.assertGreater(ba.PALETTE[mat][3], 0.0, f"{name}:{mat}") def test_adapt_requires_blender(self): # `adapt_glb` is a Blender-only intake step; outside Blender it must # fail cleanly instead of raising. result = ba.adapt_glb("/tmp/whatever.glb", "/tmp/out.glb") self.assertFalse(result["ok"]) self.assertIn("Blender", result["error"]) @unittest.skipUnless(ba.MANIFEST.is_file(), "Blender assets not built") class BuiltAssetTests(unittest.TestCase): def test_every_asset_audits_clean(self): bad = {} for category, name in ba.catalogue(): result = ba.audit_asset(category, name) if not result["ok"]: bad[name] = result["issues"] self.assertEqual(bad, {}) def test_fit_report_measures_gear_against_body(self): report = {entry["character"]: entry for entry in ba.fit_report()} self.assertIn("npc_coat", report) coat = report["npc_coat"] # Reference dimensions come from the shipped mesh, so the roster swap # (ADR-0018: the Quaternius base, 0.20 m head) moves them. The gear is # authored for the shipped roster, so the ratio checks below are what # must stay true; the absolute numbers are a drift guard. # npc_coat moved to the Suit base, whose head is 0.209 m wide; the ratio checks # below are what must hold, the absolute numbers are a drift guard. self.assertAlmostEqual(coat["reference"]["head_width"], 0.209, places=2) self.assertAlmostEqual(coat["reference"]["clothing_radius"], 0.168, places=3) sunglasses = next(row for row in coat["gear"] if row["name"] == "sunglasses") self.assertEqual(sunglasses["attach"], "head") self.assertLessEqual(sunglasses["width"], 0.70 * coat["reference"]["head_width"]) self.assertAlmostEqual(sunglasses["width_ratio"], 0.62, places=2) bag = next(row for row in coat["gear"] if row["name"] == "shoulder_bag") self.assertEqual(bag["attach"], "back") self.assertGreaterEqual(bag["x_extent"], 0.2) self.assertGreater(bag["x_clearance"], 0) self.assertGreater(bag["inner_x"], coat["reference"]["clothing_radius"]) for character in report.values(): for row in character["gear"]: self.assertEqual(len(row["min"]), 3) self.assertEqual(len(row["max"]), 3) self.assertIn("npc_dress", report) dress = report["npc_dress"] self.assertAlmostEqual(dress["reference"]["clothing_radius"], 0.198, places=3) dress_bag = next(row for row in dress["gear"] if row["name"] == "shoulder_bag") self.assertGreater(dress_bag["x_clearance"], 0) def test_manifest_covers_catalogue(self): manifest = json.loads(ba.MANIFEST.read_text(encoding="utf-8")) self.assertEqual(set(manifest["assets"]), {n for _, n in ba.catalogue()}) def test_manifest_paths_stay_in_the_library(self): manifest = json.loads(ba.MANIFEST.read_text(encoding="utf-8")) for name, entry in manifest["assets"].items(): self.assertTrue(entry["path"].startswith("assets/models/blender/"), name) if entry["category"] == "npc_gear": self.assertIn(entry["attach"], ba.GEAR_ATTACH, name) def test_baked_ao_maps_exist_for_every_asset(self): from PIL import Image for name, entry in json.loads( ba.MANIFEST.read_text(encoding="utf-8"))["assets"].items(): path = ba.OUT_DIR / entry["category"] / f"{name}_ao.png" self.assertTrue(path.is_file(), path) with Image.open(path) as image: self.assertEqual(image.size, (256, 256), path) def test_sources_are_hidden_from_godot(self): self.assertTrue((ba.SRC_DIR / ".gdignore").is_file()) @unittest.skipUnless(ba.MANIFEST.is_file(), "Blender assets not built") class TexelDensityTests(unittest.TestCase): def test_character_slots_report_density_and_area(self): report = ba.texel_density("npc_casual") self.assertEqual(report["resolution"], 256) # The five tint slots are the contract; rebuilds may add fixed-colour # detail materials (the Quaternius base keeps `Eye` and `Eyebrows`). self.assertLessEqual(set(ba.SLOTS), set(report["slots"])) for slot in ba.SLOTS: row = report["slots"][slot] self.assertGreater(row["triangles"], 0, slot) self.assertGreater(row["world_area_m2"], 0.0, slot) self.assertGreater(row["td_px_per_m"], 30.0, slot) self.assertGreater(report["total_world_area_m2"], 1.0) self.assertLess(report["total_world_area_m2"], 6.0) def test_fabric_slots_apply_the_runtime_uv_repeat(self): report = ba.texel_density("npc_casual") for slot, factor in ba.TEXEL_RUNTIME_UV_SCALE.items(): row = report["slots"][slot] self.assertAlmostEqual(row["td_runtime_px_per_m"], row["td_px_per_m"] * factor, places=0) skin = report["slots"]["skin"] self.assertEqual(skin["td_px_per_m"], skin["td_runtime_px_per_m"]) def test_every_character_reports_a_plausible_density_for_every_slot(self): # Gate for the 3D review: a new or rebuilt character must keep all five # tint slots measurable and inside the documented stylized band. for name in ba.CHARACTERS: report = ba.texel_density(name) self.assertIsNotNone(report, name) for slot in ba.SLOTS: self.assertIn(slot, report["slots"], f"{name}:{slot}") td = report["slots"][slot]["td_px_per_m"] self.assertGreaterEqual(td, 40.0, f"{name}/{slot} below 40 px/m") self.assertLessEqual(td, 400.0, f"{name}/{slot} above 400 px/m") for slot in ba.TEXEL_RUNTIME_UV_SCALE: if slot in report["slots"]: # The dense modular outfits (King 11k tris, Adventurer 9.5k) pack the # same 256-square UV set with more geometry, so their 4x fabric weave # lands near 210 px/m instead of 250+. The floor keeps the weave from # visibly stretching without failing the heavy outfits outright. self.assertGreaterEqual(report["slots"][slot]["td_runtime_px_per_m"], 200.0, f"{name}/{slot} fabric below 200 px/m") def test_base_fallback_is_measurable_from_its_own_path(self): from pathlib import Path path = ba.ROOT / "assets" / "models" / "generated" / "npc_base.glb" report = ba.texel_density("npc_base", path) self.assertIsNotNone(report) self.assertGreater(report["min_td"], 30.0) class InspectionTests(unittest.TestCase): """`inspect` reports on imported GLBs; the contract logic is pure and tested here with synthetic measurements so Blender is not a test dependency.""" def stats(self, category="npc", **overrides): base = { "file": "/tmp/synthetic.glb", "category": category, "objects": 1, "mesh_nodes": ["npc_base"] if category == "npc" else ["prop"], "armatures": ["Armature"] if category == "npc" else [], "helpers": [], "bones": list(ba.BONES) + ["Hips", "Spine", "Neck"], "bone_heads": {}, "materials": list(ba.SLOTS), "images": 5, "uv_layers": ["UVMap"], "meshes_without_uv": 0, "meshes_without_material": 0, "unskinned": [], "skinned": category == "npc", "animations": 0, "triangles": 4500, "vertices": 4200, "split_vertices": 4400, "local_bounds": {"min": [-0.3, 0.0, -0.2], "max": [0.3, 1.82, 0.2]}, "world_bounds": {"min": [-0.3, 0.0, -0.2], "max": [0.3, 1.82, 0.2]}, "transform_delta": 0.0, "transforms_applied": True, } base.update(overrides) return base def test_clean_character_passes(self): result = ba.analyse_inspection(self.stats()) self.assertTrue(result["ok"], result["issues"]) self.assertEqual(result["bones_missing"], []) self.assertEqual(result["budget"]["max_triangles"], ba.CHAR_MAX_TRIS) def test_extra_optional_bones_are_allowed(self): stats = self.stats(bones=list(ba.BONES) + ["Hips", "Spine", "Neck", "FingerL", "FingerR"]) result = ba.analyse_inspection(stats) self.assertTrue(result["ok"], result["issues"]) self.assertEqual(result["bones_missing"], []) def test_character_contract_failures_are_listed(self): stats = self.stats(mesh_nodes=["Armature"], skinned=False, unskinned=["Armature"], materials=["cloth"], bones=["Head"], meshes_without_uv=1, triangles=25000, split_vertices=25000, world_bounds={"min": [-0.3, -0.4, -0.2], "max": [0.3, 1.2, 0.2]}) issues = "\n".join(ba.analyse_inspection(stats)["issues"]) self.assertIn("no mesh node named npc_base", issues) self.assertIn("missing bone HipL", issues) self.assertIn("missing material slot skin", issues) self.assertIn("mesh not skinned", issues) self.assertIn("mesh(es) without UVs", issues) self.assertIn(f"triangles 25000 > {ba.CHAR_MAX_TRIS}", issues) self.assertIn(f"glTF vertices 25000 > {ba.CHAR_MAX_VERTS}", issues) self.assertIn("feet not on the ground", issues) self.assertIn("unexpected height", issues) def test_budget_gates_follow_the_category(self): gear = ba.analyse_inspection(self.stats("npc_gear", mesh_nodes=["cap"], triangles=ba.GEAR_MAX_TRIS + 1)) self.assertIn(f"triangles {ba.GEAR_MAX_TRIS + 1} > {ba.GEAR_MAX_TRIS}", "\n".join(gear["issues"])) prop = ba.analyse_inspection(self.stats("props", mesh_nodes=["bollard"], triangles=ba.PROP_MAX_TRIS + 1)) self.assertIn(f"triangles {ba.PROP_MAX_TRIS + 1} > {ba.PROP_MAX_TRIS}", "\n".join(prop["issues"])) external = ba.analyse_inspection(self.stats("external", mesh_nodes=["body"], materials=[], skinned=False, triangles=999_999, world_bounds={"min": [-3.0, -1.0, -3.0], "max": [3.0, 9.0, 3.0]})) self.assertTrue(external["ok"], external["issues"]) self.assertIsNone(external["budget"]["max_triangles"]) self.assertTrue(any("no project budget" in w for w in external["warnings"])) def test_unapplied_transforms_are_reported_not_failed(self): result = ba.analyse_inspection(self.stats(transform_delta=1.9)) self.assertTrue(result["ok"]) self.assertTrue(any("node transforms changed the pose" in w for w in result["warnings"])) def test_missing_mesh_or_bounds_is_a_failure(self): result = ba.analyse_inspection(self.stats(mesh_nodes=[], objects=0, triangles=0, world_bounds={"min": None, "max": None})) self.assertFalse(result["ok"]) self.assertIn("no mesh objects", result["issues"]) self.assertIn("no triangles", result["issues"]) self.assertIn("empty world bounds", result["issues"]) def test_category_inference_from_path(self): self.assertEqual(ba.category_for_path("assets/models/blender/npc/npc_casual.glb"), "npc") self.assertEqual(ba.category_for_path("assets/models/blender/npc_gear/cap.glb"), "npc_gear") self.assertEqual(ba.category_for_path("assets/models/blender/props/bollard.glb"), "props") self.assertEqual(ba.category_for_path("assets/models/generated/bollard.glb"), "props") self.assertEqual(ba.category_for_path("tmp/quaternius/Worker.glb"), "external") def test_inspect_requires_blender(self): # The stdlib test run has no bpy; inspect must fail cleanly, not crash. if ba.bpy is not None: # pragma: no cover - only inside Blender self.skipTest("running inside Blender") result = ba.inspect_glb("does-not-exist.glb") self.assertFalse(result["ok"]) self.assertIn("inside Blender", result["error"]) if __name__ == "__main__": unittest.main()