#!/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(" 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())