Aetheria/tools/blender/validate_aetheria_entity.py
2026-07-14 20:03:01 +01:00

204 lines
9.1 KiB
Python

"""Validate the open Blender file against the Aetheria fixture contract."""
import re
import sys
import bpy
SCHEMA = "gamecult.aetheria.blender_entity.v1"
ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9._/-]*$")
def descendants(collection):
yield collection
for child in collection.children:
yield from descendants(child)
def objects_recursive(collection):
seen = set()
for current in descendants(collection):
for obj in current.objects:
if obj.name not in seen:
seen.add(obj.name)
yield obj
def find_root(errors):
roots = [c for c in bpy.data.collections if c.get("aetheria.schema") == SCHEMA]
if len(roots) != 1:
errors.append(f"expected exactly one {SCHEMA} root collection; found {len(roots)}")
return None
return roots[0]
def validate_ids(root, errors):
owners = list(descendants(root)) + list(objects_recursive(root))
owners += [m for m in bpy.data.materials if m.get("aetheria.role") == "material"]
owners += [i for i in bpy.data.images if i.get("aetheria.role") == "texture"]
owners += [a for a in bpy.data.actions if a.get("aetheria.role") == "animation-clip"]
for obj in objects_recursive(root):
if obj.type == "ARMATURE":
owners.extend(obj.data.bones)
seen = {}
for owner in owners:
stable_id = owner.get("aetheria.id")
label = getattr(owner, "name", repr(owner))
if not stable_id:
errors.append(f"{label}: missing aetheria.id")
continue
if not ID_PATTERN.fullmatch(str(stable_id)):
errors.append(f"{label}: invalid aetheria.id {stable_id!r}")
if stable_id in seen:
errors.append(f"duplicate aetheria.id {stable_id!r}: {seen[stable_id]} and {label}")
seen[stable_id] = label
def validate_fixture(root, errors):
expected_root = {
"aetheria.role": "entity-root",
"aetheria.asset_id": "aetheria/conformance/entity-authoring-v1",
"aetheria.asset_kind": "fixture",
"aetheria.units": "metres",
"aetheria.handedness": "right",
"aetheria.up_axis": "+Z",
"aetheria.forward_axis": "-Y",
}
for key, expected in expected_root.items():
if root.get(key) != expected:
errors.append(f"root {key}: expected {expected!r}, got {root.get(key)!r}")
collections = list(descendants(root))
roles = {c.get("aetheria.role") for c in collections}
for role in ("visual", "collision", "destruction", "destruction-set", "sockets", "variant-library", "variant", "rig", "animation", "preview"):
if role not in roles:
errors.append(f"missing collection role {role!r}")
lods = {c.get("aetheria.lod") for c in collections if c.get("aetheria.role") == "visual"}
if not {0, 1}.issubset(lods):
errors.append(f"fixture requires visual LOD 0 and 1; found {sorted(x for x in lods if x is not None)}")
variants = [c for c in collections if c.get("aetheria.role") == "variant"]
if {c.get("aetheria.variant.variant_id") for c in variants} != {"scout", "gunship"}:
errors.append("fixture requires scout and gunship variant collections")
objects = list(objects_recursive(root))
object_roles = {o.get("aetheria.role") for o in objects}
for role in ("visual", "collision", "destruction-piece", "socket", "port", "armature", "preview"):
if role not in object_roles:
errors.append(f"missing object role {role!r}")
collision_shapes = {o.get("aetheria.collider.shape") for o in objects if o.get("aetheria.role") == "collision"}
for shape in ("box", "sphere", "convex"):
if shape not in collision_shapes:
errors.append(f"fixture missing {shape!r} collision proxy")
body_ids = {o.get("aetheria.collider.body_id") for o in objects if o.get("aetheria.role") == "collision"}
if not {"body.hull", "body.port-panel"}.issubset(body_ids):
errors.append(f"fixture collision bodies incomplete: {sorted(str(x) for x in body_ids)}")
sets = [c for c in collections if c.get("aetheria.role") == "destruction-set"]
if len(sets) != 2:
errors.append(f"fixture requires two destruction sets; found {len(sets)}")
for collection in sets:
pieces = [o for o in collection.objects if o.get("aetheria.role") == "destruction-piece"]
total = sum(float(o.get("aetheria.destruction.mass_fraction", 0.0)) for o in pieces)
if not pieces or abs(total - 1.0) > 1e-6:
errors.append(f"destruction set {collection.name!r} mass fractions sum to {total}")
hardpoints = [o for o in objects if o.get("aetheria.socket.kind") == "hardpoint"]
accepts = {o.get("aetheria.socket.accepts") for o in hardpoints}
if not {"weapon", "utility", "radiator"}.issubset(accepts):
errors.append(f"fixture hardpoint categories incomplete: {sorted(str(x) for x in accepts)}")
if any(not o.get("aetheria.socket.mount_standard") for o in hardpoints):
errors.append("every hardpoint must declare a mount standard")
variant_memberships = {
c.get("aetheria.variant.variant_id")
for o in hardpoints for c in o.users_collection
if c.get("aetheria.role") == "variant"
}
if variant_memberships != {"scout", "gunship"}:
errors.append(f"fixture variant hardpoint membership incomplete: {sorted(str(x) for x in variant_memberships)}")
ports = [o for o in objects if o.get("aetheria.role") == "port"]
kinds = {o.get("aetheria.port.kind") for o in ports}
if not {"thruster", "missile", "drone"}.issubset(kinds):
errors.append(f"fixture port kinds incomplete: {sorted(str(x) for x in kinds)}")
thrusters = [o for o in ports if o.get("aetheria.port.kind") == "thruster"]
if not thrusters or any(o.type != "MESH" or len(o.data.polygons) == 0 for o in thrusters):
errors.append("thruster ports must be non-empty emission meshes")
if any(o.get("aetheria.port.flow_axis") != "local:-Y" for o in thrusters):
errors.append("thruster flow axis must be local:-Y")
armatures = [o for o in objects if o.type == "ARMATURE" and o.get("aetheria.role") == "armature"]
if len(armatures) != 1:
errors.append(f"fixture requires one armature; found {len(armatures)}")
else:
joints = [b for b in armatures[0].data.bones if b.get("aetheria.role") == "joint"]
if len(joints) != 1:
errors.append(f"fixture requires one authored joint; found {len(joints)}")
elif joints[0].get("aetheria.joint.mode") != "physics":
errors.append("fixture joint must exercise physics mode")
skinned = [
o for o in objects
if any(m.type == "ARMATURE" and m.object == armatures[0] for m in o.modifiers)
]
if not skinned:
errors.append("fixture requires a skinned visual mesh")
actions = [a for a in bpy.data.actions if a.get("aetheria.role") == "animation-clip"]
if not actions:
errors.append("fixture requires an animation clip")
maps = {i.get("aetheria.texture.map") for i in bpy.data.images if i.get("aetheria.role") == "texture"}
if not {"base_color", "normal", "orm", "emissive", "paint_mask"}.issubset(maps):
errors.append(f"fixture texture maps incomplete: {sorted(str(x) for x in maps)}")
paint_masks = [i for i in bpy.data.images if i.get("aetheria.texture.map") == "paint_mask"]
if len(paint_masks) != 1 or any(
paint_masks[0].get(f"aetheria.texture.channel_{channel}") != meaning
for channel, meaning in (
("r", "palette-primary"),
("g", "palette-secondary"),
("b", "palette-tertiary"),
("a", "paint-coverage"),
)
):
errors.append("paint mask channel contract is incomplete")
elif not {
(1.0, 0.0, 0.0, 1.0),
(0.0, 1.0, 0.0, 1.0),
(0.0, 0.0, 1.0, 1.0),
}.issubset({
tuple(round(value, 3) for value in paint_masks[0].pixels[index:index + 4])
for index in range(0, len(paint_masks[0].pixels), 4)
}):
errors.append("paint mask does not exercise primary, secondary, and tertiary channel regions")
paint_materials = [
material for material in bpy.data.materials
if material.get("aetheria.material.paint_model") == "palette-mask-rgb-v1"
]
if len(paint_materials) != 1:
errors.append("fixture requires exactly one palette-mask-rgb-v1 material")
for image in bpy.data.images:
if image.get("aetheria.role") == "texture" and not image.packed_file:
errors.append(f"fixture image {image.name!r} is not packed")
preview_collections = [c for c in collections if c.get("aetheria.role") == "preview"]
if not preview_collections or any(c.get("aetheria.export", True) for c in preview_collections):
errors.append("preview collection must explicitly set aetheria.export=false")
def main():
errors = []
root = find_root(errors)
if root is not None:
validate_ids(root, errors)
validate_fixture(root, errors)
if errors:
print("AETHERIA_ENTITY_FIXTURE_INVALID")
for error in errors:
print(f" - {error}")
raise SystemExit(1)
print("AETHERIA_ENTITY_FIXTURE_OK")
if __name__ == "__main__":
main()