Feat/outline thickness and tp weapon hold #22

Merged
Dotts merged 43 commits from feat/outline-thickness-and-tp-weapon-hold into main 2026-07-27 23:22:53 -07:00
62 changed files with 8315 additions and 15 deletions
Showing only changes of commit fc9e6f4275 - Show all commits
Binary file not shown.
@@ -0,0 +1,9 @@
{
"name": "Anime Girl Rigged Anime model",
"uid": "fbccf5c5a7b244e7ab04fa44da19c621",
"author": "dequeijospizza",
"author_url": "https://sketchfab.com/dequeijospizza",
"license": "CC Attribution",
"license_slug": "by",
"source_url": "https://sketchfab.com/3d-models/anime-girl-rigged-anime-model-fbccf5c5a7b244e7ab04fa44da19c621"
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 637 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.
@@ -0,0 +1,9 @@
{
"name": "school anime girl (rigged)",
"uid": "6eaf44ff6ef7461583a5eccc301b88fc",
"author": "hikari",
"author_url": "https://sketchfab.com/hikari0135",
"license": "CC Attribution",
"license_slug": "by",
"source_url": "https://sketchfab.com/3d-models/school-anime-girl-rigged-6eaf44ff6ef7461583a5eccc301b88fc"
}
+731
View File
@@ -0,0 +1,731 @@
{
"roles": {
"hips": "Hips",
"head": "Bone.003_end",
"neck": "Neck",
"spine": [
"Spine",
"Chest",
"Upper Chest",
"Neck",
"Head",
"Bone.003",
"Bone.003_end"
],
"shoulder.R": "Right shoulder",
"shoulder.L": "Left shoulder",
"foot.R": "Right ankle",
"foot.L": "Left ankle",
"hand.R": "Right wrist",
"hand.L": "Left wrist",
"forearm.R": "Right elbow",
"forearm.L": "Left elbow",
"shin.R": "Right knee",
"shin.L": "Left knee",
"toe.R": "Right toe",
"toe.L": "Left toe_end",
"upper_arm.R": "Right arm",
"upper_arm.L": "Left arm",
"thigh.L": "Left leg",
"thigh.R": "Right leg"
},
"chains": [
{
"class": "breast",
"root_parent": "Upper Chest",
"bones": [
"Breast_L",
"J_Sec_L_Bust2",
"J_Sec_L_Bust2_end"
],
"tips": [
[
-0.0,
0.0,
0.0
],
[
2e-05,
5e-05,
-0.0
],
[
2e-05,
5e-05,
-0.0
]
],
"hulls": [
[],
[],
[]
],
"neighbours": [
{},
{},
{}
]
},
{
"class": "breast",
"root_parent": "Upper Chest",
"bones": [
"Breast_R",
"J_Sec_R_Bust2",
"J_Sec_R_Bust2_end"
],
"tips": [
[
0.0,
0.0,
-0.0
],
[
-3e-05,
5e-05,
-1e-05
],
[
-3e-05,
5e-05,
-1e-05
]
],
"hulls": [
[],
[],
[]
],
"neighbours": [
{},
{},
{}
]
},
{
"class": "hair",
"root_parent": "Head",
"bones": [
"J_Sec_Hair1_01",
"J_Sec_Hair2_01",
"J_Sec_Hair2_01_end"
],
"tips": [
[
-0.0,
-0.0,
-0.0
],
[
3e-05,
3e-05,
-4e-05
],
[
3e-05,
3e-05,
-4e-05
]
],
"hulls": [
[
[
-0.0,
-0.0,
-0.0
]
],
[
[
-0.0,
0.0,
0.0
]
],
[]
],
"neighbours": [
{
"J_Sec_Hair2_01": 14.335576471341188
},
{
"J_Sec_Hair1_01": 14.335576471341188
},
{}
]
},
{
"class": "hair",
"root_parent": "Head",
"bones": [
"J_Sec_Hair1_02",
"J_Sec_Hair2_02",
"J_Sec_Hair2_02_end"
],
"tips": [
[
0.0,
0.0,
-0.0
],
[
0.0,
2e-05,
-5e-05
],
[
0.0,
2e-05,
-5e-05
]
],
"hulls": [
[
[
-0.0,
0.0,
-0.0
]
],
[
[
-0.0,
0.0,
-0.0
]
],
[]
],
"neighbours": [
{
"J_Sec_Hair2_02": 13.644314260335037
},
{
"J_Sec_Hair1_02": 13.644314260335037
},
{}
]
},
{
"class": "hair",
"root_parent": "Head",
"bones": [
"J_Sec_Hair1_03",
"J_Sec_Hair2_03",
"J_Sec_Hair2_03_end"
],
"tips": [
[
0.0,
0.0,
-0.0
],
[
1e-05,
-0.0,
-6e-05
],
[
1e-05,
-0.0,
-6e-05
]
],
"hulls": [
[
[
0.0,
0.0,
0.0
]
],
[
[
-0.0,
0.0,
0.0
]
],
[]
],
"neighbours": [
{
"J_Sec_Hair2_03": 29.697010893706615
},
{
"J_Sec_Hair1_03": 29.697010893706615
},
{}
]
},
{
"class": "hair",
"root_parent": "Head",
"bones": [
"J_Sec_Hair1_04",
"J_Sec_Hair2_04",
"J_Sec_Hair2_04_end"
],
"tips": [
[
0.0,
0.0,
0.0
],
[
2e-05,
1e-05,
-6e-05
],
[
2e-05,
1e-05,
-6e-05
]
],
"hulls": [
[
[
-0.0,
-0.0,
0.0
]
],
[
[
0.0,
0.0,
-0.0
]
],
[]
],
"neighbours": [
{
"J_Sec_Hair2_04": 11.592366459380674
},
{
"J_Sec_Hair1_04": 11.592366459380674
},
{}
]
},
{
"class": "hair",
"root_parent": "Head",
"bones": [
"J_Sec_Hair1_05",
"J_Sec_Hair2_05",
"J_Sec_Hair2_05_end"
],
"tips": [
[
0.0,
0.0,
-0.0
],
[
1e-05,
2e-05,
-6e-05
],
[
1e-05,
2e-05,
-6e-05
]
],
"hulls": [
[
[
0.0,
0.0,
-0.0
]
],
[
[
-0.0,
0.0,
-0.0
]
],
[]
],
"neighbours": [
{
"J_Sec_Hair2_05": 11.876289952662745
},
{
"J_Sec_Hair1_05": 11.876289952662745
},
{}
]
},
{
"class": "hair",
"root_parent": "Head",
"bones": [
"J_Sec_Hair1_06",
"J_Sec_Hair2_06",
"J_Sec_Hair3_06",
"J_Sec_Hair4_06",
"J_Sec_Hair5_06",
"J_Sec_Hair5_06_end"
],
"tips": [
[
-0.0,
0.0,
-0.0
],
[
-0.0,
0.0,
0.0
],
[
-0.0,
0.0,
0.0
],
[
-0.0,
0.0,
0.0
],
[
0.0,
3e-05,
-5e-05
],
[
0.0,
3e-05,
-5e-05
]
],
"hulls": [
[
[
0.0,
0.0,
-0.0
]
],
[
[
-0.0,
0.0,
0.0
]
],
[
[
-0.0,
0.0,
-0.0
]
],
[
[
-0.0,
0.0,
0.0
]
],
[
[
-0.0,
0.0,
0.0
]
],
[]
],
"neighbours": [
{
"J_Sec_Hair2_06": 35.917357232526754
},
{
"J_Sec_Hair3_06": 35.52913589468082,
"J_Sec_Hair1_06": 35.917357232526754
},
{
"J_Sec_Hair4_06": 40.302343821289796,
"J_Sec_Hair2_06": 35.52913589468082
},
{
"J_Sec_Hair3_06": 40.302343821289796,
"J_Sec_Hair5_06": 40.950213036078026
},
{
"J_Sec_Hair4_06": 40.950213036078026
},
{}
]
},
{
"class": "hair",
"root_parent": "Head",
"bones": [
"J_Sec_Hair1_07",
"J_Sec_Hair2_07",
"J_Sec_Hair3_07",
"J_Sec_Hair4_07",
"J_Sec_Hair5_07",
"J_Sec_Hair5_07_end"
],
"tips": [
[
0.0,
0.0,
0.0
],
[
-0.0,
0.0,
-0.0
],
[
-0.0,
0.0,
0.0
],
[
-0.0,
0.0,
0.0
],
[
3e-05,
3e-05,
-3e-05
],
[
3e-05,
3e-05,
-3e-05
]
],
"hulls": [
[
[
0.0,
0.0,
-0.0
]
],
[
[
-0.0,
0.0,
-0.0
]
],
[
[
0.0,
0.0,
0.0
]
],
[
[
-0.0,
0.0,
0.0
]
],
[
[
0.0,
0.0,
-0.0
]
],
[]
],
"neighbours": [
{
"J_Sec_Hair2_07": 35.51566362438205
},
{
"J_Sec_Hair3_07": 35.31215403867316,
"J_Sec_Hair1_07": 35.51566362438205
},
{
"J_Sec_Hair4_07": 44.27519591590416,
"J_Sec_Hair2_07": 35.31215403867316
},
{
"J_Sec_Hair3_07": 44.27519591590416,
"J_Sec_Hair5_07": 40.6256217681383
},
{
"J_Sec_Hair4_07": 40.6256217681383
},
{}
]
},
{
"class": "hair",
"root_parent": "Head",
"bones": [
"J_Sec_Hair1_08",
"J_Sec_Hair2_08",
"J_Sec_Hair3_08",
"J_Sec_Hair3_08_end"
],
"tips": [
[
0.0,
0.0,
0.0
],
[
-0.0,
0.0,
0.0
],
[
4e-05,
-4e-05,
-1e-05
],
[
4e-05,
-4e-05,
-1e-05
]
],
"hulls": [
[
[
-0.0,
-0.0,
0.0
]
],
[
[
-0.0,
0.0,
0.0
]
],
[
[
-0.0,
-0.0,
0.0
]
],
[]
],
"neighbours": [
{
"J_Sec_Hair2_08": 2.5078807040660998
},
{
"J_Sec_Hair1_08": 2.5078807040660998,
"J_Sec_Hair3_08": 2.222614413427433
},
{
"J_Sec_Hair2_08": 2.222614413427433
},
{}
]
}
],
"twist": [
{
"bone": "Bone.004",
"parent": "Head",
"child": "Bone.004_end"
},
{
"bone": "Bone.005",
"parent": "Head",
"child": "Bone.005_end"
},
{
"bone": "Bone.006",
"parent": "Head",
"child": "Bone.006_end"
},
{
"bone": "Bone.007",
"parent": "Head",
"child": "Bone.007_end"
},
{
"bone": "Bone.008",
"parent": "Head",
"child": "Bone.008_end"
},
{
"bone": "Bone.009",
"parent": "Head",
"child": "Bone.009_end"
},
{
"bone": "Bone.010",
"parent": "Head",
"child": "Bone.010_end"
},
{
"bone": "Bone.011",
"parent": "Head",
"child": "Bone.011_end"
},
{
"bone": "Bone.012",
"parent": "Head",
"child": "Bone.012_end"
},
{
"bone": "Bone.013",
"parent": "Head",
"child": "Bone.013_end"
},
{
"bone": "Bone.001",
"parent": "Upper Chest",
"child": "Bone.001_end"
},
{
"bone": "Bone.002",
"parent": "Upper Chest",
"child": "Bone.002_end"
}
],
"colliders": [],
"weights_authored": true,
"driven_bones": [
"Bone.003",
"Bone.003_end",
"Chest",
"Head",
"Hips",
"Left ankle",
"Left arm",
"Left elbow",
"Left knee",
"Left leg",
"Left shoulder",
"Left toe_end",
"Left wrist",
"Neck",
"Right ankle",
"Right arm",
"Right elbow",
"Right knee",
"Right leg",
"Right shoulder",
"Right toe",
"Right wrist",
"Spine",
"Upper Chest"
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 337 KiB

Binary file not shown.
@@ -0,0 +1,9 @@
{
"name": "Kiyoko School Girl",
"uid": "072667d7b2b3468e9baff483b27c3a09",
"author": "Kasujin",
"author_url": "https://sketchfab.com/Kasujin",
"license": "CC Attribution",
"license_slug": "by",
"source_url": "https://sketchfab.com/3d-models/kiyoko-school-girl-072667d7b2b3468e9baff483b27c3a09"
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 220 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 896 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.
@@ -0,0 +1,9 @@
{
"name": "DANDADAN - Momo Ayase (3D Model) + DL",
"uid": "6ac6c3476a1f4b8da1c7de7e98a7c83c",
"author": "HiGuys920",
"author_url": "https://sketchfab.com/higuys920",
"license": "CC Attribution",
"license_slug": "by",
"source_url": "https://sketchfab.com/3d-models/dandadan-momo-ayase-3d-model-dl-6ac6c3476a1f4b8da1c7de7e98a7c83c"
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+28
View File
@@ -20,6 +20,34 @@
"description": "Quaternius reference mannequin (CC0)",
"model": "res://assets/characters/skins/mannequin.glb",
"unlocked": true
},
{
"id": "kiyoko",
"name": "Kiyoko",
"description": "Kiyoko School Girl \u2014 VRoid (CC-BY, Kasujin)",
"model": "res://assets/characters/skins/kiyoko.glb",
"unlocked": true
},
{
"id": "aria",
"name": "Aria",
"description": "Anime Girl Rigged \u2014 VRoid (CC-BY, dequeijospizza)",
"model": "res://assets/characters/skins/aria.glb",
"unlocked": true
},
{
"id": "momo",
"name": "Momo",
"description": "Momo Ayase, DANDADAN (CC-BY, HiGuys920)",
"model": "res://assets/characters/skins/momo.glb",
"unlocked": true
},
{
"id": "hikari",
"name": "Hikari",
"description": "School anime girl \u2014 VRoid (CC-BY, hikari)",
"model": "res://assets/characters/skins/hikari.glb",
"unlocked": true
}
]
}
+58 -6
View File
@@ -106,6 +106,25 @@ SPRING_CLASSES = {"hair", "skirt", "cloth", "ribbon", "tail", "cape", "coat",
"antenna", "wing"}
def spring_class(name):
"""Which secondary-motion class this bone belongs to, or None.
Whole token first, then a SHORT positional suffix — the same rule as
rig_map.is_cosmetic, and it must be the same rule, because a bone that reads
as cosmetic there and as nothing here is left out of every chain and its
geometry never moves. DANDADAN's hair is HairFL / HairFR / HairF_Top, which
tokenise to "hairfl" and matched no class at all: she imported with six
chains, all of them her bust, and not one strand of hair.
"""
for t in tokens(name):
if t in SPRING_CLASSES:
return t
for c in SPRING_CLASSES:
if len(t) - len(c) <= 2 and t.startswith(c) and len(t) > len(c):
return c
return None
# --------------------------------------------------------------------- import
@@ -680,8 +699,7 @@ def _body_points(meshes):
mw = m.matrix_world
for v in m.data.vertices:
cloth_w = sum(g.weight for g in v.groups
if any(t in SPRING_CLASSES
for t in tokens(gname.get(g.group, ""))))
if spring_class(gname.get(g.group, "")))
if cloth_w < 0.35:
out.append(mw @ v.co)
return out
@@ -880,7 +898,7 @@ def describe_rig(arm, roles, mapping, meshes):
chains = []
springy = {b.name: b for b in arm.data.bones
if b.name not in driven
and any(t in SPRING_CLASSES for t in tokens(b.name))}
and spring_class(b.name)}
neighbours = _cloth_neighbours(meshes, set(springy))
for name, bone in springy.items():
@@ -902,8 +920,7 @@ def describe_rig(arm, roles, mapping, meshes):
hulls.append(_hull_samples(arm, path[i], owned))
fallback = t
chains.append({
"class": next((t for t in tokens(path[0])
if t in SPRING_CLASSES), "cloth"),
"class": spring_class(path[0]) or "cloth",
"root_parent": bone.parent.name if bone.parent else None,
"bones": path,
"tips": tips,
@@ -1066,6 +1083,7 @@ def main():
arm.animation_data.nla_tracks.remove(track)
merged = 0
kept_clips = set()
for action in [a for a in bpy.data.actions if a not in before_actions]:
clip = LIBRARY_CLIP_MAP.get(action.name.split(".")[0])
if not clip:
@@ -1074,11 +1092,44 @@ def main():
baked = retarget_clip(src_arm, src_roles, arm, roles, mapping, action,
clip, yaw, scale)
add_nla_clip(arm, baked, clip)
kept_clips.add(clip)
merged += 1
if merged == 0:
print("ERROR: no clips retargeted")
sys.exit(1)
# Drop anything animated that we did not put here.
#
# Clearing bpy.data.actions before the library import is not enough: a
# character can carry its own clip in an NLA track that survives, and it is
# then exported alongside ours. `hikari` shipped two — " Girl|Loli Anime
# GirlAction" — which are keyed for a rig that no longer exists after the
# retarget, so they export as frozen rest-pose statues and the build fails a
# check that is right to fail it.
# Across EVERY object, not just the armature we retargeted onto. The export
# runs in NLA_TRACKS mode, so anything sitting in an NLA track anywhere in
# the file ships — including a second armature the model happened to carry,
# which is where hikari's two were hiding.
def _strip_foreign(ad):
if ad is None:
return
for track in list(ad.nla_tracks):
if not any(st.action and st.action.name in kept_clips
for st in track.strips):
ad.nla_tracks.remove(track)
if ad.action is not None and ad.action.name not in kept_clips:
ad.action = None
for obj in list(bpy.data.objects):
_strip_foreign(obj.animation_data)
if obj.type == "MESH" and obj.data.shape_keys:
_strip_foreign(obj.data.shape_keys.animation_data)
for a in list(bpy.data.actions):
if a.name not in kept_clips:
a.use_fake_user = False
bpy.data.actions.remove(a)
print(f"Kept {len(kept_clips)} clips; dropped everything else")
for o in new_objects:
bpy.data.objects.remove(o, do_unlink=True)
@@ -1149,7 +1200,8 @@ def subdivide_cloth_panels(arm, meshes, roles, segments=4):
for bone in arm.data.bones:
if not is_cosmetic(bone.name):
continue
if not any(t in SPRING_CLASSES and t != "hair" for t in tokens(bone.name)):
cls = spring_class(bone.name)
if cls is None or cls == "hair":
continue
if bone.children:
continue # already a chain — the artist's own topology wins
+88 -4
View File
@@ -97,8 +97,25 @@ def tokens(name: str):
def is_cosmetic(name: str) -> bool:
"""Whole-token match only — see the note on COSMETIC."""
return any(t in COSMETIC for t in tokens(name))
"""Whole-token match, plus a short positional suffix — see COSMETIC.
A bare token match misses the very common habit of gluing a position onto
the word: DANDADAN's hair bones are HairFL / HairFR / HairF_Top (front-left,
front-right, front-top), which tokenise to "hairfl" and match nothing, so
her hair was not detected as hair at all and none of it moved.
Only a SHORT remainder counts — two characters at most, which covers l/r/f/b
and the fl/fr/bl/br pairs and nothing else. That is what keeps the original
rule intact: "forearm" still does not begin with any cosmetic stem, and
"earring" has a four-character remainder and is not swept in by accident.
"""
for t in tokens(name):
if t in COSMETIC:
return True
for c in COSMETIC:
if len(t) - len(c) <= 2 and t.startswith(c) and len(t) > len(c):
return True
return False
def is_segment_of(name: str, all_names: set) -> bool:
@@ -131,11 +148,33 @@ class RigRoles:
self.head = None
self.neck = None
self._resolve_limbs()
self._fill_limbs_by_anatomy()
self._resolve_spine()
# ---------------------------------------------------------------- limbs
def _resolve_limbs(self):
"""Claim limb roles by the MOST SPECIFIC spelling that matches.
Not by walking LIMB_ORDER and taking the first role that matches at all.
That let a catch-all stem in an early role beat an exact one in a later
role, and the result depended on the order bones happened to arrive in:
J_Bip_L_UpperLeg canon "upperleg"
shin matches "leg" <- claimed it, because shin is first
thigh matches "upperleg" <- never consulted
J_Bip_L_LowerLeg canon "lowerleg"
shin already taken; nothing in thigh matches -> unclaimed
so a VRoid rig resolved with no thighs at all and the build stopped. The
same trap catches Mixamo, whose LeftUpLeg is a thigh and LeftLeg a shin —
it stayed hidden only because every character imported so far used the
Rigify DEF- spellings, where each role has an exact stem of its own.
Every plausible claim is collected, then granted longest-stem first, so
"upperleg" always beats "leg" whatever order the bones are in.
"""
claims = []
for name in self.names:
if is_cosmetic(name) or is_segment_of(name, self.names):
continue
@@ -144,11 +183,56 @@ class RigRoles:
continue
stem = canon(name)
for role in LIMB_ORDER:
if (role, side) in self.limb:
hits = [alt.replace("_", "") for alt in LIMB_ROLES[role]
if alt.replace("_", "") in stem]
if hits:
claims.append((len(max(hits, key=len)), role, side, name))
# Longest stem wins; LIMB_ORDER breaks ties, so a bone matching two roles
# equally well still resolves the way the list intends.
order = {role: i for i, role in enumerate(LIMB_ORDER)}
claims.sort(key=lambda c: (-c[0], order[c[1]]))
used = set()
for _, role, side, name in claims:
if (role, side) in self.limb or name in used:
continue
if any(alt.replace("_", "") in stem for alt in LIMB_ROLES[role]):
self.limb[(role, side)] = name
used.add(name)
# A leg is thigh -> shin -> foot, whatever the bones are called. Walk it.
#
# Names alone cannot settle this and it is not a matter of adding more
# spellings. A bare "leg" is the SHIN on a Mixamo rig (whose thigh is
# "UpLeg") and the THIGH on a rig whose shin is called "knee" — the same
# token means opposite bones, and both rigs are common. What does not vary
# is the skeleton: the foot's parent is the shin and the shin's parent is
# the thigh.
#
# Only ever FILLS IN what the names could not resolve; a confident name
# match is never overridden. Twist bones are stepped over, since a rig may
# put one between the thigh and the shin.
def _fill_limbs_by_anatomy(self):
for side in ("L", "R"):
chain = []
b = self.bones.get(self.limb.get(("foot", side), ""))
while b is not None and len(chain) < 6:
b = b.parent
if b is None or b.name not in self.names:
break
if is_cosmetic(b.name) or is_segment_of(b.name, self.names):
continue # a twist segment is not a joint of its own
chain.append(b.name)
# chain is now [shin, thigh, hips, ...] going up from the foot.
for role, up in (("shin", 0), ("thigh", 1)):
if (role, side) in self.limb or up >= len(chain):
continue
name = chain[up]
# Never claim a bone another role already owns, and never claim
# the trunk — a two-bone leg would otherwise take the hips.
if name in self.limb.values():
continue
if side_of(name) != side:
continue
self.limb[(role, side)] = name
# ---------------------------------------------------------------- spine
+45 -3
View File
@@ -48,6 +48,16 @@ PATH = argv[0]
SIDECAR = argv[1] if len(argv) > 1 else os.path.splitext(PATH)[0] + ".rig.json"
LEG_HINTS = ("thigh", "shin", "foot", "toe", "upleg", "calf")
# Resolved bone roles from the sidecar — filled in below. Every check that needs
# to know "which bones are the legs" asks THIS, not the name hints.
#
# The hints are a fallback for a build with no sidecar, and they are only ever
# right for rigs that happen to spell things the way the library does. A VRoid
# rig calls its thigh J_Bip_L_UpperLeg: no hint matches it, so the cross-leg
# bleed check found no leg vertex groups at all and PASSED vacuously, and the
# leg-motion check declared every locomotion clip static while the legs were in
# fact animating perfectly. Two green-looking lies from one missing lookup.
ROLE_LEGS = {"L": set(), "R": set()}
failures = []
warnings = []
# Set from the sidecar. A model whose source had no skeleton at all has to go
@@ -71,6 +81,10 @@ def check(ok, label, detail="", needs_authored_weights=False, advisory=False):
def side_of(name):
if name in ROLE_LEGS["L"]:
return -1
if name in ROLE_LEGS["R"]:
return 1
n = name.lower()
if not any(h in n for h in LEG_HINTS):
return 0
@@ -81,9 +95,17 @@ def side_of(name):
return 0
SIDECAR_INFO = {}
if os.path.exists(SIDECAR):
with open(SIDECAR, "r", encoding="utf-8") as f:
weights_authored = bool(json.load(f).get("weights_authored", True))
SIDECAR_INFO = json.load(f)
weights_authored = bool(SIDECAR_INFO.get("weights_authored", True))
_roles = SIDECAR_INFO.get("roles", {})
for _r in ("thigh", "shin", "foot", "toe"):
for _s in ("L", "R"):
_b = _roles.get(f"{_r}.{_s}")
if _b:
ROLE_LEGS[_s].add(_b)
bpy.ops.object.select_all(action="SELECT")
bpy.ops.object.delete()
@@ -193,6 +215,21 @@ if os.path.exists(SIDECAR):
cloth.update(c["bones"])
print(f" sidecar: {len(info.get('chains', []))} cloth chains "
f"({len(cloth)} bones), {len(info.get('twist', []))} twist bones")
# A chain whose bones have no measurable extent is DEAD: the runtime drops
# any bone whose tip is shorter than a millimetre, so such a chain simulates
# nothing and the costume is welded solid — silently, because the sidecar
# still reports it. hikari shipped ten of them; her cosmetic bones are
# zero-length terminators from a rig that had been through two toolchains.
dead = 0
for c in info.get("chains", []):
ext = max((sum(v * v for v in t) ** 0.5 for t in c.get("tips", [])),
default=0.0)
if ext < 0.001:
dead += 1
if info.get("chains"):
check(dead == 0, "cloth chains have measurable extent",
f"{dead} of {len(info['chains'])} chains are zero-length and will "
f"not simulate", advisory=True)
else:
warnings.append(f"no sidecar at {SIDECAR}")
@@ -202,6 +239,12 @@ else:
LOCOMOTION = {"Idle", "Walk", "Run", "Sprint", "Jump", "Fall", "Land",
"CrouchIdle", "CrouchWalk", "Dash"}
# The thighs and shins by ROLE, so this works on any rig's spelling.
leg_bones = {b for s in ROLE_LEGS.values() for b in s}
if not leg_bones:
leg_bones = {b.name for b in arm.data.bones
if any(h in b.name.lower() for h in ("thigh", "shin"))}
frozen = []
legless = []
keyed_cloth = set()
@@ -217,8 +260,7 @@ for name, action in sorted(actions.items()):
moved[b] = max(moved[b], max(vals) - min(vals))
if max(moved.values(), default=0.0) < 0.005:
frozen.append(name)
legs = max((v for b, v in moved.items()
if any(h in b.lower() for h in ("thigh", "shin"))), default=0.0)
legs = max((v for b, v in moved.items() if b in leg_bones), default=0.0)
if name in LOCOMOTION and legs < 0.01:
legless.append(f"{name}({legs:.4f})")