Define Blender entity authoring contract

This commit is contained in:
Meta Vens 2026-07-14 20:03:01 +01:00
commit d1f8f5d55d
4 changed files with 1031 additions and 0 deletions

BIN
Asset Sources/Conformance/AetheriaEntityAuthoringV1.blend (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -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.

View file

@ -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()

View file

@ -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()