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.
258 lines
11 KiB
Python
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()
|