diff --git a/Asset Sources/Conformance/AetheriaEntityAuthoringV1.blend b/Asset Sources/Conformance/AetheriaEntityAuthoringV1.blend new file mode 100644 index 000000000..5ac588de0 --- /dev/null +++ b/Asset Sources/Conformance/AetheriaEntityAuthoringV1.blend @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c88b9824b6aa94f72311cbd346828da680a9ce202787c27349960f7452d3f502 +size 651125 diff --git a/docs/blender-entity-authoring-v1.md b/docs/blender-entity-authoring-v1.md new file mode 100644 index 000000000..fc048e445 --- /dev/null +++ b/docs/blender-entity-authoring-v1.md @@ -0,0 +1,343 @@ +# Blender Entity Authoring v1 + +This contract makes one Blender collection the authoring authority for an +Aetheria ship, station, turret, drone, or other hull-shaped entity. Unity +prefabs are import products. They do not own hull semantics. + +The source `.blend` is consumed by the Brokkr asset pipeline. Brokkr captures +the collection, validates it, packages geometry, textures, materials, skins, +and animation through the CultMesh CDN, then publishes a +`gamecult.mesh.entity_prefab_package.v1` document. Generic clients lower that +portable package. Clients do not need the `.blend` file or Aetheria provider +code. + +## Authority map + +- **Owner:** one root Blender collection owns the authored entity. +- **Inputs:** Blender collections, objects, meshes, armatures, actions, + materials, images, transforms, and namespaced custom properties. +- **Outputs:** a Brokkr prefab snapshot, CDN artifact manifests and bodies, and + one `CultMeshEntityPrefabPackage` graph. +- **Derived state:** Unity prefabs, runtime scene objects, Ymir body fixtures, + particle systems, fracture instances, and equipped prefab instances. +- **Forbidden writers:** Unity prefabs, Eve lowerers, and client-side scripts + must not redefine collision, sockets, ports, fracture membership, or joints. +- **Shared path:** editor preview, daemon import, CDN packaging, and every + runtime lowerer consume the same package graph. +- **Deletion line:** semantics that exist only in Djinni or Longinus Unity + prefabs must be migrated into Blender before those prefabs can be retired. + +## Coordinates and identity + +The root collection MUST carry: + +| Property | Value | +| --- | --- | +| `aetheria.schema` | `gamecult.aetheria.blender_entity.v1` | +| `aetheria.asset_id` | Stable provider-owned asset ID | +| `aetheria.asset_kind` | `ship`, `station`, `turret`, `drone`, or `fixture` | +| `aetheria.version` | Author-selected immutable version | +| `aetheria.units` | `metres` | +| `aetheria.handedness` | `right` | +| `aetheria.up_axis` | `+Z` | +| `aetheria.forward_axis` | `-Y` | + +CultMesh stores source transforms in Blender coordinates. Each runtime lowerer +owns one tested coordinate conversion. Authors must not pre-rotate objects for +Unity. + +Every exported collection, object, bone, material, image, and action MUST have +a stable `aetheria.id`. Names are labels and may change. IDs use lower-case +ASCII letters, digits, `.`, `_`, `/`, and `-`; they are unique within the root +entity collection. + +Custom properties are typed scalars or fixed numeric vectors. Do not hide the +contract in a JSON string. New semantics require a versioned property or +component contract. + +## Collection roles + +Names below are the recommended readable layout. The authoritative selector is +the collection's `aetheria.role` property. + +| Role | Purpose | +| --- | --- | +| `entity-root` | Exactly one root authoring collection. | +| `visual` | Render geometry. Child collections may declare `aetheria.lod`. | +| `collision` | Non-rendering collision proxy meshes. | +| `destruction` | Parent for one or more destruction-set collections. | +| `destruction-set` | A complete named set of explosion pieces. | +| `sockets` | Attachment points and launch/emission ports. | +| `variant-library` | Parent for hull-family variant declarations. | +| `variant` | One compile-time selection of mount candidates and geometry modules. | +| `rig` | Armatures and rig helpers. | +| `animation` | Optional organizational collection for animation helpers. | +| `preview` | Cameras, lights, labels, and scale references; never exported. | + +Objects may belong to only one primary semantic role collection. They may also +be linked into one or more `variant` collections to declare variant inclusion. +Blender parenting is the runtime transform hierarchy; collection nesting is +classification. + +## Hull families, variants, and loadouts + +The mature MechWarrior/BattleTech split is worth preserving: a reusable chassis +owns the complete modeled hardpoint vocabulary; a variant selects which of +those hardpoints exist; a loadout selects installed equipment. Those are three +different authorities. + +An Aetheria `.blend` therefore authors one **hull family** and may contain a +`variant-library`. Each child `variant` collection carries: + +- `aetheria.variant.variant_id` +- `aetheria.variant.hull_id`, matching the root `aetheria.asset_id` +- `aetheria.variant.display_name` + +Objects linked only to their primary role collection are common to every +variant. An object additionally linked to one or more `variant` collections is +included only in those variants. This supports variant-specific hardpoint +placement, cowlings, apertures, collision proxies, and destruction pieces +without copying the base mesh. + +The deployer emits one portable prefab package per variant while reusing the +same content-addressed mesh and texture artifacts. Aetheria's typed hull or +item definition references the variant package. Installed equipment remains +daemon-owned entity state and is never saved into the `.blend`. + +The distinction is structural: + +- The hull family says, "these mounts and geometry modules can physically + exist." +- The variant says, "this subset exists on this manufactured hull." +- The loadout says, "these item instances are installed now." + +No layer may repair an impossible request from the layer above. If a variant +selects a hardpoint with no presentable geometry or envelope, the import fails. + +## Object roles + +All exported objects carry `aetheria.id` and `aetheria.role`. + +### Visual meshes + +`aetheria.role = visual` identifies render geometry. The containing visual +collection supplies `aetheria.lod`; `0` is mandatory. Materials and all image +nodes reachable from them are CDN dependencies. Packed images are legal source +assets but are unpacked into independent CDN artifacts by the deployer. + +Skinned visual meshes use an ordinary Blender Armature modifier. Shape keys, +vertex groups, armature bindings, and actions are exported through the skin and +animation payload. Generic clients receive a standard render/skin graph, not a +Unity Animator controller. + +### Collision proxies + +Collision meshes use `aetheria.role = collision` and: + +| Property | Meaning | +| --- | --- | +| `aetheria.collider.shape` | `box`, `sphere`, `capsule`, `convex`, or `mesh` | +| `aetheria.collider.body_id` | Stable body or articulated-body ID | +| `aetheria.collider.trigger` | Boolean trigger intent | +| `aetheria.collider.material` | Provider physics-material ID | + +Proxy geometry is authoritative. `box`, `sphere`, and `capsule` meshes define +their fitted dimensions; `convex` defines a convex hull; `mesh` is reserved for +static bodies such as stations. The Aetheria importer lowers these facts into +Ymir/Box3D fixtures appropriate to the simulation plane. Render meshes are +never silently substituted as collision meshes. + +### Destruction sets + +Each `destruction-set` collection carries a stable `aetheria.destruction.set_id`. +Every piece inside it is a visual mesh with: + +- `aetheria.role = destruction-piece` +- `aetheria.destruction.piece_id` +- `aetheria.destruction.body_id` +- `aetheria.destruction.mass_fraction` + +Mass fractions in a set sum to one within importer tolerance. A set is selected +as a whole; collection membership is not inferred from names or proximity. +Explosion impulse, lifetime, and loot are simulation state owned by the daemon, +not mesh metadata. + +### Hardpoints + +A hardpoint is an Empty with `aetheria.role = socket` and: + +- `aetheria.socket.kind = hardpoint` +- `aetheria.socket.accepts = weapon`, `utility`, `radiator`, or another typed + equipment category +- `aetheria.socket.size` as the provider's slot-size token +- `aetheria.socket.mount_standard` as the transform/envelope contract implemented + by compatible equipment prefabs +- `aetheria.socket.presentation_group` for selecting the hull's modeled mount + family when a cowl or aperture differs by weapon category +- `aetheria.region_id` for the structural region containing the mount + +The equipped prefab root is instantiated at the Empty's local transform. Its +local `-Y` axis is forward and local `+Z` is up. Weapon muzzle points belong to +the equipped weapon prefab, not the hull hardpoint. + +### Thruster, missile, and drone ports + +Ports use `aetheria.role = port`, a stable `aetheria.port.group_id`, and an +`aetheria.port.kind` of `thruster`, `missile`, or `drone`. + +Thruster ports MUST be meshes. Their triangles are the particle emission +surface and their local `-Y` axis is exhaust flow. They also carry +`aetheria.port.flow_axis = local:-Y`. They are semantic meshes, not visible hull +geometry. + +Missile ports are ordered Empty transforms or aperture meshes. Drone ports are +Empty spawn transforms or aperture meshes. Ordered banks add +`aetheria.port.sequence`, starting at zero. The daemon decides whether and when +something launches; the port only describes where and how it is presented. + +### Armatures and joints + +Armatures use normal Blender bones, weights, constraints, and actions. Every +exported bone has `aetheria.id`. A bone that declares an articulated joint also +has: + +| Property | Meaning | +| --- | --- | +| `aetheria.joint.kind` | `hinge`, `slider`, `fixed`, or `aim` | +| `aetheria.joint.mode` | `animation` or `physics` | +| `aetheria.joint.parent_body_id` | Parent collision body | +| `aetheria.joint.child_body_id` | Child collision body | +| `aetheria.joint.axis` | Local axis token such as `local:X` | +| `aetheria.joint.limit_min` | Minimum degrees or metres | +| `aetheria.joint.limit_max` | Maximum degrees or metres | + +`animation` joints are presentation-only. `physics` joints are imported into +daemon/Ymir state and require matching collision body IDs. An armature does not +silently grant physics authority to a client animation. + +Actions have `aetheria.id`, `aetheria.role = animation-clip`, and +`aetheria.animation.loop`. Animation state is selected by typed entity state; +clients merely lower the clip. + +## Paint, patterns, and decals + +Do not duplicate hull materials for every skin. A paintable material owns: + +- ordinary PBR maps: base color, normal, ORM, and emissive; +- a non-color `paint_mask` image; +- `aetheria.material.paint_model = palette-mask-rgb-v1`; +- three default palette colors. + +The paint mask uses channel weights: + +| Channel | Meaning | +| --- | --- | +| R | primary palette weight | +| G | secondary palette weight | +| B | tertiary palette weight | +| A | total paint coverage; zero preserves authored base color | + +RGB should normally be mutually exclusive but may blend at boundaries. The +shader normalizes non-zero RGB before applying the palette, multiplies the +result by alpha coverage, then composites over authored base color. Pattern +textures can remap the same three weights without replacing PBR detail. +Palette, pattern, weathering amount, and decal selection are instance or +loadout presentation state; UVs and masks are hull-authoring state. + +Mask channel meaning is declared on the image custom properties and copied into +the CDN material definition. Generic lowerers consume the declared paint model; +they do not recognize an Aetheria shader name. + +## CultMesh lowering + +The deployer maps authored facts into the existing portable prefab graph: + +| Blender fact | Portable node/component intent | +| --- | --- | +| Visual mesh | `mesh` node plus mesh/material asset references | +| Collision proxy | `collider` node plus `gamecult.prefab.collider.v1` | +| Hardpoint/port | `socket` node plus `gamecult.prefab.socket.v1` or `gamecult.prefab.port.v1` | +| Thruster surface | mesh asset plus `gamecult.prefab.emission_surface.v1` | +| Destruction piece | mesh node plus `gamecult.prefab.destruction_piece.v1` | +| Armature/joint | animation asset plus `gamecult.prefab.joint.v1` | + +Those component IDs are portable intents. Aetheria owns their gameplay use; +generic lowerers own only rendering, attachment, particles, animation, and +collision-debug presentation. If CultMesh cannot carry one of these facts +without opaque metadata, extend the typed prefab contract before shipping the +asset. Do not add an Aetheria branch to EveUnity. + +Recommended runtime payloads are GLB (`model/gltf-binary`) for geometry, skins, +and animation; PNG or KTX2 for textures; and typed CultCache documents for the +prefab graph and component intents. CDN manifests are content-addressed and +provider-owned. + +## Conformance fixture + +`Asset Sources/Conformance/AetheriaEntityAuthoringV1.blend` exercises: + +- LOD0 and LOD1 render meshes; +- packed base-color, normal, ORM, and emissive maps; +- box, sphere, convex, and articulated collision bodies; +- two complete destruction sets; +- weapon, utility, and radiator hardpoints; +- mesh thruster emission ports; +- ordered missile and drone ports; +- a two-variant hull family with different hardpoint selections; +- a channel-packed three-color paint mask; +- a skinned articulated panel, armature, physics hinge metadata, and animation; +- preview-only camera, light, and scale reference. + +Regenerate and validate it headlessly: + +```powershell +blender --background --factory-startup --python tools/blender/create_aetheria_entity_fixture.py +blender --background "Asset Sources/Conformance/AetheriaEntityAuthoringV1.blend" --python tools/blender/validate_aetheria_entity.py +``` + +The validator prints `AETHERIA_ENTITY_FIXTURE_OK` and exits zero only when the +contract is present. This validates authoring structure. A later pipeline smoke +must also prove snapshot capture, CDN dependency packaging, portable-package +publication, and lowering in a generic client. + +## Prior art + +- HBS's published [How to Create a BattleMech](https://ryanburrell.com/wp-content/uploads/2023/03/HBS_How-to-Create-a-BattleMech_X.pdf) + records that most visual sources came from Piranha/MWO and treats + `HardpointDataDef`, `ChassisDef`, `MechDef`, colliders, weapon prefabs, + heraldry, and asset-bundle association as separate production steps. +- Piranha's [MW5 Mod Editor Guide](https://static.mw5mercs.com/docs/MW5Mercs_Mod_Editor_Guide_%28v2.3%29.pdf) + treats a loadout/variant as the spawnable selection and recommends a real + test map for gameplay assets. +- The community-documented MW5 asset split describes MDL/HPS/MDA/Loadout as + chassis, modeled hardpoint vocabulary, variant, and installed configuration. + See [Hardpoints, Mech Data, and Loadouts](https://mechwarrior5modding.fandom.com/wiki/Hardpoints%2C_Mech_Data%2C_and_Loadouts). + We copy the ownership split, not Unreal's file formats. +- Epic's [texture-mask guidance](https://dev.epicgames.com/documentation/unreal-engine/using-texture-masks-in-unreal-engine) + documents channel packing as the normal way to carry several independent + material masks efficiently. + +## Pipeline gates exposed by the fixture + +The current Brokkr Blender snapshot is not yet sufficient to deploy this +contract. Before the importer can claim conformance it must capture: + +- collection custom properties, including roles and variant declarations; +- armature bones, bone custom properties, parentage, and bind poses; +- Armature modifier targets, vertex groups, and skin weights; +- actions, clip metadata, and animation curves; +- mesh UV sets and material-to-image dependency edges; +- packed image bytes and declared channel semantics; +- stable mesh payload identity rather than mesh counts alone. + +The deployer must then compile each variant into a fully resolved +`CultMeshEntityPrefabPackage`, publish all referenced CDN bodies, and reject +dangling IDs, duplicate IDs, unsupported component intents, missing payloads, +invalid destruction mass closure, or physics joints whose body IDs do not +exist. + +The existing portable package also needs typed, advertised definitions for the +socket, port, emission-surface, destruction-piece, and joint component intents. +String metadata may carry a development probe, but it is not the released +contract. This fixture is the acceptance input for that work; it is not evidence +that the deploy lane already exists. diff --git a/tools/blender/create_aetheria_entity_fixture.py b/tools/blender/create_aetheria_entity_fixture.py new file mode 100644 index 000000000..e0944859d --- /dev/null +++ b/tools/blender/create_aetheria_entity_fixture.py @@ -0,0 +1,481 @@ +"""Create the Blender Entity Authoring v1 conformance fixture. + +Run with Blender, not system Python: + blender --background --factory-startup --python tools/blender/create_aetheria_entity_fixture.py +""" + +from pathlib import Path +import math + +import bpy + + +SCHEMA = "gamecult.aetheria.blender_entity.v1" +ROOT_NAME = "AE_AuthoringFixture" + + +def repo_root() -> Path: + script = Path(__file__).resolve() + return script.parents[2] + + +def set_properties(owner, **properties): + for key, value in properties.items(): + owner[key.replace("__", ".")] = value + + +def new_collection(name, parent, stable_id, role, **properties): + collection = bpy.data.collections.new(name) + parent.children.link(collection) + set_properties( + collection, + aetheria__id=stable_id, + aetheria__role=role, + **properties, + ) + return collection + + +def move_to_collection(obj, collection): + for current in list(obj.users_collection): + current.objects.unlink(obj) + collection.objects.link(obj) + + +def tag(obj, stable_id, role, **properties): + set_properties(obj, aetheria__id=stable_id, aetheria__role=role, **properties) + return obj + + +def material(name, stable_id, base, metallic, roughness, emission): + mat = bpy.data.materials.new(name) + mat.use_nodes = True + set_properties(mat, aetheria__id=stable_id, aetheria__role="material") + set_properties( + mat, + aetheria__material__paint_model="palette-mask-rgb-v1", + aetheria__material__palette_primary=[0.08, 0.32, 0.55, 1.0], + aetheria__material__palette_secondary=[0.9, 0.18, 0.04, 1.0], + aetheria__material__palette_tertiary=[0.95, 0.72, 0.08, 1.0], + ) + nodes = mat.node_tree.nodes + links = mat.node_tree.links + nodes.clear() + + output = nodes.new("ShaderNodeOutputMaterial") + output.location = (700, 0) + principled = nodes.new("ShaderNodeBsdfPrincipled") + principled.location = (420, 0) + links.new(principled.outputs["BSDF"], output.inputs["Surface"]) + + def image_node(label, map_kind, color, x, y, non_color=False): + image = bpy.data.images.new(f"IMG_{label}", width=8, height=8, alpha=True) + pixels = list(color) * (8 * 8) + image.pixels = pixels + image.pack() + set_properties( + image, + aetheria__id=f"texture.fixture.{map_kind}", + aetheria__role="texture", + aetheria__texture__map=map_kind, + ) + node = nodes.new("ShaderNodeTexImage") + node.label = label + node.name = f"TEX_{map_kind.upper()}" + node.image = image + node.location = (x, y) + if non_color: + image.colorspace_settings.name = "Non-Color" + return node + + base_node = image_node("Base Color", "base_color", base, -700, 250) + normal_node = image_node("Normal", "normal", (0.5, 0.5, 1.0, 1.0), -700, 0, True) + orm_node = image_node("ORM", "orm", (1.0, roughness, metallic, 1.0), -700, -250, True) + emission_node = image_node("Emission", "emissive", emission, -700, -500) + paint_node = image_node("Paint Mask", "paint_mask", (1.0, 0.0, 0.0, 1.0), -700, -750, True) + paint_pixels = [] + for _y in range(8): + for x in range(8): + paint_pixels.extend((1.0, 0.0, 0.0, 1.0) if x < 3 else + (0.0, 1.0, 0.0, 1.0) if x < 6 else + (0.0, 0.0, 1.0, 1.0)) + paint_node.image.pixels = paint_pixels + paint_node.image.pack() + set_properties( + paint_node.image, + aetheria__texture__channel_r="palette-primary", + aetheria__texture__channel_g="palette-secondary", + aetheria__texture__channel_b="palette-tertiary", + aetheria__texture__channel_a="paint-coverage", + ) + + normal_map = nodes.new("ShaderNodeNormalMap") + normal_map.location = (80, 0) + separate = nodes.new("ShaderNodeSeparateColor") + separate.location = (-200, -240) + + links.new(base_node.outputs["Color"], principled.inputs["Base Color"]) + links.new(normal_node.outputs["Color"], normal_map.inputs["Color"]) + links.new(normal_map.outputs["Normal"], principled.inputs["Normal"]) + links.new(orm_node.outputs["Color"], separate.inputs["Color"]) + links.new(separate.outputs["Green"], principled.inputs["Roughness"]) + metallic_input = principled.inputs.get("Metallic IOR Level") or principled.inputs.get("Metallic") + links.new(separate.outputs["Blue"], metallic_input) + links.new(emission_node.outputs["Color"], principled.inputs["Emission Color"]) + principled.inputs["Emission Strength"].default_value = 2.0 + return mat + + +def assign_material(obj, mat): + if obj.data and hasattr(obj.data, "materials"): + obj.data.materials.append(mat) + + +def cube(name, collection, stable_id, role, location, dimensions, mat=None, **properties): + bpy.ops.mesh.primitive_cube_add(size=1.0, location=location) + obj = bpy.context.object + obj.name = name + obj.dimensions = dimensions + bpy.ops.object.transform_apply(location=False, rotation=False, scale=True) + move_to_collection(obj, collection) + tag(obj, stable_id, role, **properties) + if mat: + assign_material(obj, mat) + return obj + + +def sphere(name, collection, stable_id, role, location, radius, mat=None, **properties): + bpy.ops.mesh.primitive_ico_sphere_add(subdivisions=2, radius=radius, location=location) + obj = bpy.context.object + obj.name = name + move_to_collection(obj, collection) + tag(obj, stable_id, role, **properties) + if mat: + assign_material(obj, mat) + return obj + + +def emitter_quad(name, collection, stable_id, location, rotation=(0.0, 0.0, 0.0), **properties): + mesh = bpy.data.meshes.new(f"{name}_Mesh") + mesh.from_pydata( + [(-0.5, 0.0, -0.35), (0.5, 0.0, -0.35), (0.5, 0.0, 0.35), (-0.5, 0.0, 0.35)], + [], + [(0, 1, 2, 3)], + ) + mesh.update() + obj = bpy.data.objects.new(name, mesh) + collection.objects.link(obj) + obj.location = location + obj.rotation_euler = rotation + tag( + obj, + stable_id, + "port", + aetheria__port__kind="thruster", + aetheria__port__flow_axis="local:-Y", + **properties, + ) + return obj + + +def empty(name, collection, stable_id, role, location, display="ARROWS", **properties): + obj = bpy.data.objects.new(name, None) + collection.objects.link(obj) + obj.location = location + obj.empty_display_type = display + obj.empty_display_size = 0.45 + return tag(obj, stable_id, role, **properties) + + +def build_fixture(): + bpy.ops.wm.read_factory_settings(use_empty=True) + scene = bpy.context.scene + scene.name = "Aetheria Entity Authoring Fixture" + scene.unit_settings.system = "METRIC" + scene.unit_settings.scale_length = 1.0 + scene.render.engine = "BLENDER_EEVEE_NEXT" + scene.frame_start = 1 + scene.frame_end = 40 + + root = bpy.data.collections.new(ROOT_NAME) + scene.collection.children.link(root) + set_properties( + root, + aetheria__id="fixture.entity-authoring-v1", + aetheria__role="entity-root", + aetheria__schema=SCHEMA, + aetheria__asset_id="aetheria/conformance/entity-authoring-v1", + aetheria__asset_kind="fixture", + aetheria__version="1.0.0", + aetheria__units="metres", + aetheria__handedness="right", + aetheria__up_axis="+Z", + aetheria__forward_axis="-Y", + ) + + visual = new_collection("VISUAL", root, "collection.visual", "visual") + lod0 = new_collection("LOD0", visual, "collection.visual.lod0", "visual", aetheria__lod=0) + lod1 = new_collection("LOD1", visual, "collection.visual.lod1", "visual", aetheria__lod=1) + collision = new_collection("COLLISION", root, "collection.collision", "collision") + destruction = new_collection("DESTRUCTION", root, "collection.destruction", "destruction") + fracture_primary = new_collection( + "FRACTURE_PRIMARY", destruction, "collection.destruction.primary", "destruction-set", + aetheria__destruction__set_id="primary", + ) + fracture_secondary = new_collection( + "FRACTURE_SECONDARY", destruction, "collection.destruction.secondary", "destruction-set", + aetheria__destruction__set_id="secondary", + ) + sockets = new_collection("SOCKETS", root, "collection.sockets", "sockets") + hardpoints = new_collection("HARDPOINTS", sockets, "collection.sockets.hardpoints", "sockets") + thrusters = new_collection("THRUSTER_PORTS", sockets, "collection.sockets.thrusters", "sockets") + missiles = new_collection("MISSILE_PORTS", sockets, "collection.sockets.missiles", "sockets") + drones = new_collection("DRONE_PORTS", sockets, "collection.sockets.drones", "sockets") + variants = new_collection("VARIANTS", root, "collection.variants", "variant-library") + scout_variant = new_collection( + "VARIANT_SCOUT", variants, "collection.variant.scout", "variant", + aetheria__variant__variant_id="scout", + aetheria__variant__hull_id="aetheria/conformance/entity-authoring-v1", + aetheria__variant__display_name="Authoring Fixture Scout", + ) + gunship_variant = new_collection( + "VARIANT_GUNSHIP", variants, "collection.variant.gunship", "variant", + aetheria__variant__variant_id="gunship", + aetheria__variant__hull_id="aetheria/conformance/entity-authoring-v1", + aetheria__variant__display_name="Authoring Fixture Gunship", + ) + rig = new_collection("RIG", root, "collection.rig", "rig") + animation = new_collection("ANIMATION", root, "collection.animation", "animation") + preview = new_collection("PREVIEW", root, "collection.preview", "preview", aetheria__export=False) + + entity_root = empty("Entity Root", root, "node.root", "entity-root", (0.0, 0.0, 0.0), "PLAIN_AXES") + + hull_material = material( + "MAT_FixtureHull", + "material.fixture.hull", + (0.08, 0.32, 0.55, 1.0), + 0.65, + 0.28, + (0.0, 0.08, 0.24, 1.0), + ) + fracture_material = bpy.data.materials.new("MAT_FractureDebug") + fracture_material.diffuse_color = (0.8, 0.16, 0.05, 1.0) + set_properties(fracture_material, aetheria__id="material.fixture.fracture", aetheria__role="material") + + hull = cube( + "Hull LOD0", lod0, "visual.hull.lod0", "visual", (0.0, 0.0, 0.0), (4.0, 7.0, 1.4), hull_material, + aetheria__visual__part="hull", + ) + hull.parent = entity_root + nose = cube( + "Nose LOD0", lod0, "visual.nose.lod0", "visual", (0.0, -4.0, 0.0), (2.4, 1.5, 0.9), hull_material, + aetheria__visual__part="nose", + ) + nose.rotation_euler.z = math.radians(45.0) + nose.parent = entity_root + hull_lod1 = cube( + "Hull LOD1", lod1, "visual.hull.lod1", "visual", (0.0, -0.2, 0.0), (3.7, 7.7, 1.2), hull_material, + aetheria__visual__part="hull", + ) + hull_lod1.parent = entity_root + + body_collision = cube( + "COL Hull Box", collision, "collision.hull.box", "collision", (0.0, 0.2, 0.0), (3.8, 6.3, 1.25), + aetheria__collider__shape="box", + aetheria__collider__body_id="body.hull", + aetheria__collider__trigger=False, + aetheria__collider__material="aetheria.physics.hull", + ) + body_collision.display_type = "WIRE" + body_collision.hide_render = True + body_collision.parent = entity_root + sphere_collision = sphere( + "COL Reactor Sphere", collision, "collision.reactor.sphere", "collision", (0.0, 1.2, 0.0), 0.9, + aetheria__collider__shape="sphere", + aetheria__collider__body_id="body.hull", + aetheria__collider__trigger=False, + aetheria__collider__material="aetheria.physics.reactor", + ) + sphere_collision.display_type = "WIRE" + sphere_collision.hide_render = True + sphere_collision.parent = entity_root + convex_collision = cube( + "COL Nose Convex", collision, "collision.nose.convex", "collision", (0.0, -3.6, 0.0), (2.1, 1.8, 0.75), + aetheria__collider__shape="convex", + aetheria__collider__body_id="body.hull", + aetheria__collider__trigger=False, + aetheria__collider__material="aetheria.physics.hull", + ) + convex_collision.rotation_euler.z = math.radians(45.0) + convex_collision.display_type = "WIRE" + convex_collision.hide_render = True + convex_collision.parent = entity_root + + for set_collection, set_id, z_offset in ( + (fracture_primary, "primary", 0.0), + (fracture_secondary, "secondary", -0.25), + ): + for index, x in enumerate((-1.45, -0.5, 0.5, 1.45)): + piece = cube( + f"Fracture {set_id.title()} {index}", set_collection, + f"destruction.{set_id}.piece-{index}", "destruction-piece", + (x, 0.0, z_offset), (0.85, 5.5, 0.55), fracture_material, + aetheria__destruction__piece_id=f"{set_id}.piece-{index}", + aetheria__destruction__body_id=f"debris.{set_id}.piece-{index}", + aetheria__destruction__mass_fraction=0.25, + ) + piece.hide_render = True + piece.parent = entity_root + + for stable_id, label, accepts, size, location, variant in ( + ("socket.hardpoint.weapon-dorsal", "HP Weapon Dorsal", "weapon", "medium", (0.0, -0.4, 1.0), gunship_variant), + ("socket.hardpoint.utility-port", "HP Utility Port", "utility", "small", (-2.1, 0.8, 0.0), scout_variant), + ("socket.hardpoint.radiator-starboard", "HP Radiator Starboard", "radiator", "medium", (2.1, 0.8, 0.0), None), + ): + point = empty( + label, hardpoints, stable_id, "socket", location, + aetheria__socket__kind="hardpoint", + aetheria__socket__accepts=accepts, + aetheria__socket__size=size, + aetheria__socket__mount_standard="aetheria.mount.equipment.v1", + aetheria__socket__presentation_group=f"fixture.{accepts}", + aetheria__region_id="region.hull", + ) + point.parent = entity_root + if variant is not None: + variant.objects.link(point) + + for index, x in enumerate((-0.85, 0.85)): + port = emitter_quad( + f"PORT Thruster Main {index}", thrusters, f"port.thruster.main-{index}", + (x, 3.55, 0.0), + aetheria__port__group_id="thruster.main", + aetheria__port__sequence=index, + ) + port.parent = entity_root + + for index, x in enumerate((-0.75, 0.75)): + port = empty( + f"PORT Missile {index}", missiles, f"port.missile.bank-{index}", "port", (x, -4.8, -0.15), + aetheria__port__kind="missile", + aetheria__port__group_id="missile.bank", + aetheria__port__sequence=index, + ) + port.parent = entity_root + + drone_aperture = empty( + "PORT Drone Bay", drones, "port.drone.bay", "port", (0.0, 2.8, -0.8), + aetheria__port__kind="drone", + aetheria__port__group_id="drone.bay", + aetheria__port__sequence=0, + ) + drone_aperture.parent = entity_root + + armature_data = bpy.data.armatures.new("RIG_FixtureArmature") + armature = bpy.data.objects.new("RIG Fixture Armature", armature_data) + rig.objects.link(armature) + armature.parent = entity_root + tag(armature, "rig.fixture", "armature") + bpy.context.view_layer.objects.active = armature + armature.select_set(True) + bpy.ops.object.mode_set(mode="EDIT") + root_bone = armature.data.edit_bones.new("root") + root_bone.head = (0.0, 0.0, 0.0) + root_bone.tail = (0.0, -1.0, 0.0) + panel_bone = armature.data.edit_bones.new("port_panel_hinge") + panel_bone.head = (-2.0, 0.8, 0.0) + panel_bone.tail = (-2.0, 0.8, 1.0) + panel_bone.parent = root_bone + bpy.ops.object.mode_set(mode="OBJECT") + set_properties(armature.data.bones["root"], aetheria__id="bone.root", aetheria__role="bone") + set_properties( + armature.data.bones["port_panel_hinge"], + aetheria__id="bone.port-panel-hinge", + aetheria__role="joint", + aetheria__joint__kind="hinge", + aetheria__joint__mode="physics", + aetheria__joint__parent_body_id="body.hull", + aetheria__joint__child_body_id="body.port-panel", + aetheria__joint__axis="local:X", + aetheria__joint__limit_min=0.0, + aetheria__joint__limit_max=75.0, + ) + + panel = cube( + "Articulated Port Panel", lod0, "visual.port-panel", "visual", (-2.0, 0.8, 0.4), (0.18, 2.2, 1.2), hull_material, + aetheria__visual__part="articulated-panel", + ) + panel.parent = armature + modifier = panel.modifiers.new("Fixture Armature", "ARMATURE") + modifier.object = armature + group = panel.vertex_groups.new(name="port_panel_hinge") + group.add(list(range(len(panel.data.vertices))), 1.0, "REPLACE") + + panel_collision = cube( + "COL Port Panel", collision, "collision.port-panel.box", "collision", (-2.0, 0.8, 0.4), (0.22, 2.1, 1.1), + aetheria__collider__shape="box", + aetheria__collider__body_id="body.port-panel", + aetheria__collider__trigger=False, + aetheria__collider__material="aetheria.physics.hull", + ) + panel_collision.display_type = "WIRE" + panel_collision.hide_render = True + panel_collision.parent = armature + panel_collision.parent_type = "BONE" + panel_collision.parent_bone = "port_panel_hinge" + + action = bpy.data.actions.new("ACT Port Panel Deploy") + set_properties( + action, + aetheria__id="animation.port-panel-deploy", + aetheria__role="animation-clip", + aetheria__animation__loop=False, + ) + armature.animation_data_create() + armature.animation_data.action = action + pose_bone = armature.pose.bones["port_panel_hinge"] + pose_bone.rotation_mode = "XYZ" + pose_bone.rotation_euler.x = 0.0 + pose_bone.keyframe_insert(data_path="rotation_euler", frame=1) + pose_bone.rotation_euler.x = math.radians(75.0) + pose_bone.keyframe_insert(data_path="rotation_euler", frame=24) + animation["aetheria.action_id"] = "animation.port-panel-deploy" + + camera_data = bpy.data.cameras.new("Preview Camera") + camera = bpy.data.objects.new("Preview Camera", camera_data) + preview.objects.link(camera) + camera.location = (11.0, 12.0, 8.0) + camera.rotation_euler = (math.radians(63.0), 0.0, math.radians(138.0)) + tag(camera, "preview.camera", "preview") + scene.camera = camera + light_data = bpy.data.lights.new("Preview Key", "AREA") + light_data.energy = 1200.0 + light_data.shape = "DISK" + light_data.size = 6.0 + light = bpy.data.objects.new("Preview Key", light_data) + preview.objects.link(light) + light.location = (-4.0, -6.0, 9.0) + tag(light, "preview.light", "preview") + scale_ref = cube( + "Preview Metre Cube", preview, "preview.metre-cube", "preview", (6.0, 2.0, 0.5), (1.0, 1.0, 1.0) + ) + scale_ref.display_type = "WIRE" + scale_ref.hide_render = True + + for obj in bpy.context.selected_objects: + obj.select_set(False) + entity_root.select_set(True) + bpy.context.view_layer.objects.active = entity_root + return root + + +def main(): + build_fixture() + output = repo_root() / "Asset Sources" / "Conformance" / "AetheriaEntityAuthoringV1.blend" + output.parent.mkdir(parents=True, exist_ok=True) + bpy.ops.wm.save_as_mainfile(filepath=str(output), check_existing=False) + print(f"AETHERIA_ENTITY_FIXTURE_WRITTEN {output}") + + +if __name__ == "__main__": + main() diff --git a/tools/blender/validate_aetheria_entity.py b/tools/blender/validate_aetheria_entity.py new file mode 100644 index 000000000..90bf6911b --- /dev/null +++ b/tools/blender/validate_aetheria_entity.py @@ -0,0 +1,204 @@ +"""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()