Organize tools and move Python tests into tools/tests
This commit is contained in:
1 parent
0aacee6518
commit
e4e1566e55
22 files changed
+209
-3
No files matched your search
@@ -0,0 +1,307 @@
|
||||
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()
|
||||
Reference in new issue
Block a user