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

+6
View File
@@ -130,6 +130,12 @@ canonical implemented-features list in `docs/development-status.md`.
gear library. The procedural library + MakeHuman head stay in the tree as the tested fallback gear library. The procedural library + MakeHuman head stay in the tree as the tested fallback
(`build --source procedural`), not in the shipped roster. Silhouette variety is the known (`build --source procedural`), not in the shipped roster. Silhouette variety is the known
regression — three meshes for twelve roles — and the modular part library is the `0.9.10` fix. regression — three meshes for twelve roles — and the modular part library is the `0.9.10` fix.
- **Modular part intake is a one-time manual download (2026-11-06, owner):** the upstream pack is a
Google Drive folder, so the owner downloads it once into the gitignored
`tmp/quaternius-intake/` and `tools/quaternius_parts.py` does the rest
(`inventory` → `extract` → `verify`). A part must be one skinned mesh on the 62-joint rig; a GLB
exported *without* an armature has no `JOINTS_0` and validates as bone-less, so `extract` keeps
the armature and `build` drops it again at join time.
- **Hair variety is built in-project (2026-11-06):** the upstream modular pack is a Google Drive - **Hair variety is built in-project (2026-11-06):** the upstream modular pack is a Google Drive
folder, so it cannot be fetched reproducibly; `QUATERNIUS_HAIR_PARTS` reshapes the one hair shell folder, so it cannot be fetched reproducibly; `QUATERNIUS_HAIR_PARTS` reshapes the one hair shell
each base ships (`short` identity, `buzz` squash, `long` stretch, `bald` removal) and the role's each base ships (`short` identity, `buzz` squash, `long` stretch, `bald` removal) and the role's
+19 -6
View File
@@ -770,12 +770,25 @@ regressing gear fit, budget or readability.
place (`short` identity, `buzz` squashed toward the hairline, `long` stretched from the crown), place (`short` identity, `buzz` squashed toward the hairline, `long` stretched from the crown),
and each role picks a style through the existing `CHARACTERS[...]["hair"]` field. The T-pose and each role picks a style through the existing `CHARACTERS[...]["hair"]` field. The T-pose
cache is keyed on the style. The roster now reads as three silhouettes instead of one. cache is keyed on the style. The roster now reads as three silhouettes instead of one.
- [ ] **Outfit part library.** Unchanged ceiling: three outfit meshes for twelve roles. The upstream - [-] **Outfit part library (owner chose the one-time manual download).** The upstream modular pack is
modular pack is distributed as a Google Drive folder a Google Drive folder (<https://drive.google.com/drive/folders/1USAAquX2JJWuA2m6zol0KUkFe3UkZ8zX>);
(<https://drive.google.com/drive/folders/1USAAquX2JJWuA2m6zol0KUkFe3UkZ8zX>), which cannot be the owner downloads it once, everything after that is scripted. Steps:
fetched reproducibly by a script, so outfit variety needs either a manual intake of that pack 1. download the pack and drop the archive (or its extracted folder) into
(with the CC0 licence + SHA-256 record) or new garments authored here. Decide which before `tmp/quaternius-intake/` — that directory is gitignored;
starting; a `bald` hair variant also needs a skull-covering crown on the `worker` base first. 2. `python3 tools/quaternius_parts.py inventory --dir tmp/quaternius-intake` — lists every GLB
with its meshes, triangles, bones and materials, so the usable parts are picked by
measurement instead of by filename;
3. `python3 tools/quaternius_parts.py extract --file <glb> --mesh <name> --out parts/<part>.glb`
writes one skinned part onto the 62-joint rig (the only accepted part shape);
4. `python3 tools/quaternius_parts.py verify --dir assets/models/blender_src/quaternius/parts`
records the SHA-256 of what was kept, then copy the accepted sources into
`assets/models/blender_src/quaternius/parts/` and add the rows to `CREDITS.md`;
5. assign parts per role in `CHARACTERS[...]["parts"]` and rebuild.
Nothing lands in `assets/` until step 5, and the pack itself is never committed.
Tooling is ready and proven: `inventory`/`extract`/`verify` work, and a scratch part joined
onto a base and exported cleanly before the pack arrived. The part contract is in
`assets/models/blender_src/quaternius/parts/README.md`. **Waiting on the owner's download.**
A `bald` hair variant also needs a skull-covering crown on the `worker` base first.
- [ ] Bake per-slot albedo/normal/ORM maps and a per-slot atlas; the five slots currently share one - [ ] Bake per-slot albedo/normal/ORM maps and a per-slot atlas; the five slots currently share one
whole-body UV set, which caps how much the cloth/pants tints can differ. whole-body UV set, which caps how much the cloth/pants tints can differ.
+2 -2
View File
@@ -567,7 +567,7 @@
"min": [ "min": [
-0.262, -0.262,
-0.004, -0.004,
-0.091 -0.092
] ]
}, },
"category": "npc", "category": "npc",
@@ -597,7 +597,7 @@
"hair" "hair"
], ],
"triangles": 6242, "triangles": 6242,
"vertices": 13100 "vertices": 13110
}, },
"npc_crowd_d": { "npc_crowd_d": {
"base": "worker", "base": "worker",
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 74 KiB

@@ -0,0 +1,54 @@
# Quaternius modular parts
Build inputs for outfit, hair and accessory variety. Committed like the three whole-body
sources, so `build` works from a fresh checkout without the upstream pack.
## Where they come from
The upstream *Ultimate Modular Characters* pack is distributed as a Google Drive folder
(<https://drive.google.com/drive/folders/1USAAquX2JJWuA2m6zol0KUkFe3UkZ8zX>, CC0 1.0). It cannot be
fetched reproducibly by a script, so the owner downloads it **once** into the gitignored
`tmp/quaternius-intake/`. Everything after that is scripted:
```sh
python3 tools/quaternius_parts.py inventory --dir tmp/quaternius-intake
python3 tools/quaternius_parts.py extract \
--file tmp/quaternius-intake/<file>.glb --mesh <mesh> --part <part>
python3 tools/quaternius_parts.py verify --dir assets/models/blender_src/quaternius/parts
```
`inventory` and `verify` run outside Blender; `extract` needs Blender, because it rewrites the skin.
The pack itself is never committed — only the parts that are actually used, each with its SHA-256
recorded here and in `CREDITS.md`.
## Part contract
A part is **one skinned mesh on the 62-joint rig**:
- one mesh object, parented to the rig's armature, carrying `JOINTS_0`/`WEIGHTS_0`;
- the standard joint names (`Hips`, `Chest`, `Head`, `UpperArm.L`, …). The rig check in
`quaternius_parts.py verify` fails a part that is missing any of them, because
`NpcAvatar` maps those bones;
- materials named after a contract slot (`skin`, `cloth`, `pants`, `hair`, `accessory`) so the
runtime tint reaches them, or a fixed-colour detail name that is deliberately not tinted;
- triangles counted against the character budget: base body plus every joined part must stay
inside 24,000 triangles and 24,000 glTF vertices.
The builder drops the part's own armature when it joins the mesh onto a base body, because the base
already owns the skeleton and two armatures would duplicate every bone. Weights survive the join
because Blender merges vertex groups by name.
## Assigning parts to a role
```python
"npc_vest": {"layer": "vest", "base": "worker", "parts": ["hivis_vest"], ...}
```
`build` joins those parts after the body join and before the material slot map, so the part's
materials go through the same slot mapping as the base. The T-pose cache is keyed on the part list.
## Kept parts
| Part | Source file | SHA-256 | Tris |
| --- | --- | --- | --- |
| _(none yet — the pack has not been intaken)_ | | | |
+18
View File
@@ -302,6 +302,24 @@ python3 tools/blender_assets.py texels --json # machine-readable
python3 tools/blender_assets.py texels --only npc_coat python3 tools/blender_assets.py texels --only npc_coat
``` ```
### Quaternius modular parts (outfit variety)
`tools/quaternius_parts.py` is the intake for the upstream modular pack, which ships as a Google
Drive folder and is therefore downloaded once by hand into `tmp/quaternius-intake/` (gitignored):
| Step | Command | Runs in |
| --- | --- | --- |
| list what the download contains | `python3 tools/quaternius_parts.py inventory --dir tmp/quaternius-intake` | plain Python (parses the GLB container) |
| cut one mesh out as a part | `... extract --file <glb> --mesh <name> --part <name>` | Blender (rewrites the skin) |
| hash and validate the kept parts | `... verify --dir assets/models/blender_src/quaternius/parts` | plain Python |
A part is one skinned mesh on the 62-joint rig; the contract and the join behaviour are in
`assets/models/blender_src/quaternius/parts/README.md`. `build` joins a role's
`CHARACTERS[...]["parts"]` onto its base body before the slot map, keeping the part's weights and
dropping its redundant armature. The pack is never committed — only the parts that are used, with
their SHA-256 in `CREDITS.md`.
## Local map development (planned) ## Local map development (planned)
The local map workstreams (H–M) in [`TODO.md`](../TODO.md#3d-model-re-evaluation) are The local map workstreams (H–M) in [`TODO.md`](../TODO.md#3d-model-re-evaluation) are
+47 -2
View File
@@ -1604,6 +1604,47 @@ def _purge_unused_materials(obj):
bpy.data.materials.remove(material) bpy.data.materials.remove(material)
## Outfit/hair parts cut from the modular pack (see `tools/quaternius_parts.py`).
## A part is one skinned mesh on the 62-joint rig; the builder joins it onto the base
## body, which already owns the skeleton, so the part's weights are kept as-is.
QUATERNIUS_PARTS_DIR = SRC_DIR / "quaternius" / "parts"
def _join_quaternius_parts(obj, part_names):
"""Join the named part files onto the body. No-ops until the pack is intaken."""
joined = []
for name in part_names:
path = QUATERNIUS_PARTS_DIR / f"{name}.glb"
if not path.is_file():
print(f" part '{name}' not found in {QUATERNIUS_PARTS_DIR.relative_to(ROOT)}; skipped")
continue
before = set(o.name for o in bpy.context.scene.objects)
bpy.ops.import_scene.gltf(filepath=str(path))
added = [o for o in bpy.context.scene.objects if o.name not in before]
meshes = [o for o in added if o.type == "MESH"]
if not meshes:
print(f" part '{name}' imported no mesh; skipped")
continue
# The part's own armature is redundant: the base body already has the skeleton,
# and joining two armatures would duplicate every bone.
for obj in added:
if obj.type == "ARMATURE":
bpy.data.objects.remove(obj, do_unlink=True)
elif obj.type == "MESH" and obj not in meshes:
bpy.data.objects.remove(obj, do_unlink=True)
for o in bpy.context.selected_objects:
o.select_set(False)
obj.select_set(True)
for mesh in meshes:
if mesh.name in bpy.data.objects:
mesh.select_set(True)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.join()
joined.append(name)
if joined:
print(f" joined parts: {', '.join(joined)}")
def _add_quaternius_mouth(obj): def _add_quaternius_mouth(obj):
"""Give every Quaternius head a readable mouth. """Give every Quaternius head a readable mouth.
@@ -2158,7 +2199,7 @@ def _quaternius_work_dir():
return path return path
def _quaternius_tpose_glb(base, overrides, hair_style="short"): def _quaternius_tpose_glb(base, overrides, hair_style="short", parts=()):
"""Export (and cache) the joined, slot-mapped, unwrapped T-pose base. """Export (and cache) the joined, slot-mapped, unwrapped T-pose base.
The arm-down bake only behaves symmetrically on our own export, because the The arm-down bake only behaves symmetrically on our own export, because the
@@ -2169,6 +2210,8 @@ def _quaternius_tpose_glb(base, overrides, hair_style="short"):
# The cache key has to cover the slot overrides: two roles can share a source # The cache key has to cover the slot overrides: two roles can share a source
# and still need different slot maps (the delivery vest keeps its hi-vis). # and still need different slot maps (the delivery vest keeps its hi-vis).
key = base + "-" + hair_style key = base + "-" + hair_style
if parts:
key += "-" + "-".join(parts)
if overrides: if overrides:
key += "-" + "-".join(f"{k}:{v}" for k, v in sorted(overrides.items())) key += "-" + "-".join(f"{k}:{v}" for k, v in sorted(overrides.items()))
path = _quaternius_work_dir() / f"tpose_{key}.glb" path = _quaternius_work_dir() / f"tpose_{key}.glb"
@@ -2205,6 +2248,7 @@ def _quaternius_tpose_glb(base, overrides, hair_style="short"):
slots = dict(source["slots"]) slots = dict(source["slots"])
slots.update(overrides) slots.update(overrides)
_join_quaternius_parts(obj, parts)
if source.get("donor_head"): if source.get("donor_head"):
_transplant_worker_head(obj) _transplant_worker_head(obj)
_add_quaternius_mouth(obj) _add_quaternius_mouth(obj)
@@ -2264,7 +2308,8 @@ def build_quaternius_character(name):
"""Bake one roster character from its CC0 Quaternius source.""" """Bake one roster character from its CC0 Quaternius source."""
spec = CHARACTERS[name] spec = CHARACTERS[name]
tpose = _quaternius_tpose_glb(spec["base"], spec.get("slots", {}), tpose = _quaternius_tpose_glb(spec["base"], spec.get("slots", {}),
str(spec.get("hair", "short"))) str(spec.get("hair", "short")),
tuple(spec.get("parts", ())))
reset_scene() reset_scene()
bpy.ops.import_scene.gltf(filepath=str(tpose)) bpy.ops.import_scene.gltf(filepath=str(tpose))
arm = next(o for o in bpy.context.scene.objects if o.type == "ARMATURE") arm = next(o for o in bpy.context.scene.objects if o.type == "ARMATURE")
+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())