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:
zwitschi committed 2026-11-07 09:29:11 +01:00
1 parent 01939de9a0
commit 1c6b956435
21 files changed
+614 -33

No files matched your search

+64
View File
@@ -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))
+148
View File
@@ -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()
+81
View File
@@ -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()
+117
View File
@@ -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")
+60
View File
@@ -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()