Intake the modular outfit pack and ship the Suit base for npc_coat
This commit is contained in:
1 parent
f6bb2b1de3
commit
3b9e3b61d8
12 files changed
+254
-37
No files matched your search
+160
-2
@@ -117,10 +117,28 @@ def inventory_file(path: Path):
|
||||
}
|
||||
|
||||
|
||||
def inventory_fbx(files):
|
||||
"""The pack ships FBX parts; name-level listing, detail comes from Blender."""
|
||||
families = {}
|
||||
for path in files:
|
||||
family = path.parent.name
|
||||
families.setdefault(family, []).append(path.stem)
|
||||
print(f"{'outfit family':24s} parts")
|
||||
for family in sorted(families):
|
||||
print(f"{family[:24]:24s} {', '.join(sorted(families[family]))}")
|
||||
print(f"\n{len(files)} FBX parts in {len(families)} families.")
|
||||
print("Next: `extract --file <fbx> --mesh <name> --part <part>`; the mesh name is the "
|
||||
"node name inside the FBX (check with `--list`).")
|
||||
return 0
|
||||
|
||||
|
||||
def inventory(root: Path):
|
||||
fbx = sorted(p for p in root.rglob("*.fbx"))
|
||||
files = sorted(p for p in root.rglob("*.glb"))
|
||||
if not files and fbx:
|
||||
return inventory_fbx(fbx)
|
||||
if not files:
|
||||
print(f"no .glb under {root}\n"
|
||||
print(f"no .glb or .fbx under {root}\n"
|
||||
f" download the pack into {INTAKE.relative_to(ROOT)}/ first "
|
||||
f"(see TODO.md, 'Outfit part library')")
|
||||
return 1
|
||||
@@ -175,7 +193,10 @@ def extract(source: Path, mesh_name: str, part: str, out_dir: Path):
|
||||
file=sys.stderr)
|
||||
return 2
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.ops.import_scene.gltf(filepath=str(source))
|
||||
if source.suffix.lower() == ".fbx":
|
||||
bpy.ops.import_scene.fbx(filepath=str(source))
|
||||
else:
|
||||
bpy.ops.import_scene.gltf(filepath=str(source))
|
||||
target = None
|
||||
for obj in bpy.context.scene.objects:
|
||||
if obj.type == "MESH" and obj.name.split(".")[0] == mesh_name.split(".")[0]:
|
||||
@@ -215,6 +236,138 @@ def extract(source: Path, mesh_name: str, part: str, out_dir: Path):
|
||||
return 0
|
||||
|
||||
|
||||
def _base_name(name: str) -> str:
|
||||
"""Strip Blender's `.001` duplicate suffix."""
|
||||
head, sep, tail = name.rpartition(".")
|
||||
return head if sep and tail.isdigit() else name
|
||||
|
||||
|
||||
def make_base(family_dir: Path, out: Path):
|
||||
"""Compose one outfit family from its FBX parts into a single skinned GLB base.
|
||||
|
||||
The pack ships each outfit as four FBX files (Body/Legs/Feet/Head) that share one
|
||||
skeleton. Each file carries its own copy of that armature, so this keeps one and
|
||||
repoints every mesh at it before joining — otherwise `join` would leave three
|
||||
meshes bound to armatures that no longer exist.
|
||||
"""
|
||||
try:
|
||||
import bpy # type: ignore
|
||||
import bmesh # type: ignore
|
||||
except ImportError:
|
||||
print("make-base must run inside Blender", file=sys.stderr)
|
||||
return 2
|
||||
parts = sorted(family_dir.glob("*.fbx"))
|
||||
if not parts:
|
||||
print(f"no FBX under {family_dir}")
|
||||
return 1
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
for path in parts:
|
||||
bpy.ops.import_scene.fbx(filepath=str(path))
|
||||
armatures = [o for o in bpy.context.scene.objects if o.type == "ARMATURE"]
|
||||
meshes = [o for o in bpy.context.scene.objects if o.type == "MESH"]
|
||||
if not armatures or not meshes:
|
||||
print(f"{family_dir.name}: import produced no armature/mesh")
|
||||
return 1
|
||||
keep = armatures[0]
|
||||
for extra in armatures[1:]:
|
||||
bpy.data.objects.remove(extra, do_unlink=True)
|
||||
# FBX adds artefacts the runtime rig does not have: zero-length `_end` leaf bones
|
||||
# and a `neutral_bone` root. Godot's importer failed on the exported file with them
|
||||
# (`resource_format_binary` error), and the accepted rig is the 62 joints only.
|
||||
bpy.context.view_layer.objects.active = keep
|
||||
bpy.ops.object.mode_set(mode="EDIT")
|
||||
for bone in list(keep.data.edit_bones):
|
||||
if bone.name.endswith("_end") or bone.name == "neutral_bone":
|
||||
for child in list(bone.children):
|
||||
child.parent = bone.parent
|
||||
child.use_connect = False
|
||||
keep.data.edit_bones.remove(bone)
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
for mesh in meshes:
|
||||
mesh.parent = keep
|
||||
mesh.matrix_parent_inverse.identity()
|
||||
for modifier in list(mesh.modifiers):
|
||||
if modifier.type == "ARMATURE":
|
||||
mesh.modifiers.remove(modifier)
|
||||
modifier = mesh.modifiers.new("Armature", "ARMATURE")
|
||||
modifier.object = keep
|
||||
# The pack reuses one fabric material for jacket and trousers, so the joined base
|
||||
# would have no `pants` surface. Give the leg meshes their own material copies;
|
||||
# the slot map in blender_assets.py then maps those to `pants`.
|
||||
leg_copies = {}
|
||||
for mesh in meshes:
|
||||
if "Legs" not in mesh.name:
|
||||
continue
|
||||
for slot, material in enumerate(mesh.data.materials):
|
||||
if material is None:
|
||||
continue
|
||||
# `Suit` and `Suit.001` are the same fabric arriving from two FBX files;
|
||||
# normalise the name so every leg mesh shares one `Suit_legs` material.
|
||||
base = _base_name(material.name)
|
||||
if base not in leg_copies:
|
||||
copy = material.copy()
|
||||
copy.name = f"{base}_legs"
|
||||
leg_copies[base] = copy
|
||||
mesh.data.materials[slot] = leg_copies[base]
|
||||
for obj in bpy.context.selected_objects:
|
||||
obj.select_set(False)
|
||||
for mesh in meshes:
|
||||
mesh.select_set(True)
|
||||
bpy.context.view_layer.objects.active = meshes[0]
|
||||
if len(meshes) > 1:
|
||||
bpy.ops.object.join()
|
||||
body = bpy.context.object
|
||||
body.name = "npc_base"
|
||||
body.data.name = "npc_base"
|
||||
# Imported FBX parts share seams, which leaves zero-area triangles that the audit
|
||||
# rejects (and Godot warns about during LOD generation). Dissolve and drop them
|
||||
# after the join: nothing that small carries a meaningful weight.
|
||||
bm = bmesh.new()
|
||||
bm.from_mesh(body.data)
|
||||
bmesh.ops.dissolve_degenerate(bm, dist=2e-4, edges=bm.edges)
|
||||
bmesh.ops.triangulate(bm, faces=bm.faces[:], quad_method="BEAUTY",
|
||||
ngon_method="BEAUTY")
|
||||
bad = [f for f in bm.faces if f.calc_area() < 1e-10]
|
||||
if bad:
|
||||
bmesh.ops.delete(bm, geom=bad, context="FACES_ONLY")
|
||||
bm.to_mesh(body.data)
|
||||
bm.free()
|
||||
|
||||
# The FBX rig comes in at the pack's own scale; normalise to the 1.81 m the roster
|
||||
# uses so gear attach points and the pose maths stay valid.
|
||||
evaluated = body.evaluated_get(bpy.context.evaluated_depsgraph_get())
|
||||
zs = [(body.matrix_world @ v.co).z for v in evaluated.data.vertices]
|
||||
height = max(zs) - min(zs)
|
||||
scale = 1.81 / height if height > 0 else 1.0
|
||||
if abs(scale - 1.0) > 0.02:
|
||||
keep.scale = [component * scale for component in keep.scale]
|
||||
bpy.context.view_layer.update()
|
||||
print(f" scaled {family_dir.name} by {scale:.3f} (was {height:.3f} m)")
|
||||
|
||||
# Bake the armature and mesh object transforms into their data. An FBX import
|
||||
# keeps a scale/rotation on the objects, and the glTF exporter then writes node
|
||||
# transforms that change the rest pose on import (Godot rendered the head at
|
||||
# several times its size before this).
|
||||
for obj in bpy.context.selected_objects:
|
||||
obj.select_set(False)
|
||||
for obj in (keep, body):
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = keep
|
||||
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||||
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
for obj in bpy.context.scene.objects:
|
||||
obj.select_set(obj is body or obj is keep)
|
||||
bpy.context.view_layer.objects.active = body
|
||||
bpy.ops.export_scene.gltf(filepath=str(out), export_format="GLB",
|
||||
use_selection=True, export_apply=False,
|
||||
export_animations=False, export_skins=True,
|
||||
export_yup=True, export_materials="EXPORT")
|
||||
print(f"wrote {out.relative_to(ROOT)} ({len(body.data.vertices)} verts, "
|
||||
f"{len(keep.data.bones)} bones)")
|
||||
return 0
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description="Quaternius modular part intake")
|
||||
sub = parser.add_subparsers(dest="cmd", required=True)
|
||||
@@ -227,6 +380,9 @@ def main(argv=None):
|
||||
ext.add_argument("--mesh", required=True)
|
||||
ext.add_argument("--part", required=True)
|
||||
ext.add_argument("--out", default=str(PARTS))
|
||||
base = sub.add_parser("make-base", help="compose one outfit family as a base GLB")
|
||||
base.add_argument("--family", required=True, help="folder under the pack's Separate Skeletal Meshes")
|
||||
base.add_argument("--out", required=True)
|
||||
argv = list(argv if argv is not None else sys.argv[1:])
|
||||
if "--" in argv: # Blender hands the script path over before its own `--`
|
||||
argv = argv[argv.index("--") + 1:]
|
||||
@@ -235,6 +391,8 @@ def main(argv=None):
|
||||
return inventory(Path(args.dir))
|
||||
if args.cmd == "verify":
|
||||
return verify(Path(args.dir))
|
||||
if args.cmd == "make-base":
|
||||
return make_base(Path(args.family), Path(args.out))
|
||||
return extract(Path(args.file), args.mesh, args.part, Path(args.out))
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user