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>
306 lines
16 KiB
Python
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())
|