bootstrap: finish project bootstrap — scale test, MPFB character, build docs
- Static-mesh scale test passes: 1 m = 100.00 cm, origin preserved, axis mapping east→+X / north→−Y / up→+Z (named presets ue_metric_v1 both sides). - MPFB 2.0.18 acceptance passes: generated human + standard 137-bone rig with weights + v2 skin baked to 1024² diffuse; imports as SkeletalMesh with Skeleton, Material, and Texture2D in /Game/Test/Character. - MPFB headless requires XDG_CONFIG_HOME at the Flatpak config tree; documented in Art/blender/README.md, probe in Tools/addon_compat_probe.py. - Docs/build.md completed: fresh-checkout build, packaging, headless checks. - Docs/setup.md reconciled with reality; ROADMAP/bootstrap closed out.
This commit is contained in:
1 parent
01939de9a0
commit
1c6b956435
21 files changed
+614
-33
No files matched your search
@@ -0,0 +1,64 @@
|
||||
# Headless compatibility probe for Blosm and MPFB on the pinned Blender 5.2.
|
||||
#
|
||||
# blender -b --factory-startup --python Tools/addon_compat_probe.py
|
||||
#
|
||||
# Checks: Blender version, Blosm add-on loads + version, MPFB extension
|
||||
# enables + version, MPFB can create a default human headless, resulting
|
||||
# object inventory (mesh/armature/materials/uvs).
|
||||
|
||||
import bpy
|
||||
import sys
|
||||
|
||||
REPORT = "/tmp/addon-compat-report.txt"
|
||||
lines = []
|
||||
|
||||
|
||||
def log(msg):
|
||||
lines.append(str(msg))
|
||||
|
||||
|
||||
bl = bpy.app.version_string
|
||||
log(f"Blender: {bl}")
|
||||
|
||||
# --- Blosm (legacy add-on, scripts/addons) ---
|
||||
try:
|
||||
import blosm # noqa
|
||||
info = getattr(blosm, "bl_info", {})
|
||||
log(f"blosm: import OK, version {info.get('version', '?')}, blender {info.get('blender', '?')}")
|
||||
except Exception as exc: # noqa
|
||||
log(f"blosm: IMPORT FAILED: {exc!r}")
|
||||
|
||||
# --- MPFB (extension, extensions/blender_org/mpfb) ---
|
||||
try:
|
||||
import addon_utils
|
||||
bpy.ops.preferences.addon_enable(module="bl_ext.blender_org.mpfb")
|
||||
enabled = "bl_ext.blender_org.mpfb" in bpy.context.preferences.addons
|
||||
version = "?"
|
||||
try:
|
||||
import bl_ext.blender_org.mpfb as mpfb_mod
|
||||
version = getattr(mpfb_mod, "MPFB_VERSION", "?")
|
||||
except Exception:
|
||||
pass
|
||||
log(f"mpfb: enable OK (registered={enabled}), version {version}")
|
||||
except Exception as exc: # noqa
|
||||
log(f"mpfb: ENABLE FAILED: {exc!r}")
|
||||
|
||||
# --- MPFB create-human smoke test ---
|
||||
try:
|
||||
bpy.ops.mpfb.create_human("EXEC_DEFAULT")
|
||||
meshes = [o for o in bpy.data.objects if o.type == "MESH"]
|
||||
armatures = [o for o in bpy.data.objects if o.type == "ARMATURE"]
|
||||
log(f"mpfb.create_human: OK — {len(bpy.data.objects)} objects, "
|
||||
f"{len(meshes)} meshes, {len(armatures)} armatures")
|
||||
for o in meshes:
|
||||
uv = bool(o.data.uv_layers)
|
||||
mats = [m.name for m in o.data.materials]
|
||||
log(f" mesh {o.name}: {len(o.data.vertices)} verts, uv={uv}, materials={mats}")
|
||||
for o in armatures:
|
||||
log(f" armature {o.name}: {len(o.data.bones)} bones")
|
||||
except Exception as exc: # noqa
|
||||
log(f"mpfb.create_human: FAILED: {exc!r}")
|
||||
|
||||
with open(REPORT, "w") as f:
|
||||
f.write("\n".join(lines) + "\n")
|
||||
print("\n".join(lines))
|
||||
@@ -0,0 +1,148 @@
|
||||
# MPFB character export for the Blender -> Unreal pipeline (0.1.0 acceptance).
|
||||
#
|
||||
# Generates a default human, adds the standard deform rig with weights,
|
||||
# materializes the v2 skin, bakes its diffuse to a texture, and exports
|
||||
# FBX with the named export preset "ue_character_v1".
|
||||
#
|
||||
# blender -b --factory-startup --python Tools/mpfb_character_export.py -- \
|
||||
# --out /tmp/gigline/character.fbx \
|
||||
# --save-blend Art/blender/character/character-<date>.blend
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
|
||||
REPORT = "/tmp/mpfb-export-report.txt"
|
||||
lines = []
|
||||
|
||||
|
||||
def log(msg):
|
||||
lines.append(str(msg))
|
||||
|
||||
|
||||
def enable_mpfb():
|
||||
bpy.ops.preferences.addon_enable(module="bl_ext.blender_org.mpfb")
|
||||
assert "bl_ext.blender_org.mpfb" in bpy.context.preferences.addons, "MPFB did not enable"
|
||||
|
||||
|
||||
def build_character():
|
||||
for obj in list(bpy.data.objects):
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
|
||||
bpy.ops.mpfb.create_human("EXEC_DEFAULT")
|
||||
human = bpy.context.active_object
|
||||
log(f"create_human: {human.name}, {len(human.data.vertices)} verts, dims={[round(d, 3) for d in human.dimensions]}")
|
||||
|
||||
bpy.ops.mpfb.add_standard_rig("EXEC_DEFAULT")
|
||||
armatures = [o for o in bpy.data.objects if o.type == "ARMATURE"]
|
||||
log(f"add_standard_rig: {len(armatures)} armature(s), bones={[len(a.data.bones) for a in armatures]}")
|
||||
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
human.select_set(True)
|
||||
bpy.context.view_layer.objects.active = human
|
||||
bpy.ops.mpfb.create_v2_skin("EXEC_DEFAULT")
|
||||
mats = [m.name for m in human.data.materials]
|
||||
log(f"create_v2_skin: materials={mats}")
|
||||
return human
|
||||
|
||||
|
||||
def bake_diffuse(human, size=1024):
|
||||
mat = human.data.materials[0]
|
||||
nodes, links = mat.node_tree.nodes, mat.node_tree.links
|
||||
|
||||
img = bpy.data.images.new("T_Skin_Diffuse", size, size)
|
||||
tex_node = nodes.new("ShaderNodeTexImage")
|
||||
tex_node.image = img
|
||||
nodes.active = tex_node
|
||||
tex_node.select = True
|
||||
|
||||
bsdf = next((n for n in nodes if n.type == "BSDF_PRINCIPLED"), None)
|
||||
bsdf_input = bsdf.inputs["Base Color"] if bsdf else None
|
||||
if bsdf is not None:
|
||||
for link in list(links):
|
||||
if link.to_node == bsdf and link.to_socket == bsdf_input:
|
||||
links.remove(link)
|
||||
links.new(tex_node.outputs["Color"], bsdf_input)
|
||||
|
||||
scene = bpy.context.scene
|
||||
scene.render.engine = "CYCLES"
|
||||
scene.cycles.device = "CPU"
|
||||
scene.cycles.samples = 1
|
||||
scene.render.bake.use_pass_direct = False
|
||||
scene.render.bake.use_pass_indirect = False
|
||||
scene.render.bake.use_pass_color = True
|
||||
scene.render.bake.margin = 8
|
||||
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
human.select_set(True)
|
||||
bpy.context.view_layer.objects.active = human
|
||||
bpy.ops.object.bake(type="DIFFUSE", pass_filter={"COLOR"}, use_clear=True)
|
||||
img.filepath = "/tmp/gigline/T_Skin_Diffuse.png"
|
||||
img.filepath_raw = img.filepath
|
||||
img.file_format = "PNG"
|
||||
img.save()
|
||||
log(f"bake: baked {size}x{size} diffuse -> {img.filepath} (filepath now set for FBX)")
|
||||
|
||||
# MPFB's v2 skin is a node-group tree the FBX exporter cannot map to a
|
||||
# texture. Build a flat export material carrying the baked diffuse so the
|
||||
# FBX (and therefore Unreal) gets a real texture.
|
||||
export_mat = bpy.data.materials.new("M_Character_Export")
|
||||
export_mat.use_nodes = True
|
||||
nt = export_mat.node_tree
|
||||
nt.nodes.clear()
|
||||
out = nt.nodes.new("ShaderNodeOutputMaterial")
|
||||
bsdf = nt.nodes.new("ShaderNodeBsdfPrincipled")
|
||||
tex = nt.nodes.new("ShaderNodeTexImage")
|
||||
tex.image = img
|
||||
nt.links.new(tex.outputs["Color"], bsdf.inputs["Base Color"])
|
||||
nt.links.new(bsdf.outputs["BSDF"], out.inputs["Surface"])
|
||||
human.data.materials.clear()
|
||||
human.data.materials.append(export_mat)
|
||||
log("export material: M_Character_Export with baked texture assigned")
|
||||
|
||||
|
||||
def export_fbx(human, path):
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
# Named export preset "ue_character_v1" — change only via a new preset name.
|
||||
bpy.ops.export_scene.fbx(
|
||||
filepath=path,
|
||||
use_selection=False,
|
||||
object_types={"ARMATURE", "MESH"},
|
||||
apply_unit_scale=True,
|
||||
apply_scale_options="FBX_SCALE_NONE",
|
||||
axis_forward="-Y",
|
||||
axis_up="Z",
|
||||
use_space_transform=True,
|
||||
use_mesh_modifiers=True,
|
||||
add_leaf_bones=False,
|
||||
path_mode="COPY",
|
||||
embed_textures=False,
|
||||
mesh_smooth_type="OFF",
|
||||
bake_space_transform=False,
|
||||
)
|
||||
log(f"export: FBX preset ue_character_v1 -> {path}")
|
||||
|
||||
|
||||
def main():
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--out", default="/tmp/gigline/character.fbx")
|
||||
parser.add_argument("--save-blend", default=None)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
enable_mpfb()
|
||||
human = build_character()
|
||||
bake_diffuse(human)
|
||||
if args.save_blend:
|
||||
os.makedirs(os.path.dirname(args.save_blend), exist_ok=True)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=args.save_blend)
|
||||
log(f"save: {args.save_blend}")
|
||||
export_fbx(human, args.out)
|
||||
|
||||
with open(REPORT, "w") as f:
|
||||
f.write("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,81 @@
|
||||
# Scale-test scene builder and FBX exporter for the Blender -> Unreal pipeline.
|
||||
#
|
||||
# Builds three 1 m cubes in the project geographic frame (EPSG:26911, origin
|
||||
# Hollywood x Highland, X east / Y north / Z up):
|
||||
# ScaleTest_A centred at (0, 0, 0.5) - origin + 1 m size reference
|
||||
# ScaleTest_B centred at (10, 0, 0.5) - +10 m east probe
|
||||
# ScaleTest_C centred at (0, 10, 0.5) - +10 m north probe
|
||||
# and exports them as one FBX with the named export preset "ue_metric_v1".
|
||||
#
|
||||
# Run headless (see Art/blender/README.md for the Flatpak invocation):
|
||||
# blender -b --factory-startup --python Tools/scale_test_export.py -- \
|
||||
# --out /tmp/gigline/scaletest.fbx --save-blend Art/blender/scaletest/scale-test-<date>.blend
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
|
||||
import bpy
|
||||
|
||||
|
||||
def build_scene():
|
||||
scene = bpy.context.scene
|
||||
for obj in list(bpy.data.objects):
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
|
||||
mat = bpy.data.materials.new("M_ScaleTest_Red")
|
||||
mat.use_nodes = True
|
||||
bsdf = mat.node_tree.nodes.get("Principled BSDF")
|
||||
bsdf.inputs["Base Color"].default_value = (0.8, 0.05, 0.05, 1.0)
|
||||
|
||||
specs = {
|
||||
"ScaleTest_A": (0.0, 0.0, 0.5),
|
||||
"ScaleTest_B": (10.0, 0.0, 0.5),
|
||||
"ScaleTest_C": (0.0, 10.0, 0.5),
|
||||
}
|
||||
for name, loc in specs.items():
|
||||
bpy.ops.mesh.primitive_cube_add(size=1.0, location=loc)
|
||||
obj = bpy.context.active_object
|
||||
obj.name = name
|
||||
obj.data.materials.append(mat)
|
||||
print(f"[scale_test] created {name} at {loc}")
|
||||
|
||||
|
||||
def export_fbx(path):
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
# Named export preset "ue_metric_v1" - change only via a new preset name.
|
||||
bpy.ops.export_scene.fbx(
|
||||
filepath=path,
|
||||
use_selection=False,
|
||||
object_types={"MESH"},
|
||||
apply_unit_scale=True,
|
||||
apply_scale_options="FBX_SCALE_NONE",
|
||||
axis_forward="-Y",
|
||||
axis_up="Z",
|
||||
use_space_transform=True,
|
||||
use_mesh_modifiers=True,
|
||||
mesh_smooth_type="OFF",
|
||||
path_mode="AUTO",
|
||||
bake_space_transform=False,
|
||||
)
|
||||
print(f"[scale_test] exported FBX preset ue_metric_v1 -> {path}")
|
||||
|
||||
|
||||
def main():
|
||||
argv = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--out", default="/tmp/gigline/scaletest.fbx")
|
||||
parser.add_argument("--save-blend", default=None)
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
build_scene()
|
||||
|
||||
if args.save_blend:
|
||||
os.makedirs(os.path.dirname(args.save_blend), exist_ok=True)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=args.save_blend)
|
||||
print(f"[scale_test] saved {args.save_blend}")
|
||||
|
||||
export_fbx(args.out)
|
||||
|
||||
|
||||
main()
|
||||
@@ -0,0 +1,117 @@
|
||||
# Unreal-side half of the MPFB character acceptance: imports the skeletal FBX
|
||||
# with the named import preset "ue_character_v1" and verifies the result.
|
||||
#
|
||||
# UnrealEditor-Cmd <uproject> -run=pythonscript -script=<repo>/Tools/ue_character_import.py \
|
||||
# -nullrhi -unattended -nopause -stdout
|
||||
|
||||
import unreal
|
||||
|
||||
FBX = "/tmp/gigline/character.fbx"
|
||||
DEST = "/Game/Test/Character"
|
||||
REPORT = "/tmp/ue-character-result.txt"
|
||||
lines = []
|
||||
|
||||
|
||||
def log(msg):
|
||||
lines.append(str(msg))
|
||||
|
||||
|
||||
def import_character():
|
||||
if unreal.EditorAssetLibrary.does_directory_exist(DEST):
|
||||
unreal.EditorAssetLibrary.delete_directory(DEST)
|
||||
task = unreal.AssetImportTask()
|
||||
task.filename = FBX
|
||||
task.destination_path = DEST
|
||||
task.automated = True
|
||||
task.save = True
|
||||
task.replace_existing = True
|
||||
|
||||
ui = unreal.FbxImportUI()
|
||||
ui.import_mesh = True
|
||||
ui.import_materials = True
|
||||
ui.import_textures = True
|
||||
ui.import_as_skeletal = True
|
||||
ui.import_animations = False
|
||||
|
||||
smd = ui.skeletal_mesh_import_data
|
||||
smd.import_uniform_scale = 1.0
|
||||
smd.convert_scene_unit = False
|
||||
smd.force_front_x_axis = False
|
||||
smd.normal_import_method = unreal.FBXNormalImportMethod.FBXNIM_IMPORT_NORMALS
|
||||
task.options = ui
|
||||
|
||||
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
|
||||
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
|
||||
|
||||
|
||||
def import_texture_and_material():
|
||||
# Interchange 5.8 does not create material/texture assets for skeletal
|
||||
# FBX imports; finish the baked-material chain explicitly.
|
||||
task = unreal.AssetImportTask()
|
||||
task.filename = "/tmp/gigline/T_Skin_Diffuse.png"
|
||||
task.destination_path = DEST
|
||||
task.automated = True
|
||||
task.save = True
|
||||
task.replace_existing = True
|
||||
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
|
||||
|
||||
tex = unreal.EditorAssetLibrary.load_asset(f"{DEST}/T_Skin_Diffuse")
|
||||
log(f"texture: {tex.get_class().get_name() if tex else 'MISSING'}")
|
||||
|
||||
mat_path = f"{DEST}/M_Character_Export"
|
||||
if unreal.EditorAssetLibrary.does_asset_exist(mat_path):
|
||||
unreal.EditorAssetLibrary.delete_asset(mat_path)
|
||||
mat = unreal.AssetToolsHelpers.get_asset_tools().create_asset(
|
||||
"M_Character_Export", DEST, unreal.Material, unreal.MaterialFactoryNew())
|
||||
tex_expr = unreal.MaterialEditingLibrary.create_material_expression(
|
||||
mat, unreal.MaterialExpressionTextureSample)
|
||||
tex_expr.set_editor_property("texture", tex)
|
||||
unreal.MaterialEditingLibrary.connect_material_property(
|
||||
tex_expr, "RGB", unreal.MaterialProperty.MP_BASE_COLOR)
|
||||
unreal.MaterialEditingLibrary.recompile_material(mat)
|
||||
|
||||
sm = unreal.EditorAssetLibrary.load_asset(f"{DEST}/character")
|
||||
entry = unreal.SkeletalMaterial()
|
||||
entry.set_editor_property("material_interface", mat)
|
||||
entry.set_editor_property("material_slot_name", "M_Character_Export")
|
||||
sm.set_editor_property("materials", [entry])
|
||||
unreal.EditorAssetLibrary.save_loaded_asset(sm)
|
||||
log("material assigned to slot 0 (via SkeletalMaterial.material_interface)")
|
||||
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
|
||||
|
||||
|
||||
def verify():
|
||||
sm = unreal.EditorAssetLibrary.load_asset(f"{DEST}/character")
|
||||
if sm is None:
|
||||
log(f"MISSING skeletal asset {DEST}/character; assets: {unreal.EditorAssetLibrary.list_assets(DEST)}")
|
||||
return
|
||||
log(f"class: {sm.get_class().get_name()}")
|
||||
try:
|
||||
skel = sm.get_editor_property("skeleton")
|
||||
tree = skel.get_editor_property("bone_tree")
|
||||
log(f"bones: {len(tree)}")
|
||||
except Exception as exc: # noqa
|
||||
log(f"bone count unavailable: {exc!r}")
|
||||
try:
|
||||
mats = sm.get_editor_property("materials")
|
||||
log(f"material slots: {len(mats)} -> {[m.get_editor_property('material_slot_name') for m in mats]}")
|
||||
except Exception as exc: # noqa
|
||||
log(f"material slots unavailable: {exc!r}")
|
||||
try:
|
||||
bounds = sm.get_editor_property("bounds")
|
||||
o, e = bounds.origin, bounds.box_extent
|
||||
log(f"bounds cm: size=({e.x * 2:.1f}, {e.y * 2:.1f}, {e.z * 2:.1f}) origin=({o.x:.1f}, {o.y:.1f}, {o.z:.1f})")
|
||||
except Exception as exc: # noqa
|
||||
log(f"bounds unavailable: {exc!r}")
|
||||
log(f"assets: {unreal.EditorAssetLibrary.list_assets(DEST)}")
|
||||
# materials actually imported as assets?
|
||||
for a in unreal.EditorAssetLibrary.list_assets(DEST):
|
||||
obj = unreal.EditorAssetLibrary.load_asset(a)
|
||||
log(f" {a} : {obj.get_class().get_name()}")
|
||||
|
||||
|
||||
import_character()
|
||||
import_texture_and_material()
|
||||
verify()
|
||||
with open(REPORT, "w") as f:
|
||||
f.write("\n".join(lines) + "\n")
|
||||
@@ -0,0 +1,60 @@
|
||||
# Unreal-side half of the scale test: imports the Blender scale-test FBX with
|
||||
# the named import preset "ue_metric_v1" and measures the result.
|
||||
#
|
||||
# Run headless:
|
||||
# <engine>/Engine/Binaries/Linux/UnrealEditor-Cmd <uproject> -run=pythonscript \
|
||||
# -script=<repo>/Tools/ue_scale_test_import.py -nullrhi -unattended -nopause -stdout
|
||||
|
||||
import unreal
|
||||
|
||||
FBX = "/tmp/gigline/scaletest.fbx"
|
||||
DEST = "/Game/Test/ScaleTest"
|
||||
|
||||
|
||||
def import_fbx():
|
||||
task = unreal.AssetImportTask()
|
||||
task.filename = FBX
|
||||
task.destination_path = DEST
|
||||
task.automated = True
|
||||
task.save = True
|
||||
task.replace_existing = True
|
||||
|
||||
ui = unreal.FbxImportUI()
|
||||
ui.import_mesh = True
|
||||
ui.import_materials = True
|
||||
ui.import_textures = False
|
||||
ui.import_as_skeletal = False
|
||||
smd = ui.static_mesh_import_data
|
||||
smd.import_uniform_scale = 1.0
|
||||
smd.combine_meshes = False
|
||||
smd.convert_scene_unit = False
|
||||
smd.force_front_x_axis = False
|
||||
smd.normal_import_method = unreal.FBXNormalImportMethod.FBXNIM_IMPORT_NORMALS
|
||||
task.options = ui
|
||||
|
||||
unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
|
||||
unreal.EditorLoadingAndSavingUtils.save_dirty_packages(True, True)
|
||||
|
||||
|
||||
def measure():
|
||||
# Named import preset "ue_metric_v1" measures in Unreal centimetres.
|
||||
lines = ["---- measured local-space bounds (cm) ----"]
|
||||
for name in ("ScaleTest_A", "ScaleTest_B", "ScaleTest_C"):
|
||||
path = f"{DEST}/{name}"
|
||||
asset = unreal.EditorAssetLibrary.load_asset(path)
|
||||
if asset is None:
|
||||
lines.append(f"MISSING asset {path}")
|
||||
continue
|
||||
box = asset.get_bounding_box()
|
||||
mn, mx = box.min, box.max
|
||||
lines.append(
|
||||
f"{name}: origin=({(mn.x + mx.x) / 2:.2f}, {(mn.y + mx.y) / 2:.2f}, {(mn.z + mx.z) / 2:.2f}) "
|
||||
f"size=({mx.x - mn.x:.2f}, {mx.y - mn.y:.2f}, {mx.z - mn.z:.2f})"
|
||||
)
|
||||
lines.append(f"assets: {unreal.EditorAssetLibrary.list_assets(DEST)}")
|
||||
with open("/tmp/ue-scale-test-result.txt", "w") as f:
|
||||
f.write("\n".join(lines))
|
||||
|
||||
|
||||
import_fbx()
|
||||
measure()
|
||||
Reference in new issue
Block a user