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