diff --git a/CHANGELOG.md b/CHANGELOG.md index df8147e..adc496d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ Semantic Versioning. - Hands, head gear and carried items were re-fitted to the new characters: hand items sit in the palm instead of floating at the wrist, the lanyard moved up to the sternum, back gear moved up to shoulder-blade height, and the clipboard is carried by its top edge. +- The influencer character now wears a formal suit from the modular outfit pack instead of a + tinted hoodie, the first of the modular outfits to ship. - NPCs no longer all share one hairstyle: the roster now uses short, buzz and long hair silhouettes instead of a single shell in twelve colours. - Eyes now have whites and pupils instead of flat dark rectangles. diff --git a/MEMORY.md b/MEMORY.md index 4db184d..ce8d030 100644 --- a/MEMORY.md +++ b/MEMORY.md @@ -130,6 +130,17 @@ 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. +- **FBX modular parts need five fixes before they are a base (2026-11-06):** (1) every FBX file + carries its own armature — keep one and repoint the meshes; (2) FBX adds `_end` leaf bones and a + `neutral_bone` root, and **Godot refuses the GLB entirely** (`resource_format_binary` / missing + `.scn`) until they are stripped to the 62 joints; (3) bake object transforms before export or the + glTF node transforms change the rest pose and the head renders several times too large; + (4) the pack reuses one fabric material for jacket and trousers — copy it per leg mesh + (`*_legs`) so the slot map can send it to `pants`; (5) imported parts share seams, so dissolve + and drop zero-area triangles or the audit fails. +- **`tmp/` needs a `.gdignore`.** Godot scans the project tree, so a downloaded asset pack under + `tmp/quaternius-intake/` gets imported (it flooded the import log and broke `--import` for the + rest of the project). An empty `tmp/.gdignore` stops it; capture output there is unaffected. - **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 diff --git a/TODO.md b/TODO.md index cf0169a..b65a442 100644 --- a/TODO.md +++ b/TODO.md @@ -770,25 +770,21 @@ 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 (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. +- [x] **Outfit part library — intake landed (`Suit` pilot).** The owner downloaded the + *Ultimate Modular Men* pack (CC0, 15 families, FBX only) into `tmp/quaternius-intake/`. + `tools/quaternius_parts.py make-base` composes a family into a skinned base GLB, undoing five + FBX artefacts (per-file armatures, `_end`/`neutral_bone`, node transforms, the shared + jacket/trouser fabric, seam degenerates). `Suit.glb` is the first base: `npc_coat` is now a + dark formal suit instead of a tinted hoodie. The pitfalls are recorded in + `docs/npc-model-sourcing.md` and `MEMORY.md`. +- [ ] **Remaining outfit families.** The pack also has Adventurer, Beach, Casual_2, Farmer, King, + Punk, SpaceSuit, Swat and Weapons (pistol/sword — unused, the game has no weapon system). + Pick the roles each one fits, add a `QUATERNIUS_SOURCES` entry per family with its slot map, + then rebuild. `Swat` and `SpaceSuit` have **no hair or eye geometry** (helmeted heads), so + they need the five-slot question answered first: hair would be an invisible patch under the + helmet, or the audit loses a slot. +- [x] Intake tooling: `inventory` / `extract` / `verify` / `make-base` in + `tools/quaternius_parts.py`, all exercised against the real pack. - [ ] 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 b09f9b8..4d15713 100644 --- a/assets/models/blender/manifest.json +++ b/assets/models/blender/manifest.json @@ -429,30 +429,31 @@ "vertices": 13100 }, "npc_coat": { - "base": "hoodie", + "base": "suit", "bounds_godot": { "max": [ - 0.262, - 1.866, - 0.287 + 0.255, + 1.809, + 0.279 ], "min": [ - -0.262, - -0.004, - -0.092 + -0.255, + -0.001, + -0.083 ] }, "category": "npc", "layer": "coat", "materials": [ "skin", + "Tie", "cloth", + "White", "accessory", "Eyebrows", - "sclera", "hair", + "sclera", "pants", - "mouth", "pupil" ], "mesh_node": "npc_base", @@ -469,8 +470,8 @@ "accessory", "hair" ], - "triangles": 6242, - "vertices": 13110 + "triangles": 6582, + "vertices": 13607 }, "npc_crowd_a": { "base": "casual", diff --git a/assets/models/blender/npc/npc_coat.glb b/assets/models/blender/npc/npc_coat.glb index 33566d0..29cd7e8 100644 Binary files a/assets/models/blender/npc/npc_coat.glb and b/assets/models/blender/npc/npc_coat.glb differ diff --git a/assets/models/blender/npc/npc_coat_ao.png b/assets/models/blender/npc/npc_coat_ao.png index abf295a..5e79749 100644 Binary files a/assets/models/blender/npc/npc_coat_ao.png and b/assets/models/blender/npc/npc_coat_ao.png differ diff --git a/assets/models/blender_src/quaternius/Suit.glb b/assets/models/blender_src/quaternius/Suit.glb new file mode 100644 index 0000000..7e3969d Binary files /dev/null and b/assets/models/blender_src/quaternius/Suit.glb differ diff --git a/docs/npc-model-sourcing.md b/docs/npc-model-sourcing.md index a1fdb57..0e0179a 100644 --- a/docs/npc-model-sourcing.md +++ b/docs/npc-model-sourcing.md @@ -281,6 +281,36 @@ Head gear is authored for this head: `HEAD_GEAR_SCALE = 0.64` and `HEAD_GEAR_LIF 0.315 m procedural head onto the 0.201–0.214 m Quaternius head. The procedural fallback therefore wears slightly small hats. +## Modular outfit intake (2026-11-06) + +The owner downloaded the *Ultimate Modular Men* pack once (CC0 1.0, 54.5 MB zip, 15 outfit families +plus weapons and a horse) into the gitignored `tmp/quaternius-intake/`. The pack ships FBX, not GLB, +so the intake composes each family into a single skinned base: + + python3 tools/quaternius_parts.py make-base \ + --family "tmp/quaternius-intake/Ultimate Modular Men- Feb 2022/Separate Skeletal Meshes and Animations/Suit" \ + --out assets/models/blender_src/quaternius/Suit.glb + +What `make-base` has to undo, and why it is not a straight import: + +1. each of the four FBX files carries its own copy of the armature — one is kept and the meshes are + repointed at it, otherwise `join` leaves three meshes bound to deleted armatures; +2. the FBX rig adds zero-length `_end` leaf bones and a `neutral_bone` root; Godot's importer fails + the exported GLB with them (`resource_format_binary` error), so they are stripped back to the + accepted 62 joints; +3. the object transforms are baked before export, or the glTF node transforms change the rest pose + on import and the character renders with a giant head; +4. the pack reuses one fabric material for jacket and trousers, so the leg meshes get their own + `*_legs` copies that the slot map sends to `pants`; +5. imported parts share seams, which leaves zero-area triangles the audit rejects. + +The pack itself is never committed. Only the composed bases are, with their SHA-256 here and in +`CREDITS.md`. + +| Base | Source family | SHA-256 | Used by | +| --- | --- | --- | --- | +| `Suit.glb` | Suit | `674f2a63…` | `npc_coat` | + ## Contract mismatch The expensive part is the rig and proportions, not the mesh. The source has a diff --git a/src/game/npc_avatar.gd b/src/game/npc_avatar.gd index c79a225..b70c812 100644 --- a/src/game/npc_avatar.gd +++ b/src/game/npc_avatar.gd @@ -413,6 +413,11 @@ func _build_skinned_body(npc_type: int) -> bool: "npc_vest": # The hi-vis vest is a fixed material; cloth is the undershirt. pass + "npc_coat": + # The Suit base is the only formal outfit in the roster; the seeded + # street palette turns it pink, so it keeps a dark suit tone. + cloth = Color(0.13, 0.15, 0.20) + pants = Color(0.11, 0.12, 0.15) "npc_firefighter": cloth = Color(0.16, 0.17, 0.20) pants = Color(0.13, 0.14, 0.17) diff --git a/tools/blender_assets.py b/tools/blender_assets.py index 6ac4bf2..e3d430c 100644 --- a/tools/blender_assets.py +++ b/tools/blender_assets.py @@ -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) diff --git a/tools/quaternius_parts.py b/tools/quaternius_parts.py index d5cb3bf..52b6e94 100644 --- a/tools/quaternius_parts.py +++ b/tools/quaternius_parts.py @@ -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 --mesh --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)) diff --git a/tools/tests/test_blender_assets.py b/tools/tests/test_blender_assets.py index 6d0886c..cbdbbc7 100644 --- a/tools/tests/test_blender_assets.py +++ b/tools/tests/test_blender_assets.py @@ -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)