Prepare the modular part intake: inventory, extract, verify, join and the part contract
Full build / Import + headless tests (push) Successful in 41s
Full build / Full desktop build + optional release (push) Skipped

This commit is contained in:
zwitschi committed 2026-11-06 18:54:08 +01:00
1 parent 7a46963e3f
commit f6bb2b1de3
9 files changed
+388 -10

No files matched your search

+242
View File
@@ -0,0 +1,242 @@
#!/usr/bin/env python3
"""Intake for the Quaternius modular character parts.
The upstream modular pack is distributed as a Google Drive folder, so it cannot be
fetched reproducibly by CI. The owner downloads it once into the gitignored
`tmp/quaternius-intake/`; everything after that is scripted here:
# 1. what is in the download?
python3 tools/quaternius_parts.py inventory --dir tmp/quaternius-intake
# 2. keep one mesh as a part (a single skinned mesh on the 62-joint rig)
python3 tools/quaternius_parts.py extract \
--file tmp/quaternius-intake/Male_Modular.glb \
--mesh "Male_Hair_1" --part hair_1
# 3. record the provenance of what was kept
python3 tools/quaternius_parts.py verify \
--dir assets/models/blender_src/quaternius/parts
`inventory` and `verify` run outside Blender (they parse the GLB container);
`extract` needs Blender, because it re-parents the mesh and rewrites the skin.
Part contract: one mesh, the standard 62-joint vertex-group names, materials named
after a contract slot (`skin`, `cloth`, `pants`, `hair`, `accessory`) or a
fixed-colour detail. `blender_assets.py` refuses a part that breaks it.
"""
import argparse
import hashlib
import json
import struct
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
PARTS = ROOT / "assets" / "models" / "blender_src" / "quaternius" / "parts"
INTAKE = ROOT / "tmp" / "quaternius-intake"
# The runtime rig. A part that does not carry these groups cannot be skinned by
# `NpcAvatar`, so it is not a part for this project.
REQUIRED_GROUPS = ("Hips", "Chest", "Head", "UpperArm.L", "UpperArm.R",
"UpperLeg.L", "UpperLeg.R")
def _glb_json(path: Path):
"""Return (gltf_json, binary_length) from a GLB container."""
data = path.read_bytes()
magic, version, _length = struct.unpack("<III", data[:12])
if magic != 0x46546C67:
raise ValueError(f"{path}: not a GLB")
offset = 12
gltf = None
binary = 0
while offset < len(data):
chunk_length, chunk_type = struct.unpack("<II", data[offset:offset + 8])
chunk = data[offset + 8:offset + 8 + chunk_length]
if chunk_type == 0x4E4F534A: # JSON
gltf = json.loads(chunk)
elif chunk_type == 0x004E4942: # BIN
binary = chunk_length
offset += 8 + chunk_length + (-chunk_length % 4)
if gltf is None:
raise ValueError(f"{path}: no JSON chunk")
return gltf, binary
def _node_names(gltf):
return [n.get("name", "") for n in gltf.get("nodes", [])]
def _skin_bones(gltf):
nodes = _node_names(gltf)
return [nodes[j] for s in gltf.get("skins", []) for j in s.get("joints", [])]
def _mesh_node_names(gltf):
"""Node names that reference a mesh: the names a Blender import exposes."""
names = []
for node in gltf.get("nodes", []):
if "mesh" in node:
names.append(node.get("name", f"mesh{node['mesh']}"))
return names
def inventory_file(path: Path):
gltf, binary = _glb_json(path)
bones = _skin_bones(gltf)
materials = [m.get("name", "") for m in gltf.get("materials", [])]
node_names = _mesh_node_names(gltf)
meshes = []
for index, mesh in enumerate(gltf.get("meshes", [])):
tris = 0
verts = 0
skinned = True
for prim in mesh.get("primitives", []):
attributes = prim.get("attributes", {})
if "indices" in prim:
tris += gltf["accessors"][prim["indices"]]["count"] // 3
if "POSITION" in attributes:
verts += gltf["accessors"][attributes["POSITION"]]["count"]
if "JOINTS_0" not in attributes:
skinned = False
# Prefer the node name (what Blender shows and what `extract --mesh` takes).
name = node_names[index] if index < len(node_names) else mesh.get("name", "")
meshes.append({"name": name, "triangles": tris,
"vertices": verts, "skinned": skinned})
parts_ready = all(group in bones for group in REQUIRED_GROUPS)
return {
"file": str(path),
"bytes": path.stat().st_size,
"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
"meshes": meshes,
"bones": len(bones),
"rig_ready": parts_ready,
"materials": materials,
"binary_bytes": binary,
}
def inventory(root: Path):
files = sorted(p for p in root.rglob("*.glb"))
if not files:
print(f"no .glb under {root}\n"
f" download the pack into {INTAKE.relative_to(ROOT)}/ first "
f"(see TODO.md, 'Outfit part library')")
return 1
rows = []
for path in files:
try:
rows.append(inventory_file(path))
except (ValueError, KeyError) as error:
print(f"skip {path.name}: {error}")
print(f"{'file':44s} {'meshes':>6s} {'tris':>7s} {'bones':>5s} rig meshes")
for row in rows:
tris = sum(m["triangles"] for m in row["meshes"])
names = ", ".join(m["name"] for m in row["meshes"][:4])
print(f"{Path(row['file']).name[:44]:44s} {len(row['meshes']):6d} {tris:7d} "
f"{row['bones']:5d} {'yes' if row['rig_ready'] else 'NO ':5s} {names}")
ready = [r for r in rows if r["rig_ready"]]
print(f"\n{len(ready)}/{len(rows)} files carry the 62-joint rig and can be cut into parts.")
print("Next: `extract --file <glb> --mesh <name> --part <name>` for each part to keep.")
return 0
def verify(root: Path):
files = sorted(p for p in root.glob("*.glb"))
if not files:
print(f"no part files in {root.relative_to(ROOT)}")
return 1
print(f"{'part':32s} {'sha256':64s} {'tris':>6s} bones")
problems = 0
for path in files:
gltf, _binary = _glb_json(path)
bones = _skin_bones(gltf)
missing = [group for group in REQUIRED_GROUPS if group not in bones]
tris = sum(gltf["accessors"][p["indices"]]["count"] // 3
for mesh in gltf.get("meshes", [])
for p in mesh.get("primitives", {}) if "indices" in p)
digest = hashlib.sha256(path.read_bytes()).hexdigest()
status = "ok" if not missing else "MISSING " + ",".join(missing)
print(f"{path.stem[:32]:32s} {digest} {tris:6d} {len(bones):5d} {status}")
problems += 1 if missing else 0
print(f"\n{len(files)} parts, {problems} unusable. Record the hashes in CREDITS.md.")
return 1 if problems else 0
def extract(source: Path, mesh_name: str, part: str, out_dir: Path):
try:
import bpy # type: ignore
from mathutils import Matrix # type: ignore
except ImportError:
print("extract must run inside Blender:\n"
f" flatpak run org.blender.Blender --background --python {Path(__file__).name} "
f"-- extract --file {source} --mesh {mesh_name} --part {part}",
file=sys.stderr)
return 2
bpy.ops.wm.read_factory_settings(use_empty=True)
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]:
target = obj
break
if target is None:
names = [o.name for o in bpy.context.scene.objects if o.type == "MESH"]
print(f"mesh '{mesh_name}' not found; the file has: {', '.join(names)}")
return 1
# Keep the armature: a glTF mesh only carries JOINTS_0/WEIGHTS_0 when a skin
# references a skeleton, so a parts file without one validates as bone-less and
# cannot be skinned. The builder drops the part's armature when it joins the mesh
# onto the base body, which already owns the skeleton.
armour = next((o for o in bpy.context.scene.objects
if o.type == "ARMATURE" and o is not target), None)
for obj in list(bpy.context.scene.objects):
if obj is not target and obj is not armour:
bpy.data.objects.remove(obj, do_unlink=True)
for group in list(target.vertex_groups):
if group.name not in REQUIRED_GROUPS + (
"LowerArm.L", "LowerArm.R", "LowerLeg.L", "LowerLeg.R", "Wrist.L",
"Wrist.R", "Foot.L", "Foot.R", "Neck", "Shoulder.L", "Shoulder.R",
"Abdomen", "Torso", "Body", "Root", "PT.L", "PT.R"):
target.vertex_groups.remove(group)
out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / f"{part}.glb"
target.name = part
target.data.name = part
for obj in bpy.context.scene.objects:
obj.select_set(obj is target or obj is armour)
bpy.context.view_layer.objects.active = target
bpy.ops.export_scene.gltf(filepath=str(path), export_format="GLB",
use_selection=True, export_apply=False,
export_animations=False, export_skins=True,
export_yup=True, export_materials="EXPORT")
print(f"wrote {path.relative_to(ROOT)} ({len(target.data.vertices)} verts)")
return 0
def main(argv=None):
parser = argparse.ArgumentParser(description="Quaternius modular part intake")
sub = parser.add_subparsers(dest="cmd", required=True)
inv = sub.add_parser("inventory", help="list the GLBs in a download")
inv.add_argument("--dir", default=str(INTAKE))
ver = sub.add_parser("verify", help="hash and validate the kept parts")
ver.add_argument("--dir", default=str(PARTS))
ext = sub.add_parser("extract", help="write one mesh as a part (needs Blender)")
ext.add_argument("--file", required=True)
ext.add_argument("--mesh", required=True)
ext.add_argument("--part", required=True)
ext.add_argument("--out", default=str(PARTS))
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:]
args = parser.parse_args(argv)
if args.cmd == "inventory":
return inventory(Path(args.dir))
if args.cmd == "verify":
return verify(Path(args.dir))
return extract(Path(args.file), args.mesh, args.part, Path(args.out))
if __name__ == "__main__":
sys.exit(main())