diff --git a/MEMORY.md b/MEMORY.md index 42e9f74..4db184d 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -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 (`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. +- **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 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 diff --git a/TODO.md b/TODO.md index fa9b099..cf0169a 100644 --- a/TODO.md +++ b/TODO.md @@ -770,12 +770,25 @@ regressing gear fit, budget or readability. 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 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 - modular pack is distributed as a Google Drive folder - (), which cannot be - fetched reproducibly by a script, so outfit variety needs either a manual intake of that pack - (with the CC0 licence + SHA-256 record) or new garments authored here. Decide which before - starting; a `bald` hair variant also needs a skull-covering crown on the `worker` base first. +- [-] **Outfit part library (owner chose the one-time manual download).** The upstream modular pack is + a Google Drive folder (); + the owner downloads it once, everything after that is scripted. Steps: + 1. download the pack and drop the archive (or its extracted folder) into + `tmp/quaternius-intake/` — that directory is gitignored; + 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 --mesh --out parts/.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 whole-body UV set, which caps how much the cloth/pants tints can differ. diff --git a/assets/models/blender/manifest.json b/assets/models/blender/manifest.json index c13b54d..b09f9b8 100644 --- a/assets/models/blender/manifest.json +++ b/assets/models/blender/manifest.json @@ -567,7 +567,7 @@ "min": [ -0.262, -0.004, - -0.091 + -0.092 ] }, "category": "npc", @@ -597,7 +597,7 @@ "hair" ], "triangles": 6242, - "vertices": 13100 + "vertices": 13110 }, "npc_crowd_d": { "base": "worker", diff --git a/assets/models/blender/npc/npc_crowd_c.glb b/assets/models/blender/npc/npc_crowd_c.glb index 707033a..33566d0 100644 Binary files a/assets/models/blender/npc/npc_crowd_c.glb and b/assets/models/blender/npc/npc_crowd_c.glb differ diff --git a/assets/models/blender/npc/npc_crowd_c_ao.png b/assets/models/blender/npc/npc_crowd_c_ao.png index 9c79099..abf295a 100644 Binary files a/assets/models/blender/npc/npc_crowd_c_ao.png and b/assets/models/blender/npc/npc_crowd_c_ao.png differ diff --git a/assets/models/blender_src/quaternius/parts/README.md b/assets/models/blender_src/quaternius/parts/README.md new file mode 100644 index 0000000..6c39edc --- /dev/null +++ b/assets/models/blender_src/quaternius/parts/README.md @@ -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 +(, 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/.glb --mesh --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)_ | | | | diff --git a/docs/asset-tooling.md b/docs/asset-tooling.md index b18e4c3..10f6852 100644 --- a/docs/asset-tooling.md +++ b/docs/asset-tooling.md @@ -302,6 +302,24 @@ python3 tools/blender_assets.py texels --json # machine-readable 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 --mesh --part ` | 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) The local map workstreams (H–M) in [`TODO.md`](../TODO.md#3d-model-re-evaluation) are diff --git a/tools/blender_assets.py b/tools/blender_assets.py index 6dae5a8..6ac4bf2 100644 --- a/tools/blender_assets.py +++ b/tools/blender_assets.py @@ -1604,6 +1604,47 @@ def _purge_unused_materials(obj): 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): """Give every Quaternius head a readable mouth. @@ -2158,7 +2199,7 @@ def _quaternius_work_dir(): 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. 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 # and still need different slot maps (the delivery vest keeps its hi-vis). key = base + "-" + hair_style + if parts: + key += "-" + "-".join(parts) if overrides: key += "-" + "-".join(f"{k}:{v}" for k, v in sorted(overrides.items())) 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.update(overrides) + _join_quaternius_parts(obj, parts) if source.get("donor_head"): _transplant_worker_head(obj) _add_quaternius_mouth(obj) @@ -2264,7 +2308,8 @@ def build_quaternius_character(name): """Bake one roster character from its CC0 Quaternius source.""" spec = CHARACTERS[name] 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() bpy.ops.import_scene.gltf(filepath=str(tpose)) arm = next(o for o in bpy.context.scene.objects if o.type == "ARMATURE") diff --git a/tools/quaternius_parts.py b/tools/quaternius_parts.py new file mode 100644 index 0000000..d5cb3bf --- /dev/null +++ b/tools/quaternius_parts.py @@ -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("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 --mesh --part ` 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())