Intake the modular outfit pack and ship the Suit base for npc_coat
Full build / Import + headless tests (push) Successful in 42s
Full build / Full desktop build + optional release (push) Skipped

This commit is contained in:
zwitschi committed 2026-11-06 19:02:38 +01:00
1 parent f6bb2b1de3
commit 3b9e3b61d8
12 files changed
+254 -37

No files matched your search

+14 -2
View File
@@ -97,7 +97,7 @@ CHARACTERS = {
"npc_jacket": {"layer": "jacket", "hair": "short", "gear": ["lanyard", "clipboard"],
"base": "casual",
"roles": ["authorized_staff", "protester", "guest"]},
"npc_coat": {"layer": "coat", "hair": "long", "base": "hoodie",
"npc_coat": {"layer": "coat", "hair": "short", "base": "suit",
"gear": ["sunglasses", "shoulder_bag", "phone"],
"roles": ["influencer", "trespasser", "violent_threat"]},
"npc_vest": {"layer": "vest", "hair": "buzz", "gear": ["clipboard"],
@@ -1513,6 +1513,14 @@ QUATERNIUS_SOURCES = {
"Eye": "keep", "Eyebrows": "keep"},
"notes": "hoodie + shorts + white shoes (the hoodie covers the shoe uppers)",
},
"suit": {
"file": "Suit.glb",
"slots": {"Skin": "skin", "Suit": "cloth", "Suit_legs": "pants",
"Black": "accessory",
"Hair": "hair",
"Tie": "keep", "White": "keep", "Eye": "keep", "Eyebrows": "keep"},
"notes": "formal suit: jacket+trousers one fabric, white shirt and tie as fixed detail",
},
"worker": {
"file": "Worker.glb",
"slots": {"Skin": "skin", "LightBrown": "cloth", "Brown": "pants",
@@ -2083,7 +2091,11 @@ def _quaternius_slot_map(obj, slot_map):
index_of_slot = {}
resolved = []
for name, z in zip(source_names, centroids):
rule = slot_map.get(name, "keep")
# Imported parts carry `.001` duplicates of the base material; fall back to the
# base name so those still reach their slot instead of staying untinted.
rule = slot_map.get(name)
if rule is None:
rule = slot_map.get(_base_name(name), "keep")
if isinstance(rule, dict) and rule.get("split"):
slot = rule["above"] if z >= float(rule["z"]) else rule["below"]
key = ("keep", name) if slot == "keep" else ("slot", slot)
+160 -2
View File
@@ -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))
+5 -3
View File
@@ -60,13 +60,15 @@ class BuiltAssetTests(unittest.TestCase):
# (ADR-0018: the Quaternius base, 0.20 m head) moves them. The gear is
# authored for the shipped roster, so the ratio checks below are what
# must stay true; the absolute numbers are a drift guard.
self.assertAlmostEqual(coat["reference"]["head_width"], 0.201, places=2)
self.assertAlmostEqual(coat["reference"]["clothing_radius"], 0.19, places=3)
# npc_coat moved to the Suit base, whose head is 0.209 m wide; the ratio checks
# below are what must hold, the absolute numbers are a drift guard.
self.assertAlmostEqual(coat["reference"]["head_width"], 0.209, places=2)
self.assertAlmostEqual(coat["reference"]["clothing_radius"], 0.168, places=3)
sunglasses = next(row for row in coat["gear"] if row["name"] == "sunglasses")
self.assertEqual(sunglasses["attach"], "head")
self.assertLessEqual(sunglasses["width"],
0.70 * coat["reference"]["head_width"])
self.assertAlmostEqual(sunglasses["width_ratio"], 0.65, places=2)
self.assertAlmostEqual(sunglasses["width_ratio"], 0.62, places=2)
bag = next(row for row in coat["gear"] if row["name"] == "shoulder_bag")
self.assertEqual(bag["attach"], "back")
self.assertGreaterEqual(bag["x_extent"], 0.2)