456 lines
16 KiB
Python
456 lines
16 KiB
Python
"""Generate the bounded Blender 5.2 desktop GLB fixture group for M12-06A.
|
|
|
|
The generator deliberately keeps each feature in its own file. Later import and
|
|
round-trip tasks can therefore fail on one capability without hiding it behind a
|
|
large all-in-one scene.
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import struct
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import bpy
|
|
|
|
|
|
FIXTURES = (
|
|
("mesh", "M12 Mesh Fixture", "mesh.glb"),
|
|
("pbr", "M12 PBR Fixture", "pbr.glb"),
|
|
("uv", "M12 UV Fixture", "uv.glb"),
|
|
("skin", "M12 Skin Fixture", "skin.glb"),
|
|
("animation", "M12 Animation Fixture", "animation.glb"),
|
|
)
|
|
|
|
|
|
def sha256_file(path):
|
|
digest = hashlib.sha256()
|
|
with open(path, "rb") as handle:
|
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def reset():
|
|
bpy.ops.wm.read_factory_settings(use_empty=True)
|
|
scene = bpy.context.scene
|
|
scene.frame_start = 1
|
|
scene.frame_end = 25
|
|
scene.render.fps = 24
|
|
scene.unit_settings.system = "METRIC"
|
|
scene.unit_settings.scale_length = 1.0
|
|
return scene
|
|
|
|
|
|
def mesh_object(name, vertices, faces):
|
|
mesh = bpy.data.meshes.new(name + " Mesh")
|
|
mesh.from_pydata(vertices, [], faces)
|
|
mesh.update()
|
|
obj = bpy.data.objects.new(name, mesh)
|
|
bpy.context.scene.collection.objects.link(obj)
|
|
return obj
|
|
|
|
|
|
def select_only(objects):
|
|
bpy.ops.object.select_all(action="DESELECT")
|
|
for obj in objects:
|
|
obj.select_set(True)
|
|
bpy.context.view_layer.objects.active = objects[0]
|
|
|
|
|
|
def export_selected(path):
|
|
result = bpy.ops.export_scene.gltf(
|
|
filepath=str(path),
|
|
export_format="GLB",
|
|
use_selection=True,
|
|
export_apply=False,
|
|
export_animations=True,
|
|
export_animation_mode="ACTIONS",
|
|
export_frame_range=True,
|
|
export_frame_step=1,
|
|
export_force_sampling=True,
|
|
export_skins=True,
|
|
export_all_influences=True,
|
|
export_morph=True,
|
|
export_morph_animation=True,
|
|
export_attributes=True,
|
|
export_texcoords=True,
|
|
export_normals=True,
|
|
export_tangents=False,
|
|
export_materials="EXPORT",
|
|
export_image_format="AUTO",
|
|
export_cameras=False,
|
|
export_lights=False,
|
|
export_draco_mesh_compression_enable=False,
|
|
export_meshopt_compression_enable=False,
|
|
export_try_sparse_sk=False,
|
|
export_try_omit_sparse_sk=False,
|
|
export_current_frame=False,
|
|
export_yup=True,
|
|
)
|
|
if "FINISHED" not in result:
|
|
raise RuntimeError("Blender GLB export did not finish: %s" % (result,))
|
|
|
|
|
|
def add_pbr_material(name, color, metallic, roughness):
|
|
material = bpy.data.materials.new(name)
|
|
material.use_nodes = True
|
|
material.diffuse_color = (*color, 1.0)
|
|
principled = material.node_tree.nodes.get("Principled BSDF")
|
|
principled.inputs["Base Color"].default_value = (*color, 1.0)
|
|
principled.inputs["Metallic"].default_value = metallic
|
|
principled.inputs["Roughness"].default_value = roughness
|
|
if principled.inputs.get("IOR"):
|
|
principled.inputs["IOR"].default_value = 1.45
|
|
return material
|
|
|
|
|
|
def add_uv_layer(obj):
|
|
layer = obj.data.uv_layers.new(name="UVMap")
|
|
values = ((0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0))
|
|
for loop, value in zip(layer.data, values):
|
|
loop.uv = value
|
|
|
|
|
|
def add_corner_colors(obj):
|
|
colors = obj.data.color_attributes.new(name="M12Color", type="FLOAT_COLOR", domain="CORNER")
|
|
values = (
|
|
(1.0, 0.0, 0.0, 1.0),
|
|
(0.0, 1.0, 0.0, 1.0),
|
|
(0.0, 0.0, 1.0, 1.0),
|
|
(1.0, 1.0, 0.0, 1.0),
|
|
)
|
|
for item, value in zip(colors.data, values):
|
|
item.color = value
|
|
obj.data.color_attributes.active_color_index = 0
|
|
|
|
|
|
def use_corner_colors(material):
|
|
vertex_color = material.node_tree.nodes.new("ShaderNodeVertexColor")
|
|
vertex_color.layer_name = "M12Color"
|
|
principled = material.node_tree.nodes.get("Principled BSDF")
|
|
material.node_tree.links.new(vertex_color.outputs["Color"], principled.inputs["Base Color"])
|
|
|
|
|
|
def create_mesh_fixture():
|
|
reset()
|
|
obj = mesh_object(
|
|
"M12 Mesh Fixture",
|
|
[(-1.0, -1.0, 0.0), (1.0, -1.0, 0.0), (1.0, 1.0, 0.0), (-1.0, 1.0, 0.0)],
|
|
[(0, 1, 2, 3)],
|
|
)
|
|
add_corner_colors(obj)
|
|
material = add_pbr_material("M12 Mesh Material", (0.22, 0.48, 0.83), 0.15, 0.55)
|
|
use_corner_colors(material)
|
|
obj.data.materials.append(material)
|
|
select_only([obj])
|
|
|
|
|
|
def create_pbr_fixture():
|
|
reset()
|
|
obj = mesh_object(
|
|
"M12 PBR Fixture",
|
|
[(-1.0, -1.0, 0.0), (1.0, -1.0, 0.0), (1.0, 1.0, 0.0), (-1.0, 1.0, 0.0)],
|
|
[(0, 1, 2, 3)],
|
|
)
|
|
material = add_pbr_material("M12 PBR Material", (0.31, 0.57, 0.91), 0.72, 0.28)
|
|
principled = material.node_tree.nodes.get("Principled BSDF")
|
|
principled.inputs["Emission Color"].default_value = (0.02, 0.04, 0.08, 1.0)
|
|
if principled.inputs.get("Emission Strength"):
|
|
principled.inputs["Emission Strength"].default_value = 1.5
|
|
obj.data.materials.append(material)
|
|
select_only([obj])
|
|
|
|
|
|
def create_uv_fixture():
|
|
reset()
|
|
obj = mesh_object(
|
|
"M12 UV Fixture",
|
|
[(-1.0, -1.0, 0.0), (1.0, -1.0, 0.0), (1.0, 1.0, 0.0), (-1.0, 1.0, 0.0)],
|
|
[(0, 1, 2, 3)],
|
|
)
|
|
add_uv_layer(obj)
|
|
material = add_pbr_material("M12 UV Material", (1.0, 1.0, 1.0), 0.0, 0.45)
|
|
image = bpy.data.images.new("M12 UV Texture", width=2, height=2, alpha=True)
|
|
image.colorspace_settings.name = "sRGB"
|
|
image.pixels = (
|
|
1.0, 0.1, 0.1, 1.0,
|
|
0.1, 1.0, 0.1, 1.0,
|
|
0.1, 0.1, 1.0, 1.0,
|
|
1.0, 1.0, 0.1, 1.0,
|
|
)
|
|
image.pack()
|
|
texture = material.node_tree.nodes.new("ShaderNodeTexImage")
|
|
texture.name = "M12 UV Image Texture"
|
|
texture.image = image
|
|
texture.interpolation = "Closest"
|
|
texture.extension = "REPEAT"
|
|
principled = material.node_tree.nodes.get("Principled BSDF")
|
|
material.node_tree.links.new(texture.outputs["Color"], principled.inputs["Base Color"])
|
|
obj.data.materials.append(material)
|
|
select_only([obj])
|
|
|
|
|
|
def create_armature(name):
|
|
armature_data = bpy.data.armatures.new(name + " Data")
|
|
armature = bpy.data.objects.new(name, armature_data)
|
|
bpy.context.scene.collection.objects.link(armature)
|
|
bpy.context.view_layer.objects.active = armature
|
|
armature.select_set(True)
|
|
bpy.ops.object.mode_set(mode="EDIT")
|
|
root = armature_data.edit_bones.new("Root")
|
|
root.head = (0.0, 0.0, 0.0)
|
|
root.tail = (0.0, 0.0, 1.0)
|
|
tip = armature_data.edit_bones.new("Tip")
|
|
tip.head = (0.0, 0.0, 1.0)
|
|
tip.tail = (0.0, 0.0, 2.0)
|
|
tip.parent = root
|
|
bpy.ops.object.mode_set(mode="OBJECT")
|
|
return armature
|
|
|
|
|
|
def create_skin_fixture():
|
|
reset()
|
|
armature = create_armature("M12 Skin Armature")
|
|
obj = mesh_object(
|
|
"M12 Skin Fixture",
|
|
[(-1.0, -0.5, 0.0), (1.0, -0.5, 0.0), (1.0, 0.5, 0.0), (-1.0, 0.5, 0.0)],
|
|
[(0, 1, 2, 3)],
|
|
)
|
|
root = obj.vertex_groups.new(name="Root")
|
|
tip = obj.vertex_groups.new(name="Tip")
|
|
root.add([0, 3], 0.75, "REPLACE")
|
|
tip.add([0, 3], 0.25, "REPLACE")
|
|
root.add([1, 2], 0.2, "REPLACE")
|
|
tip.add([1, 2], 0.8, "REPLACE")
|
|
modifier = obj.modifiers.new(name="M12 Armature Deform", type="ARMATURE")
|
|
modifier.object = armature
|
|
obj.parent = armature
|
|
material = add_pbr_material("M12 Skin Material", (0.76, 0.24, 0.18), 0.05, 0.5)
|
|
obj.data.materials.append(material)
|
|
select_only([armature, obj])
|
|
|
|
|
|
def create_animation_fixture():
|
|
reset()
|
|
obj = mesh_object(
|
|
"M12 Animation Fixture",
|
|
[(-0.75, -0.75, 0.0), (0.75, -0.75, 0.0), (0.0, 0.75, 0.0)],
|
|
[(0, 1, 2)],
|
|
)
|
|
material = add_pbr_material("M12 Animation Material", (0.16, 0.72, 0.38), 0.1, 0.4)
|
|
obj.data.materials.append(material)
|
|
obj.location = (-1.0, 0.0, 0.0)
|
|
obj.rotation_mode = "XYZ"
|
|
obj.keyframe_insert(data_path="location", frame=1)
|
|
obj.keyframe_insert(data_path="rotation_euler", frame=1)
|
|
obj.location = (0.0, 0.5, 0.25)
|
|
obj.rotation_euler[2] = 0.75
|
|
obj.keyframe_insert(data_path="location", frame=13)
|
|
obj.keyframe_insert(data_path="rotation_euler", frame=13)
|
|
obj.location = (1.0, 0.0, 0.0)
|
|
obj.rotation_euler[2] = 1.5
|
|
obj.keyframe_insert(data_path="location", frame=25)
|
|
obj.keyframe_insert(data_path="rotation_euler", frame=25)
|
|
if obj.animation_data and obj.animation_data.action:
|
|
obj.animation_data.action.name = "M12 Animation Action"
|
|
select_only([obj])
|
|
|
|
|
|
def read_glb(path):
|
|
payload = path.read_bytes()
|
|
if len(payload) < 20 or payload[:4] != b"glTF":
|
|
raise RuntimeError("invalid GLB header: %s" % path)
|
|
version, total_length = struct.unpack_from("<II", payload, 4)
|
|
if version != 2 or total_length != len(payload):
|
|
raise RuntimeError("invalid GLB version/length: %s" % path)
|
|
offset = 12
|
|
chunks = {}
|
|
while offset < len(payload):
|
|
length, chunk_type = struct.unpack_from("<II", payload, offset)
|
|
start = offset + 8
|
|
chunks[chunk_type] = payload[start : start + length]
|
|
offset = start + length
|
|
document = json.loads(chunks[0x4E4F534A].rstrip(b" \x00").decode("utf-8"))
|
|
return payload, document
|
|
|
|
|
|
def accessor_summary(document, index):
|
|
if index is None:
|
|
return None
|
|
accessor = document.get("accessors", [])[index]
|
|
return {
|
|
"componentType": accessor["componentType"],
|
|
"count": accessor["count"],
|
|
"type": accessor["type"],
|
|
"normalized": bool(accessor.get("normalized", False)),
|
|
"min": accessor.get("min"),
|
|
"max": accessor.get("max"),
|
|
}
|
|
|
|
|
|
def semantic_summary(document):
|
|
meshes = []
|
|
for mesh in document.get("meshes", []):
|
|
primitives = []
|
|
for primitive in mesh.get("primitives", []):
|
|
primitives.append(
|
|
{
|
|
"attributes": {
|
|
name: accessor_summary(document, index)
|
|
for name, index in sorted(primitive.get("attributes", {}).items())
|
|
},
|
|
"indices": accessor_summary(document, primitive.get("indices")),
|
|
"material": primitive.get("material"),
|
|
"mode": primitive.get("mode", 4),
|
|
"targets": [
|
|
{name: accessor_summary(document, index) for name, index in sorted(target.items())}
|
|
for target in primitive.get("targets", [])
|
|
],
|
|
}
|
|
)
|
|
meshes.append({"name": mesh.get("name"), "primitives": primitives})
|
|
materials = []
|
|
for material in document.get("materials", []):
|
|
pbr = material.get("pbrMetallicRoughness", {})
|
|
materials.append(
|
|
{
|
|
"name": material.get("name"),
|
|
"alphaMode": material.get("alphaMode", "OPAQUE"),
|
|
"doubleSided": bool(material.get("doubleSided", False)),
|
|
"pbr": {
|
|
"baseColorFactor": pbr.get("baseColorFactor"),
|
|
"baseColorTexture": pbr.get("baseColorTexture"),
|
|
"metallicFactor": pbr.get("metallicFactor"),
|
|
"roughnessFactor": pbr.get("roughnessFactor"),
|
|
},
|
|
"normalTexture": material.get("normalTexture"),
|
|
"emissiveFactor": material.get("emissiveFactor"),
|
|
}
|
|
)
|
|
animations = []
|
|
for animation in document.get("animations", []):
|
|
samplers = animation.get("samplers", [])
|
|
channels = animation.get("channels", [])
|
|
animations.append(
|
|
{
|
|
"name": animation.get("name"),
|
|
"samplers": [
|
|
{
|
|
"interpolation": sampler.get("interpolation", "LINEAR"),
|
|
"input": accessor_summary(document, sampler.get("input")),
|
|
"output": accessor_summary(document, sampler.get("output")),
|
|
}
|
|
for sampler in samplers
|
|
],
|
|
"channels": [
|
|
{"sampler": channel["sampler"], "target": channel["target"]} for channel in channels
|
|
],
|
|
}
|
|
)
|
|
return {
|
|
"asset": document.get("asset"),
|
|
"extensionsUsed": sorted(document.get("extensionsUsed", [])),
|
|
"extensionsRequired": sorted(document.get("extensionsRequired", [])),
|
|
"scene": document.get("scene"),
|
|
"nodeNames": [node.get("name") for node in document.get("nodes", [])],
|
|
"nodes": [
|
|
{
|
|
"name": node.get("name"),
|
|
"mesh": node.get("mesh"),
|
|
"skin": node.get("skin"),
|
|
"children": node.get("children", []),
|
|
"translation": node.get("translation"),
|
|
"rotation": node.get("rotation"),
|
|
"scale": node.get("scale"),
|
|
}
|
|
for node in document.get("nodes", [])
|
|
],
|
|
"meshes": meshes,
|
|
"materials": materials,
|
|
"textures": document.get("textures", []),
|
|
"images": document.get("images", []),
|
|
"samplers": document.get("samplers", []),
|
|
"skins": [
|
|
{
|
|
"name": skin.get("name"),
|
|
"joints": skin.get("joints", []),
|
|
"inverseBindMatrices": accessor_summary(document, skin.get("inverseBindMatrices")),
|
|
"skeleton": skin.get("skeleton"),
|
|
}
|
|
for skin in document.get("skins", [])
|
|
],
|
|
"animations": animations,
|
|
}
|
|
|
|
|
|
def generate_fixture(fixture_id, output_path):
|
|
creators = {
|
|
"mesh": create_mesh_fixture,
|
|
"pbr": create_pbr_fixture,
|
|
"uv": create_uv_fixture,
|
|
"skin": create_skin_fixture,
|
|
"animation": create_animation_fixture,
|
|
}
|
|
creators[fixture_id]()
|
|
export_selected(output_path)
|
|
payload, document = read_glb(output_path)
|
|
return {
|
|
"id": fixture_id,
|
|
"file": output_path.name,
|
|
"byteLength": len(payload),
|
|
"sha256": hashlib.sha256(payload).hexdigest(),
|
|
"semantic": semantic_summary(document),
|
|
}
|
|
|
|
|
|
def runtime_identity():
|
|
binary = Path(bpy.app.binary_path)
|
|
def text(value):
|
|
return value.decode("utf-8") if isinstance(value, bytes) else value
|
|
return {
|
|
"blenderVersion": text(bpy.app.version_string),
|
|
"versionTuple": list(bpy.app.version),
|
|
"buildDate": text(bpy.app.build_date),
|
|
"buildTime": text(bpy.app.build_time),
|
|
"buildHash": text(bpy.app.build_hash),
|
|
"buildBranch": text(bpy.app.build_branch),
|
|
"buildPlatform": text(bpy.app.build_platform),
|
|
"buildType": text(bpy.app.build_type),
|
|
"binarySha256": sha256_file(binary),
|
|
}
|
|
|
|
|
|
def main(output_dir, report_path):
|
|
output_dir = Path(output_dir).resolve()
|
|
report_path = Path(report_path).resolve()
|
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
report_path.parent.mkdir(parents=True, exist_ok=True)
|
|
fixtures = []
|
|
for fixture_id, _label, filename in FIXTURES:
|
|
fixtures.append(generate_fixture(fixture_id, output_dir / filename))
|
|
report = {
|
|
"schemaVersion": 1,
|
|
"task": "M12-06A",
|
|
"operation": "DESKTOP_GLB_FIXTURE_GENERATION",
|
|
"runtime": runtime_identity(),
|
|
"fixtureCount": len(fixtures),
|
|
"maxFixtureBytes": 512 * 1024,
|
|
"fixtures": fixtures,
|
|
"nextTask": "M12-06B",
|
|
}
|
|
report_path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
print(
|
|
"glb-desktop-fixtures-generated "
|
|
"fixtures=%s bytes=%s next=%s"
|
|
% (len(fixtures), sum(item["byteLength"] for item in fixtures), report["nextTask"])
|
|
)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
args = sys.argv[sys.argv.index("--") + 1 :] if "--" in sys.argv else []
|
|
if len(args) != 2:
|
|
raise SystemExit("usage: blender --background --python generate-glb-desktop-fixtures.py -- OUTPUT_DIR REPORT")
|
|
main(args[0], args[1])
|