saumwacht-godot/tools/verify_phase4_glb.py
Martin Stickelbroeck c2b447610c Baseline: Saumwacht 0.15.0 (Phase 20A) vor dem Umbau zu Champions of Luneraria
Unveränderter Ausgangsstand inklusive Masterprompt und Konzeptbildern
unter assets/. .godot/ und docs/screenshots/ bleiben laut .gitignore
unversioniert; letztere sind unter ../backups gesichert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 20:00:36 +02:00

306 lines
16 KiB
Python

#!/usr/bin/env python3
"""Read-only structural and geometry audit of the Phase 4 GLB package.
Uses only Python's standard library. Nothing is imported into Blender or Godot,
and neither assets nor reports are modified. --json emits machine-readable QA.
Open meshes are warnings, because horns and cutout surfaces may be intentional.
Closed meshes with negative signed volume are errors: their winding faces inward.
"""
import argparse
from collections import Counter
import hashlib
import json
import math
from pathlib import Path
import re
import struct
import sys
PROJECT = Path(__file__).resolve().parents[1]
COMPONENTS = {5120: ("b", 1), 5121: ("B", 1), 5122: ("h", 2),
5123: ("H", 2), 5125: ("I", 4), 5126: ("f", 4)}
WIDTHS = {"SCALAR": 1, "VEC2": 2, "VEC3": 3, "VEC4": 4,
"MAT2": 4, "MAT3": 9, "MAT4": 16}
def require(condition, message):
if not condition:
raise ValueError(message)
def finite(values):
return all(isinstance(value, (int, float)) and math.isfinite(value) for value in values)
def sub(a, b):
return tuple(x - y for x, y in zip(a, b))
def cross(a, b):
return (a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0])
def dot(a, b):
return sum(x * y for x, y in zip(a, b))
class GLB:
def __init__(self, path):
self.raw = path.read_bytes()
require(len(self.raw) >= 20, "Truncated GLB header")
magic, version, length = struct.unpack_from("<4sII", self.raw)
require((magic, version, length) == (b"glTF", 2, len(self.raw)), "Invalid GLB header")
chunks, offset = [], 12
while offset < length:
require(offset + 8 <= length, "Truncated GLB chunk header")
size, kind = struct.unpack_from("<I4s", self.raw, offset)
require(size % 4 == 0 and offset + 8 + size <= length, "Invalid GLB chunk size")
chunks.append((kind, self.raw[offset + 8:offset + 8 + size]))
offset += size + 8
require([kind for kind, _ in chunks] == [b"JSON", b"BIN\0"],
"Expected one JSON and one embedded BIN chunk")
self.doc = json.loads(chunks[0][1])
self.binary = chunks[1][1]
require(self.doc.get("asset", {}).get("version") == "2.0", "Not glTF 2.0")
buffers = self.doc.get("buffers", [])
require(len(buffers) == 1 and "uri" not in buffers[0], "External/multiple buffers")
require(0 <= len(self.binary) - buffers[0]["byteLength"] <= 3, "BIN byteLength mismatch")
self.buffer_length = buffers[0]["byteLength"]
self.cache = {}
def accessor(self, index):
if index in self.cache:
return self.cache[index]
accessors = self.doc.get("accessors", [])
require(isinstance(index, int) and 0 <= index < len(accessors), "Invalid accessor reference")
accessor = accessors[index]
require("sparse" not in accessor, "Sparse accessors are not supported by this asset audit")
component = accessor.get("componentType")
require(component in COMPONENTS and accessor.get("type") in WIDTHS, "Invalid accessor type")
require(isinstance(accessor.get("count"), int) and accessor["count"] > 0, "Empty accessor")
views = self.doc.get("bufferViews", [])
view_index = accessor.get("bufferView", -1)
require(isinstance(view_index, int) and 0 <= view_index < len(views), "Invalid bufferView")
view = views[view_index]
require(view.get("buffer") == 0, "Invalid embedded buffer reference")
fmt, size = COMPONENTS[component]
width = WIDTHS[accessor["type"]]
item_size = width * size
# Matrices are not used in these static models; byte/short matrix padding
# requires a different decoder, and must never be silently misinterpreted.
require(not accessor["type"].startswith("MAT") or size == 4, "Packed matrix accessor")
stride = view.get("byteStride", item_size)
require(stride >= item_size and stride % size == 0, "Invalid accessor stride")
start = view.get("byteOffset", 0)
local = accessor.get("byteOffset", 0)
require(start >= 0 and local >= 0 and view["byteLength"] >= 0, "Negative buffer offset")
require(start + view["byteLength"] <= self.buffer_length, "bufferView exceeds BIN")
end = local + (accessor["count"] - 1) * stride + item_size
require(end <= view["byteLength"], "Accessor exceeds bufferView")
unpack = struct.Struct("<" + fmt * width).unpack_from
values = [unpack(self.binary, start + local + i * stride) for i in range(accessor["count"])]
self.cache[index] = values
return values
def topology(vertices, triangles):
"""Weld equal-position flat-shading vertices to assess the actual surface."""
welded = {}
ids = []
for point in vertices:
key = tuple(round(coordinate, 6) for coordinate in point)
if key not in welded:
welded[key] = len(welded)
ids.append(welded[key])
edges, orientations = Counter(), Counter()
volume_terms = []
degenerate = 0
for a, b, c in triangles:
points = (vertices[a], vertices[b], vertices[c])
area = cross(sub(points[1], points[0]), sub(points[2], points[0]))
if len({ids[a], ids[b], ids[c]}) < 3 or dot(area, area) <= 1e-22:
degenerate += 1
continue
volume_terms.append(dot(points[0], cross(points[1], points[2])) / 6)
for start, end in ((ids[a], ids[b]), (ids[b], ids[c]), (ids[c], ids[a])):
edge = tuple(sorted((start, end)))
edges[edge] += 1
orientations[edge] += 1 if start < end else -1
boundary = sum(count == 1 for count in edges.values())
nonmanifold = sum(count > 2 for count in edges.values())
inconsistent = sum(edges[edge] == 2 and direction != 0 for edge, direction in orientations.items())
closed = bool(edges) and boundary == nonmanifold == inconsistent == 0
return {"closed": closed, "boundary_edges": boundary, "nonmanifold_edges": nonmanifold,
"inconsistent_edges": inconsistent, "degenerate_triangles": degenerate,
"signed_volume": math.fsum(volume_terms)}
def audit_asset(project, record):
identifier = record["id"]
path = project / "assets" / "models" / record["file"]
glb = GLB(path)
doc = glb.doc
result = {"id": identifier, "file": record["file"], "errors": [], "warnings": [],
"triangles": 0, "mesh_parts": 0, "primitives": 0, "vertices": 0,
"closed_meshes": 0, "open_meshes": [], "inward_meshes": [],
"degenerate_triangles": 0}
require(hashlib.sha256(glb.raw).hexdigest() == record["sha256"], "Manifest SHA-256 mismatch")
require(len(doc.get("scenes", [])) == 1 and doc.get("scene", 0) == 0, "Expected exactly one default scene")
require(not doc.get("cameras"), "Unexpected camera")
require(not doc.get("extensions", {}).get("KHR_lights_punctual", {}).get("lights"), "Unexpected light")
require(not doc.get("animations") and not doc.get("skins"), "Unexpected animation or skin")
nodes, meshes, materials = doc.get("nodes", []), doc.get("meshes", []), doc.get("materials", [])
require(bool(nodes) and bool(meshes) and bool(materials), "Missing nodes, meshes or materials")
expected = set(record["parts"]) | {"Model_" + identifier, "Forward"}
names = [node.get("name", "") for node in nodes]
require(len(set(names)) == len(names), "Duplicate node names")
require(set(names) == expected, "Node/part inventory differs from manifest")
for node in nodes:
name = node.get("name")
require(node.get("extras", {}).get("part_name") == name, "Unstable logical part name: " + str(name))
require(not re.search(r"\.\d{3}$", name), "Blender numeric suffix: " + name)
require(not re.fullmatch(r"(?:Cube|Camera|Light|Lamp)(?:\.\d+)?", name), "Factory object: " + name)
require("camera" not in node and "KHR_lights_punctual" not in node.get("extensions", {}),
"Camera/light node: " + name)
for key, width in (("translation", 3), ("rotation", 4), ("scale", 3), ("matrix", 16)):
if key in node:
require(len(node[key]) == width and finite(node[key]), "Invalid node transform: " + name)
roots = doc["scenes"][0].get("nodes", [])
require(len(roots) == 1 and nodes[roots[0]].get("name") == "Model_" + identifier, "Unexpected model root")
visited = set()
def visit(index):
require(isinstance(index, int) and 0 <= index < len(nodes), "Invalid scene node index")
require(index not in visited, "Node cycle or multiple parents")
visited.add(index)
for child in nodes[index].get("children", []):
visit(child)
visit(roots[0])
require(len(visited) == len(nodes), "Unreachable extra nodes")
mesh_indices = [node["mesh"] for node in nodes if "mesh" in node]
require(all(isinstance(index, int) and 0 <= index < len(meshes) for index in mesh_indices), "Invalid mesh reference")
require(sorted(mesh_indices) == list(range(len(meshes))), "Unused or shared unexpected mesh datablock")
result["mesh_parts"] = len(meshes)
for node in nodes:
if "mesh" not in node:
continue
mesh = meshes[node["mesh"]]
mesh_vertices, mesh_triangles = [], []
require(bool(mesh.get("primitives")), "Mesh has no surfaces: " + node["name"])
for primitive in mesh["primitives"]:
result["primitives"] += 1
require(primitive.get("mode", 4) == 4, "Non-triangle primitive")
material = primitive.get("material", -1)
require(isinstance(material, int) and 0 <= material < len(materials), "Surface without valid material")
attributes = primitive.get("attributes", {})
require("POSITION" in attributes and "NORMAL" in attributes, "Surface lacks positions or normals")
for attribute in ("POSITION", "NORMAL"):
accessor = doc["accessors"][attributes[attribute]]
require(accessor["type"] == "VEC3" and accessor["componentType"] == 5126,
"Expected float VEC3 " + attribute)
positions, normals = glb.accessor(attributes["POSITION"]), glb.accessor(attributes["NORMAL"])
require(len(positions) == len(normals), "Position/normal count mismatch")
require(all(finite(point) for point in positions), "Non-finite vertex position")
require(all(finite(normal) and abs(dot(normal, normal) - 1) < 0.005 for normal in normals),
"Non-finite, zero or non-unit normal")
if "indices" in primitive:
accessor = doc["accessors"][primitive["indices"]]
require(accessor["type"] == "SCALAR" and accessor["componentType"] in (5121, 5123, 5125),
"Invalid index accessor type")
indices = [value[0] for value in glb.accessor(primitive["indices"])]
else:
indices = list(range(len(positions)))
require(len(indices) % 3 == 0 and len(indices) > 0, "Invalid triangle index count")
require(all(0 <= index < len(positions) for index in indices), "Index outside POSITION array")
triangles = [tuple(indices[i:i + 3]) for i in range(0, len(indices), 3)]
for a, b, c in triangles:
face_normal = cross(sub(positions[b], positions[a]), sub(positions[c], positions[a]))
require(all(dot(face_normal, normals[index]) >= -1e-7 for index in (a, b, c)),
"Normals oppose face winding: " + node["name"])
offset = len(mesh_vertices)
mesh_vertices.extend(positions)
mesh_triangles.extend(tuple(index + offset for index in triangle) for triangle in triangles)
result["triangles"] += len(triangles)
result["vertices"] += len(positions)
info = topology(mesh_vertices, mesh_triangles)
result["degenerate_triangles"] += info["degenerate_triangles"]
if info["closed"]:
result["closed_meshes"] += 1
if info["signed_volume"] < -1e-10:
result["inward_meshes"].append(node["name"])
result["errors"].append("Closed mesh has inward winding: " + node["name"])
else:
result["open_meshes"].append({"part": node["name"], **info})
result["warnings"].append("Open/nonmanifold mesh: " + node["name"])
require(result["triangles"] == record["triangles"], "Triangle count differs from manifest")
require(result["mesh_parts"] == record["mesh_parts"], "Mesh part count differs from manifest")
require(result["triangles"] <= int(record.get("triangle_budget", 3000 if record["role"] in ("core", "lane") else 1800)), "Triangle budget exceeded")
if result["degenerate_triangles"]:
result["warnings"].append(str(result["degenerate_triangles"]) + " degenerate triangles")
result["part_names"] = sorted(record["parts"])
return result
def audit(project):
manifest = json.loads((project / "assets" / "phase4_manifest.json").read_text())
records = manifest["assets"]
result = {"asset_count": len(records), "errors": [], "assets": [], "upgrade_pairs": []}
if manifest.get("asset_count") != len(records) or len(records) != 59:
result["errors"].append("Expected manifest inventory of exactly 59 assets")
if len({record["id"] for record in records}) != len(records):
result["errors"].append("Duplicate asset IDs")
actual_files = {path.name for path in (project / "assets" / "models").glob("*.glb")}
if actual_files != {record["file"] for record in records}:
result["errors"].append("GLB directory inventory differs from manifest")
for record in records:
try:
asset = audit_asset(project, record)
except (ValueError, KeyError, IndexError, TypeError, OSError, struct.error) as error:
asset = {"id": record["id"], "errors": [str(error)], "warnings": []}
result["assets"].append(asset)
by_id = {asset["id"]: asset for asset in result["assets"]}
for faction in "gmnw":
for base_number in range(1, 10, 2):
base, upgrade = faction + f"{base_number:02}", faction + f"{base_number + 1:02}"
a, b = by_id.get(base, {}), by_id.get(upgrade, {})
different = ("part_names" in a and "part_names" in b and
(a["part_names"] != b["part_names"] or a["triangles"] != b["triangles"]))
result["upgrade_pairs"].append({"base": base, "upgrade": upgrade,
"structurally_distinct": different})
if not different:
result["errors"].append(base + "/" + upgrade + ": no proven geometry/part difference")
result["passed"] = not result["errors"] and all(not asset["errors"] for asset in result["assets"])
result["warning_count"] = sum(len(asset["warnings"]) for asset in result["assets"])
return result
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--project", type=Path, default=PROJECT)
parser.add_argument("--json", action="store_true", help="Print the full audit as JSON")
args = parser.parse_args()
result = audit(args.project.resolve())
if args.json:
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
passed = sum(not asset["errors"] for asset in result["assets"])
print(f"Phase 4 GLB audit: {passed}/{result['asset_count']} assets passed")
print(f"Upgrade pairs: {sum(pair['structurally_distinct'] for pair in result['upgrade_pairs'])}/20 distinct")
print(f"Warnings: {result['warning_count']}")
for error in result["errors"]:
print("ERROR: " + error)
for asset in result["assets"]:
for error in asset["errors"]:
print("ERROR " + asset["id"] + ": " + error)
for warning in asset["warnings"]:
print("WARNING " + asset["id"] + ": " + warning)
return 0 if result["passed"] else 1
if __name__ == "__main__":
sys.exit(main())