274 lines
14 KiB
Python
274 lines
14 KiB
Python
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.18, 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"]:
|
|
self.assertGreaterEqual(report["slots"][slot]["td_runtime_px_per_m"],
|
|
250.0, f"{name}/{slot} fabric below 250 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()
|