Draw prop models with a single atlas material
Full build / Import + headless tests (push) Successful in 33s
Full build / Full desktop build + optional release (push) Successful in 3m39s

Each generated prop used one material per part (body, accent, metal...). The
model pipeline now packs a prop's palette into one power-of-two colour atlas plus a
matching metallic/roughness atlas and emits a single material:

- atlas_for(): 8x8 cells per palette role, colours in sRGB, roughness in the G and
  metallic in the B channel of the second atlas; NEAREST sampling keeps cells flat.
- build_glb(): every part merges into one primitive with constant per-vertex atlas
  UVs; the material uses baseColorTexture + metallicRoughnessTexture with factors 1.0.
- audit_glb(): fails unless a prop has exactly one material, both atlas textures,
  two images, and TEXCOORD_0 on every primitive.
- Removed the now-unused sRGB->linear factor helpers; colour travels in the texture.

Verification: headless 181 -> 182 (new "one material per prop scene" test), Python
tooling 53/53 (atlas determinism/size/UV/palette-override tests replace the old
factor tests), model audit OK for all six props, screenshots regenerated and
screen_designs.py compare still 6 good / 0 bad. Version 0.3.1.
This commit is contained in:
zwitschi committed 2026-10-31 23:47:15 +01:00
1 parent 7f92b8861d
commit f2863abeac
32 files changed
+275 -97

No files matched your search

+36 -23
View File
@@ -94,14 +94,17 @@ class GlbTests(unittest.TestCase):
self.assertEqual(gltf["asset"]["version"], "2.0")
self.assertEqual(len(gltf["scenes"]), 1)
primitives = gltf["meshes"][0]["primitives"]
self.assertEqual(len(primitives), len(prop("bench")["parts"]),
"one primitive per part")
self.assertEqual(len(gltf["materials"]), len(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.assertLess(primitive["material"], len(gltf["materials"]))
self.assertEqual(primitive["material"], 0)
for accessor in gltf["accessors"]:
self.assertIn(accessor["bufferView"],
range(len(gltf["bufferViews"])))
@@ -114,31 +117,41 @@ class GlbTests(unittest.TestCase):
second = model_pipeline.build_glb(prop("bollard"))
self.assertEqual(first, second)
def test_palette_override_changes_material_colour(self):
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]
gltf = parse_glb(model_pipeline.build_glb(
prop("bollard"), palette))["gltf"]
factors = [m["pbrMetallicRoughness"]["baseColorFactor"]
for m in gltf["materials"]]
self.assertIn(model_pipeline._linear_rgba([0.9, 0.1, 0.1]), factors)
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_glb_colours_are_written_in_linear_space(self):
# glTF baseColorFactor is linear; writing sRGB made props render pastel.
def test_material_uses_both_atlas_textures(self):
gltf = parse_glb(model_pipeline.build_glb(prop("bollard")))["gltf"]
factor = gltf["materials"][0]["pbrMetallicRoughness"]["baseColorFactor"]
srgb_metal = model_pipeline.PALETTE["metal"][0]
self.assertLess(factor[0], srgb_metal)
self.assertAlmostEqual(factor[0],
model_pipeline.srgb_to_linear(srgb_metal),
places=4)
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_srgb_to_linear_reference_values(self):
self.assertAlmostEqual(model_pipeline.srgb_to_linear(0.0), 0.0)
self.assertAlmostEqual(model_pipeline.srgb_to_linear(1.0), 1.0)
self.assertAlmostEqual(model_pipeline.srgb_to_linear(0.5), 0.2140,
places=4)
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"))