Files
security-game/tools/test_model_pipeline.py
T
zwitschi afa1d6beec
Full build / Import + headless tests (push) Successful in 36s
Full build / Full desktop build + optional release (push) Successful in 4m17s
v0.3.0: rendering pass, generated props, selection model and Web build
Rendering and presentation
- Shared surface-material module (src/game/materials.gd) with per-surface
  roughness/metallic/uv profiles; ACES tonemapping, glow, fog and phase-driven sky
  energy. WorldBuilder rebuilt on those profiles with facade windows that light up,
  door trim/handles, curb, lane dashes, bollards, bench, bin and planter foliage.
- NPC silhouettes: per-role clothing palettes, caps/hoods/backpacks/sunglasses,
  hands and shoes, arm/leg walk swing.
- Six generated low-poly GLB props (bollards, bench, bin, planter, newspaper box,
  hydrant) built by tools/model_pipeline.py, loaded by WorldBuilder with a
  procedural fallback; ADR-0012.

Selection and movement
- Focus ranking by view angle then distance (NpcFocus.rank_indices); Tab and the
  mouse wheel cycle nearby people initially, right click clears focus with a
  re-acquire grace, HUD shows the candidate count, Shift walks at half speed.
  ADR-0011. Windowed smoke extended to cover wheel cycling and clearing.

QA tooling
- screen_designs.py catalogue grown to 19 screens (8 scene, 11 UI) plus a `list`
  subcommand; draft generation keeps existing AI references unless --force.

Web export
- New Web preset using the non-threaded Godot template (static hosting, no
  COOP/COEP). tools/build_releases.sh exports build/web and packages
  build/step-line-web-<ver>.zip; publish_release.sh uploads it with the desktop
  artifacts. test_web_export.gd covers the preset and release wiring.

Verification
- Headless suite 177 -> 181, Python tooling 53 tests, all eight smokes pass
  (including the windowed focus/wheel smoke). Web bundle serves index.html,
  index.wasm (35 MB) and index.pck (30 MB) with HTTP 200 from a static server.
- Version 0.3.0; README, PLAN, RELEASING, TODO/DONE and MEMORY updated.
2026-10-31 23:07:41 +01:00

258 lines
11 KiB
Python

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), len(prop("bench")["parts"]),
"one primitive per part")
self.assertEqual(len(gltf["materials"]), len(primitives))
for primitive in primitives:
self.assertIn("POSITION", primitive["attributes"])
self.assertIn("NORMAL", primitive["attributes"])
self.assertIsNotNone(primitive["indices"])
self.assertLess(primitive["material"], len(gltf["materials"]))
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_material_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)
def test_glb_colours_are_written_in_linear_space(self):
# glTF baseColorFactor is linear; writing sRGB made props render pastel.
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)
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_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_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()