Define Blender entity authoring contract
This commit is contained in:
parent
580a4c37b8
commit
d1f8f5d55d
4 changed files with 1031 additions and 0 deletions
BIN
Asset Sources/Conformance/AetheriaEntityAuthoringV1.blend
(Stored with Git LFS)
Normal file
BIN
Asset Sources/Conformance/AetheriaEntityAuthoringV1.blend
(Stored with Git LFS)
Normal file
Binary file not shown.
343
docs/blender-entity-authoring-v1.md
Normal file
343
docs/blender-entity-authoring-v1.md
Normal 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.
|
||||
481
tools/blender/create_aetheria_entity_fixture.py
Normal file
481
tools/blender/create_aetheria_entity_fixture.py
Normal 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()
|
||||
204
tools/blender/validate_aetheria_entity.py
Normal file
204
tools/blender/validate_aetheria_entity.py
Normal 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()
|
||||
Loading…
Reference in a new issue