Prepare the modular part intake: inventory, extract, verify, join and the part contract
This commit is contained in:
1 parent
7a46963e3f
commit
f6bb2b1de3
9 files changed
+388
-10
No files matched your search
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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)_ | | | |
|
||||||
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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())
|
||||||
Reference in new issue
Block a user