Ship the WeChat hot-update catalogs and the camp, TideWhale T2, UI atlas, and animation-lab work.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
udx
2026-10-24 18:04:41 +08:00
co-authored by Cursor
parent 6ebc9401ce
commit 45e62132c8
1432 changed files with 2971856 additions and 2798 deletions
+63
View File
@@ -0,0 +1,63 @@
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory=$true)][string]$WebglRoot,
[string]$PatchZip = ''
)
$ErrorActionPreference = 'Stop'
if (-not $PatchZip) { $PatchZip = Join-Path $PSScriptRoot 'Client-BuddyDefense-15888.zip' }
$PatchZip = (Resolve-Path -LiteralPath $PatchZip).Path
$WebglRoot = (Resolve-Path -LiteralPath $WebglRoot).Path
if (-not (Test-Path -LiteralPath $WebglRoot -PathType Container)) { throw 'WebglRoot must be an existing website directory.' }
$versionPath = Join-Path $WebglRoot 'cdn/version.json'
if (-not (Test-Path -LiteralPath $versionPath) -and
-not (Get-ChildItem -LiteralPath $WebglRoot -Filter '*.webgl.data.unityweb.bin.txt' -File | Select-Object -First 1)) {
throw 'This does not look like the existing /game/Friends/webgl website folder. Choose the website folder, not the AIServer folder.'
}
Add-Type -AssemblyName System.IO.Compression.FileSystem
$archive = [IO.Compression.ZipFile]::OpenRead($PatchZip)
try {
$contents = @{}
foreach ($entry in $archive.Entries) {
$name = $entry.FullName.Replace('\','/')
if ($name -ne 'cdn/version.json' -and $name -notmatch '^yoo/DefaultPackage/[A-Za-z0-9_.-]+$') {
throw "Unexpected patch entry: $name"
}
if ($contents.ContainsKey($name) -or $entry.Length -gt 5242880) { throw "Invalid patch entry: $name" }
$reader = $entry.Open(); $memory = New-Object IO.MemoryStream
try { $reader.CopyTo($memory); $contents[$name] = $memory.ToArray() }
finally { $reader.Dispose(); $memory.Dispose() }
}
} finally { $archive.Dispose() }
if ($contents.Count -ne 5 -or -not $contents.ContainsKey('cdn/version.json')) { throw 'Expected the five-file hot-update patch.' }
$version = [Text.Encoding]::UTF8.GetString($contents['cdn/version.json']) | ConvertFrom-Json
if ($version.packVersion -notmatch '^[a-f0-9]{32}$') { throw 'Invalid packVersion.' }
$indexName = "yoo/DefaultPackage/DefaultPackage_$($version.packVersion).bytes"
if (-not $contents.ContainsKey($indexName)) { throw 'Version refers to a missing manifest.' }
$md5 = [Security.Cryptography.MD5]::Create()
try { $indexHash = [BitConverter]::ToString($md5.ComputeHash($contents[$indexName])).Replace('-','').ToLowerInvariant() }
finally { $md5.Dispose() }
if ($indexHash -ne $version.packVersion) { throw 'Manifest MD5 does not match packVersion. Nothing published.' }
$changed = 0
if ($PSCmdlet.ShouldProcess($WebglRoot,"Publish pack $($version.packVersion); write cdn/version.json last")) {
# Content-addressed resources must exist before exposing the new global version.
$names = @($contents.Keys | Where-Object { $_ -ne 'cdn/version.json' } | Sort-Object) + @('cdn/version.json')
foreach ($name in $names) {
$target = Join-Path $WebglRoot $name
if (Test-Path -LiteralPath $target) {
$old = [IO.File]::ReadAllBytes($target)
if ([Convert]::ToBase64String($old) -eq [Convert]::ToBase64String($contents[$name])) { continue }
}
New-Item -ItemType Directory -Path (Split-Path -Parent $target) -Force | Out-Null
if ($name -eq 'cdn/version.json') {
$temporary = "$target.publish-$([Guid]::NewGuid().ToString('N')).tmp"
try {
[IO.File]::WriteAllBytes($temporary,$contents[$name])
if (Test-Path -LiteralPath $target) { [IO.File]::Replace($temporary,$target,"$target.previous") }
else { [IO.File]::Move($temporary,$target) }
} finally { if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary } }
} else { [IO.File]::WriteAllBytes($target,$contents[$name]) }
$changed++
Write-Output "Published $name"
}
Write-Output "Done: packVersion=$($version.packVersion); changedFiles=$changed. Check the public cdn/version.json, then restart the mini game."
}
+57
View File
@@ -0,0 +1,57 @@
"""Author the animation comparison controls through the project's JSON UI pipeline."""
import json
from pathlib import Path
root=Path(__file__).resolve().parents[1]
nodes=[]
sprites={name:'Assets/Bundles/UI/Texture/Common/'+name+'.png' for name in [
'ui_panel_popup','btn_secondary_normal','btn_secondary_pressed','btn_primary_normal','btn_primary_pressed','btn_primary_disabled','progress_bg','progress_fill']}
def node(name,parent,rect,components):
nodes.append(dict(name=name,parent=parent,active=True,rect=rect,components=components))
def rect(x,y,w,h,anchor=(.5,1),pivot=(.5,1)):
return dict(anchorMin=anchor,anchorMax=anchor,pivot=pivot,anchoredPosition=[x,y],sizeDelta=[w,h])
def text(name,parent,value,x,y,w,h,size=28,color='#33405EFF'):
node(name,parent,rect(x,y,w,h),[dict(type='TextMeshProUGUI',text=value,fontSize=size,alignment='Center',color=color,raycastTarget=False)])
node('UI_CharacterAnimationLab',None,dict(anchorMin=[0,0],anchorMax=[1,1],offsetMin=[0,0],offsetMax=[0,0]),[
dict(type='Canvas',sortingOrder=100),dict(type='CanvasScaler',referenceResolution=[1080,1920],matchWidthOrHeight=.5),dict(type='GraphicRaycaster')])
node('Panel_Header','UI_CharacterAnimationLab',rect(0,-24,1032,188),[dict(type='Image',sprite='ui_panel_popup',imageType='Sliced',raycastTarget=False,color='#FFFFFFFF')])
text('Txt_Title','Panel_Header','角色动画对比',0,-12,960,58,42)
text('Txt_Subtitle','Panel_Header','相同骨架与动作 · 三版同步播放',0,-70,960,40,25)
for i,(label,color) in enumerate([('① Mixamo 网站','#1B8299FF'),('② TokenRig 原始','#B46A2AFF'),('③ 手部约束后','#458533FF')]):
text('Txt_Column'+str(i),'Panel_Header',label,(i-1)*336,-122,330,44,27,color)
node('Panel_Controls','UI_CharacterAnimationLab',rect(0,24,1032,600,(.5,0),(.5,0)),[dict(type='Image',sprite='ui_panel_popup',imageType='Sliced',raycastTarget=True,color='#FFFFFFFF')])
text('Txt_State','Panel_Controls','行走 · 三版同步对比',0,-12,978,46,30)
node('Btn_Seek','Panel_Controls',rect(0,-66,978,32),[dict(type='Image',sprite='progress_bg',imageType='Sliced',raycastTarget=True),
dict(type='MonoBehaviour',typeName='AIGame.Review.AnimationLabSeekBar')])
node('Img_Progress','Btn_Seek',dict(anchorMin=[0,0],anchorMax=[0,1],pivot=[0,.5],anchoredPosition=[0,0],sizeDelta=[2,0]),[
dict(type='Image',sprite='progress_fill',imageType='Sliced',raycastTarget=False)])
text('Txt_Time','Panel_Controls','0.00 / 1.03 秒',0,-104,978,34,23)
rows=[
[('Walk','行走'),('Breathe','呼吸待机'),('Happy','轻松待机')],
[('Play','暂停'),('Previous','上一帧'),('Next','下一帧'),('Restart','重播')],
[('Speed025','0.25×'),('Speed05','0.5×'),('Speed1','1×'),('Speed2','2×'),('Travel','原地循环')],
[('Front','正面'),('Side','侧面'),('Back','背面'),('Material','切灰模')],
[('All','三版并排'),('Focus1','看 ①'),('Focus2','看 ②'),('Focus3','看 ③'),('Detail','全身')]]
events=[]
for row,items in enumerate(rows):
width=(978-(len(items)-1)*12)/len(items)
for col,(key,label) in enumerate(items):
name='Btn_'+key
x=-489+width/2+col*(width+12)
primary=key in ('Play','All')
prefix='btn_primary' if primary else 'btn_secondary'
node(name,'Panel_Controls',rect(x,-148-row*76,width,62),[
dict(type='Image',sprite=prefix+'_normal',imageType='Sliced',raycastTarget=True),
dict(type='Button',transition='SpriteSwap',spritePressed=prefix+'_pressed',onClick='AnimationLab.'+key)])
node('Txt_'+key,name,dict(anchorMin=[0,0],anchorMax=[1,1],offsetMin=[3,1],offsetMax=[-3,-1]),[
dict(type='TextMeshProUGUI',text=label,fontSize=27,alignment='Center',fontStyle='Bold',color='#F6F8FFFF',raycastTarget=False),
dict(type='Outline',effectColor='#102033AA',effectDistance=[1,-1])])
events.append(dict(name='AnimationLab.'+key,node=name))
text('Txt_Help','Panel_Controls','拖动角色旋转 · 滚轮缩放 · 空格播放/暂停',0,-542,978,36,23)
document=dict(schemaVersion=1,prefabName='UI_CharacterAnimationLab',prefabPath='Assets/Bundles/UI/Prefab/UI_CharacterAnimationLab.prefab',
description='独立角色动画观看场景的同步播放、逐帧、进度、视角与放大控制。1080×1920;所有控件由 JSON→Prefab 创建。',
canvas=dict(canvasScaler=dict(referenceResolution=[1080,1920],matchWidthOrHeight=.5)),assets=dict(sprites=sprites),nodes=nodes,
bindings=['Txt_State','Txt_Time','Btn_Seek','Img_Progress']+[e['node'] for e in events],events=events)
path=root/'Doc/UIPrefabCreater/UI_CharacterAnimationLab.json'
path.write_text(json.dumps(document,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
print(path)
+215
View File
@@ -0,0 +1,215 @@
"""Render review WAVs from installed clips and a Pipeline mix snapshot.
This is an offline reconstruction, not a device/Unity loopback recording.
No normalization or limiter masks the installed gain balance. Production audio
and account data are read-only. Outputs remain outside Assets/Bundles.
"""
from __future__ import annotations
import hashlib
import json
import subprocess
from pathlib import Path
import imageio_ffmpeg
import numpy as np
from scipy.io import wavfile
from scipy.signal import resample_poly
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT / "Doc/Design/Sound/MixReview"
RATE = 48000
SOUND = ROOT / "AIClient/Assets/Bundles/Sound"
def load(path):
return json.loads(path.read_text(encoding="utf-8-sig"))
def sha(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
class Mixer:
def __init__(self, seconds, snapshot):
self.data = np.zeros((round(seconds * RATE), 2), dtype=np.float64)
self.events = []
self.cache = {}
self.sources = {}
self.voices = {"skill": [None] * 8, "enemy": [None] * 8}
self.next = {"skill": 0, "enemy": 0}
self.seen = set()
self.last_enemy = {}
self.snapshot = snapshot
self.now = 0
def clip(self, path):
path = path.resolve()
if path not in self.cache:
if path.suffix == ".wav":
rate, pcm = wavfile.read(path)
x = pcm.astype(np.float64) / (32768 if pcm.dtype == np.int16 else 1)
if x.ndim == 1:
x = np.repeat(x[:, None], 2, axis=1)
assert x.shape[1] == 2 and np.isfinite(x).all()
if rate != RATE:
from math import gcd
divisor = gcd(rate, RATE)
x = resample_poly(x, RATE // divisor, rate // divisor)
else:
decoded = subprocess.run([imageio_ffmpeg.get_ffmpeg_exe(), "-v", "error", "-i", str(path),
"-f", "f32le", "-ar", str(RATE), "-ac", "2", "-"], capture_output=True, check=True)
x = np.frombuffer(decoded.stdout, dtype="<f4").reshape(-1, 2).astype(np.float64)
self.cache[path] = x
self.sources[path.relative_to(ROOT).as_posix()] = sha(path)
return self.cache[path]
def advance(self, seconds):
end = min(len(self.data), round(seconds * RATE))
assert end >= self.now
for bank in self.voices.values():
for voice in bank:
if voice:
a, clip, gain, _, _ = voice
count = min(end - self.now, len(clip) - (self.now - a))
if count > 0:
self.data[self.now:self.now + count] += clip[self.now - a:self.now - a + count] * gain
self.now = end
def play(self, bank, path, seconds, cast=0, sound=-1):
self.advance(seconds)
if bank == "skill" and cast > 0:
if (cast, sound) in self.seen:
return
self.seen.add((cast, sound))
if bank == "enemy":
if seconds - self.last_enemy.get(sound, -100) < .075:
return
self.last_enemy[sound] = seconds
index = self.next[bank] % len(self.voices[bank])
self.next[bank] += 1
pcm = self.clip(path)
gain = self.snapshot["skill"][index]["volume"] if bank == "skill" else self.snapshot["enemyVoiceVolume"]
self.voices[bank][index] = [self.now, pcm, gain, cast, sound]
if bank == "enemy":
active = [v for v in self.voices[bank] if v and self.now - v[0] < len(v[1])]
gain = min(self.snapshot["enemyVoiceVolume"], self.snapshot["enemyMixBudget"] / len(active))
for voice in active:
voice[2] = gain
self.events.append(dict(time=round(seconds, 4), bank=bank, id=path.stem, voice=index, gain=gain, cast=cast))
def stop_charge(self, cast, seconds):
self.advance(seconds)
for i, voice in enumerate(self.voices["skill"]):
if voice and voice[3:] == [cast, 0]:
self.voices["skill"][i] = None
def one_shot(self, path, seconds, gain, bank="ui"):
pcm = self.clip(path)
start = round(seconds * RATE)
count = min(len(pcm), len(self.data) - start)
if count > 0:
self.data[start:start + count] += pcm[:count] * gain
self.events.append(dict(time=seconds, bank=bank, id=path.stem, gain=gain))
def save(self, name):
self.advance(len(self.data) / RATE)
peak = float(np.abs(self.data).max())
true_peak = float(np.abs(resample_poly(self.data, 4, 1)).max())
assert np.isfinite(self.data).all() and peak > .001, name + ": empty or invalid audio"
# Keep the raw float master so clipping cannot be hidden by the PCM conversion.
raw = OUT / (name + "_float.wav")
wavfile.write(raw, RATE, self.data.astype(np.float32))
path = OUT / (name + ".wav")
wavfile.write(path, RATE, np.round(np.clip(self.data, -1, 32767 / 32768) * 32768).astype(np.int16))
row = dict(file=path.name, floatMaster=raw.name, seconds=len(self.data) / RATE,
samplePeakDbfs=20 * np.log10(max(peak, 1e-12)), truePeak4xDbfs=20 * np.log10(max(true_peak, 1e-12)),
rmsDbfs=20 * np.log10(max(float(np.sqrt(np.mean(self.data ** 2))), 1e-12)),
overRangeSamples=int(np.count_nonzero(np.abs(self.data) >= 1)),
eventCount=len(self.events), maxSkillVoices=8, maxEnemyVoices=8, sha256=sha(path))
(OUT / (name + ".json")).write_text(json.dumps(dict(metrics=row, events=self.events, sources=self.sources), indent=2) + "\n", encoding="utf-8")
return row
def main():
OUT.mkdir(parents=True, exist_ok=True)
snapshot = load(OUT / "unity_snapshot.json")
plan = load(ROOT / "Config/Combat/partner_presentations_v1.json")
skills = [SOUND / ("SFX/BattleSkills/Sfx_" + s + ".wav") for s in plan["soundIds"]]
enemy_names = ["HornletAttack", "DashletAttack", "ShellhornAttack", "HornCaptainAttack", "GreatHornAttack", "HitSoft", "HitArmor", "HitShield"]
enemies = [SOUND / ("SFX/EnemyCombat/Enemy_" + s + ".wav") for s in enemy_names]
ui_names = ["Click", "Confirm", "Back", "Open", "Close", "Tab", "Claim", "Deny", "Toggle", "Notify"]
ui = [SOUND / ("SFX/UI/Ui_" + s + ".wav") for s in ui_names]
rows = []
mix = Mixer(17, snapshot)
mix.one_shot(ROOT / "AIClient" / snapshot["music"][0]["clip"], 0, snapshot["music"][0]["volume"], "music")
for i, path in enumerate(ui):
mix.one_shot(path, 1 + i * 1.4, snapshot["ui"][0]["volume"])
rows.append(mix.save("menu_ui"))
# Nine skill sounds in manifest order, separated at the exact installed per-voice gain.
mix = Mixer(16, snapshot)
for i, path in enumerate(skills):
mix.play("skill", path, .75 + i * 1.6, cast=i + 1, sound=i)
rows.append(mix.save("skill_bank"))
trace_path = ROOT / "Tools/output/combat_runtime/traces/stage_20_t2_f0.json"
trace = load(trace_path)
for speed in (1, 2):
mix = Mixer(26, snapshot)
art = {u["id"]: u["art"] for u in trace["manifest"]["initial"] if u["team"] == 1}
# PrototypeFlow drains enemy cues before skill cues on each 50ms simulation tick.
ticks = {}
for event in trace["events"]:
ticks.setdefault(event["tick"], []).append(event)
for tick, events in ticks.items():
seconds = .5 + tick * .05 / speed
if seconds >= 24:
break
mix.advance(seconds)
for e in events:
if e["kind"] == "spawn":
art[e["id"]] = e["art"]
if e["kind"] != "hit":
continue
source, target = e.get("source", -1), e.get("target", -1)
if source >= 100 and target < 100:
index = art[source]
mix.play("enemy", enemies[index], seconds, sound=index)
index = 7 if e.get("amount", 0) == 0 else 5
mix.play("enemy", enemies[index], seconds, sound=index)
elif source < 100 and target >= 100 and art[target] >= 2:
mix.play("enemy", enemies[6], seconds, sound=6)
for e in events:
source, cast, kind = e.get("source", -1), e.get("cast", 0), e["kind"]
if kind in ("release", "cancel", "cast_end"):
mix.stop_charge(cast, seconds)
if 0 <= source < 10:
p = plan["profiles"][source]
key = {"cast_start": "chargeSound", "release": "launchSound", "hit": "hitSound", "shield": "abilitySound", "heal": "abilitySound", "slow": "abilitySound"}.get(kind)
sound = p[key] if key else -1
if sound >= 0:
mix.play("skill", skills[sound], seconds, cast=cast, sound=sound)
if kind == "battle_end":
mix.voices = {"skill": [None] * 8, "enemy": [None] * 8}
if tick == 200:
mix.one_shot(ui[8], seconds, snapshot["ui"][0]["volume"])
rows.append(mix.save("battle_" + str(speed) + "x"))
mix = Mixer(8, snapshot)
for i in range(16):
seconds = 1 + i * .25
for sound in range(9):
mix.play("skill", skills[sound], seconds, cast=i * 10 + sound + 1, sound=sound)
for sound in range(5):
mix.play("enemy", enemies[sound], seconds, sound=sound)
mix.one_shot(ui[i % 10], seconds, snapshot["ui"][0]["volume"])
rows.append(mix.save("overlap_stress"))
result = dict(status="pass" if all(r["overRangeSamples"] == 0 and r["truePeak4xDbfs"] < 0 for r in rows) else "needs_adjustment",
method="offline reconstruction from installed clips, Pipeline snapshot and authoritative trace; not a loopback recording",
rendererAttempt="Unity AudioRenderer timed out; not used as acceptance evidence",
rate=RATE, channels=2, normalized=False, limiter=False, subjectivelyApproved=False,
snapshotSha256=sha(OUT / "unity_snapshot.json"), traceSha256=sha(trace_path), files=rows)
(OUT / "validation.json").write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8")
print(json.dumps(result, indent=2))
if __name__ == "__main__":
main()
+39
View File
@@ -0,0 +1,39 @@
"""Derive LmGirl's persistent comparison controls from the existing JSON UI."""
import json
import copy
from pathlib import Path
root=Path(__file__).resolve().parents[1]
folder=root/'Doc/UIPrefabCreater'
document=json.loads((folder/'UI_CharacterAnimationLab.json').read_text(encoding='utf-8'))
old='UI_CharacterAnimationLab';new='UI_LmGirlAnimationLab'
document['prefabName']=new;document['prefabPath']=document['prefabPath'].replace(old,new)
document['description']='LmGirl Mixamo 网站、TokenRig 原始、体素后处理、局部约束四版同动作比较。'
extra=copy.deepcopy(next(n for n in document['nodes'] if n['name']=='Txt_Column2'));extra['name']='Txt_Column3';document['nodes'].append(extra)
for original in [n for n in document['nodes'] if n['name'] in ['Btn_Focus3','Txt_Focus3']]:
node=copy.deepcopy(original);node['name']=node['name'].replace('Focus3','Focus4')
if node.get('parent'):node['parent']=node['parent'].replace('Focus3','Focus4')
for c in node['components']:
if c.get('type')=='Button':c['onClick']='AnimationLab.Focus4'
if c.get('type')=='TextMeshProUGUI':c['text']='看 ④'
document['nodes'].append(node)
document['bindings'].append('Btn_Focus4');document['events'].append({'name':'AnimationLab.Focus4','node':'Btn_Focus4'})
labels={'Txt_Title':'LmGirl 同动作蒙皮对比','Txt_Subtitle':'网站 33 骨 / TokenRig 41 骨 · 同步动作',
'Txt_Column0':'① Mixamo 网站','Txt_Column1':'② 原始预测','Txt_Column2':'③ 体素后处理','Txt_Column3':'④ 局部约束',
'Txt_All':'四版并排'}
focus_row=['Btn_All','Btn_Focus1','Btn_Focus2','Btn_Focus3','Btn_Focus4','Btn_Detail']
for node in document['nodes']:
node['name']=node['name'].replace(old,new)
if node.get('parent'):node['parent']=node['parent'].replace(old,new)
for component in node.get('components',[]):
if component.get('type')=='TextMeshProUGUI' and node['name'] in labels:component['text']=labels[node['name']]
if node['name'].startswith('Txt_Column'):
i=int(node['name'][-1]);node['rect']['anchoredPosition'][0]=(i-1.5)*252;node['rect']['sizeDelta'][0]=250
node['components'][0]['fontSize']=24
node['components'][0]['color']=['#1B8299FF','#B46A2AFF','#7066A0FF','#458533FF'][i]
if node['name'] in focus_row:
width=(978-5*10)/6;i=focus_row.index(node['name']);node['rect']['sizeDelta'][0]=width
node['rect']['anchoredPosition'][0]=-489+width/2+i*(width+10)
if node['name']=='Txt_All':node['components'][0]['fontSize']=24
(folder/(new+'.json')).write_text(json.dumps(document,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
print(folder/(new+'.json'))
+164
View File
@@ -0,0 +1,164 @@
"""CampGuide after Studio: game FBX, Mixamo TokenRig (40-50 bones), Kimodo in-place idle and walk."""
from __future__ import annotations
import json
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
STUDIO = Path(r"D:\UD\AI\XModel\LLM3DGraphics")
BLENDER = Path(r"D:\Tools\Blender\blender-5.0.0-windows-x64\blender.exe")
MESH = ROOT / "Tools/output/campguide_studio"
NAME = "CampGuideStudio"
UNITY = ROOT / "AIClient/Assets/Bundles/Characters/Human/CampGuideStudio"
RIG = MESH / "rig"
MOTIONS = [
("Idle", "a young boy stands in place and breathes gently, idle, feet planted, no walking"),
("Walk", "a young boy walks in place with alternating steps, no forward travel, gentle arm swing"),
]
CONVERT = r'''
import sys
from pathlib import Path
import bpy
from mathutils import Matrix
from math import pi
src, dst = [Path(p) for p in sys.argv[sys.argv.index("--") + 1:]]
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=str(src))
if any(o.type == "ARMATURE" for o in bpy.context.scene.objects):
raise SystemExit("expected a static mesh")
for obj in bpy.context.scene.objects:
if obj.parent is None:
obj.matrix_world = Matrix.Rotation(pi / 2, 4, "X") @ obj.matrix_world
dst.parent.mkdir(parents=True, exist_ok=True)
bpy.ops.export_scene.gltf(filepath=str(dst), export_format="GLB", export_yup=True)
print("OK", dst)
'''
def joints(glb: Path) -> list[str]:
data = glb.read_bytes()
if data[:4] != b"glTF":
raise RuntimeError(f"not a glb: {glb}")
import struct
json_size = struct.unpack_from("<I", data, 12)[0]
document = json.loads(data[20:20 + json_size])
names = []
for skin in document.get("skins") or []:
for index in skin.get("joints") or []:
names.append(document["nodes"][index].get("name") or "")
return names
def run(cmd, log: Path, timeout: int) -> None:
log.parent.mkdir(parents=True, exist_ok=True)
with log.open("w", encoding="utf-8") as handle:
completed = subprocess.run(cmd, stdout=handle, stderr=subprocess.STDOUT, timeout=timeout)
if completed.returncode != 0:
tail = log.read_text(encoding="utf-8", errors="replace")[-2000:]
raise RuntimeError(f"command failed ({completed.returncode}): {tail}")
def export_static() -> Path:
if not (MESH / "model_ex.glb").is_file():
raise SystemExit("Studio mesh is not ready")
fbx = MESH / "static" / f"{NAME}.fbx"
if not fbx.is_file():
run(
[str(BLENDER), "-b", "-t", "1", "--factory-startup", "--python",
str(ROOT / "Tools/model_tools/bl_prepare_studio_trial.py"), "--", str(MESH), NAME],
MESH / "static_export.log", 180,
)
sys.path.insert(0, str(ROOT / "Tools/model_tools"))
from mt_run import run_blender_script
from png_compress import compress_dir_pngs
xpbr = MESH / "xpbr"
if not (xpbr / f"{NAME}_base.png").is_file():
run_blender_script(str(BLENDER), "bl_convert.py", [str(fbx), str(xpbr), "1024"], blender_args=("-t", "1"))
compress_dir_pngs(str(xpbr), suffixes=("_base.png", "_mix.png"))
return fbx
def rig_mixamo(fbx: Path) -> Path:
sys.path.insert(0, str(STUDIO))
from backend.motion_branches import apply_mixamo_profile
from backend.tokenrig_runner import run_tokenrig
RIG.mkdir(parents=True, exist_ok=True)
source = RIG / "source.glb"
if not source.is_file():
script = RIG / "_convert.py"
script.write_text(CONVERT, encoding="utf-8")
run([str(BLENDER), "-b", "--factory-startup", "--python", str(script), "--", str(fbx), str(source)],
RIG / "convert.log", 180)
ready = RIG / "mixamo_ready.glb"
# The default vroid sample stopped at a 23-bone body. rignet is the densest
# complete Mixamo sample this mitten-handed mesh produced.
chosen = RIG / "rignet.glb"
fbx = RIG / "mixamo_ready.fbx"
if chosen.is_file() and not (RIG / "skeleton.json").is_file():
if fbx.is_file():
fbx.unlink()
apply_mixamo_profile(chosen, RIG, RIG / "tokenrig.log")
elif not ready.is_file():
rigged = Path(run_tokenrig(source, RIG, RIG / "tokenrig.log", skeleton_template="mixamo"))
apply_mixamo_profile(rigged, RIG, RIG / "tokenrig.log")
names = joints(ready)
mixamo = [name for name in names if name.startswith("mixamorig:")]
report = {"joints": len(names), "mixamo": len(mixamo), "names": names}
(RIG / "skeleton.json").write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
required = {"mixamorig:Hips", "mixamorig:Head", "mixamorig:LeftHand", "mixamorig:RightHand",
"mixamorig:LeftFoot", "mixamorig:RightFoot"}
if not required.issubset(names) or not 30 <= len(mixamo) <= 65:
raise SystemExit(f"Mixamo skeleton rejected: {len(mixamo)} mixamorig joints. See {RIG / 'skeleton.json'}")
if not (RIG / "mixamo_ready.fbx").is_file():
raise SystemExit("mixamo_ready.fbx was not exported")
return ready
def animate(ready: Path) -> None:
sys.path.insert(0, str(STUDIO))
from backend.kimodo_runner import submit_kimodo_job
for label, prompt in MOTIONS:
dest = RIG / label
if (dest / "animated.fbx").is_file():
continue
work = RIG / f"_work_{label}"
if work.exists():
shutil.rmtree(work)
work.mkdir()
submit_kimodo_job(rig_path=ready, prompt=prompt, directory=work, log_path=work / "kimodo.log", job_id=label)
dest.mkdir(parents=True, exist_ok=True)
for name in ("animated.fbx", "animated.glb", "kimodo_motion.bvh"):
shutil.copy2(work / name, dest / name)
def install() -> None:
for folder in (UNITY, UNITY / "Rigged", UNITY / "Anims"):
folder.mkdir(parents=True, exist_ok=True)
shutil.copy2(MESH / "xpbr" / f"{NAME}_base.png", UNITY / f"{NAME}_base.png")
shutil.copy2(MESH / "xpbr" / f"{NAME}_mix.png", UNITY / f"{NAME}_mix.png")
import zipfile
package = RIG / "mixamo_ready.fbx"
with zipfile.ZipFile(package) as archive:
archive.extract("model.fbx", UNITY / "Rigged")
(UNITY / "Rigged" / "model.fbx").replace(UNITY / "Rigged" / f"{NAME}_Rigged.fbx")
shutil.copy2(RIG / "Idle" / "animated.fbx", UNITY / "Anims" / f"{NAME}_Idle.fbx")
shutil.copy2(RIG / "Walk" / "animated.fbx", UNITY / "Anims" / f"{NAME}_Walk.fbx")
def main() -> int:
fbx = export_static()
ready = rig_mixamo(fbx)
animate(ready)
install()
skeleton = json.loads((RIG / "skeleton.json").read_text(encoding="utf-8"))
print(json.dumps({"ok": True, "mixamoJoints": skeleton["mixamo"], "unity": str(UNITY)}, ensure_ascii=False), flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+93
View File
@@ -0,0 +1,93 @@
"""CampGuide steps 02-05 from the existing Hunyuan 50k high mesh.
02 Studio low-poly, 03 Studio UV, 04 Blender bake onto those UVs,
05 UV-preserving decimation only when the baked mesh exceeds the target by 30%.
"""
from __future__ import annotations
import json
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
STUDIO = Path(r"D:\UD\AI\XModel\LLM3DGraphics")
BLENDER = Path(r"D:\Tools\Blender\blender-5.0.0-windows-x64\blender.exe")
SOURCE = ROOT / "Tools/output/campguide_3d/campguide.glb"
OUT = ROOT / "Tools/output/campguide_studio"
TARGET = 3000
SIZE = 1024
sys.path.insert(0, str(ROOT / "Tools"))
from hunyuan_3d import auth # noqa: E402
sys.path.insert(0, str(STUDIO))
from backend import hunyuan_studio as studio # noqa: E402
from backend.retopology import _run_blender_step, compress_bake_maps, limit_studio_faces # noqa: E402
def update(**values):
print(json.dumps(values, ensure_ascii=False), flush=True)
def main() -> int:
if not SOURCE.is_file():
raise SystemExit(f"missing high mesh: {SOURCE}")
OUT.mkdir(parents=True, exist_ok=True)
shutil.copy2(SOURCE, OUT / "model.glb")
studio.is_logged_in = lambda client_id=None: auth.is_logged_in()
studio.load_storage_state = lambda client_id=None: (
json.loads(auth.storage_state_path().read_text(encoding="utf-8")),
None,
)
uv = OUT / "model_lowpoly_uv.glb"
if uv.is_file():
studio.validate_uv_glb(uv)
update(stage="reuse studio uv", path=str(uv))
else:
meta_path = OUT / "hunyuan_studio.json"
meta = json.loads(meta_path.read_text(encoding="utf-8")) if meta_path.is_file() else {}
studio.process_existing(
SOURCE,
OUT,
update,
target_faces=TARGET,
resume_low_id=meta.get("lowpoly_works_id"),
resume_uv_id=meta.get("uv_works_id"),
)
aligned = OUT / "model_lowpoly_uv_aligned.glb"
if not aligned.is_file():
with (OUT / "alignment.log").open("w", encoding="utf-8") as log:
subprocess.run(
[str(BLENDER), "-b", "--factory-startup", "--python",
str(ROOT / "Tools/model_tools/bl_align_studio_uv.py"), "--", str(OUT)],
stdout=log, stderr=subprocess.STDOUT, check=True, timeout=180,
)
baked = OUT / "model_baked.glb"
if not baked.is_file():
_run_blender_step(
OUT, str(BLENDER), "blender_lowpoly_bake.py",
(OUT / "model.glb", baked, 1, SIZE, aligned),
"bake.log", marker="Lowpoly bake saved to",
)
compress_bake_maps(OUT)
shutil.copy2(baked, OUT / "model_ex.glb")
mesh, decimation = limit_studio_faces(OUT, TARGET, str(BLENDER), update)
receipt = {
"ok": True,
"source": str(SOURCE.relative_to(ROOT)).replace("\\", "/"),
"targetTriangles": TARGET,
"textureSize": SIZE,
"triangles": len(mesh.faces),
"vertices": len(mesh.vertices),
"decimation": decimation,
"final": str((OUT / "model_ex.glb").relative_to(ROOT)).replace("\\", "/"),
}
(OUT / "mesh_receipt.json").write_text(json.dumps(receipt, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(receipt, ensure_ascii=False, indent=2), flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+5 -3
View File
@@ -148,8 +148,9 @@ def cmd_write_version(args: argparse.Namespace) -> int:
"appVersionMin": args.app_version,
"appVersionMax": args.app_version,
"resVersion": args.res_version,
"packVersion": args.pack_version,
"cdnRoot": args.cdn_root,
"packages": ["DefaultPackage"],
"packages": ["DefaultPackage", "Art"],
"forceUpdate": bool(args.force_update),
"notice": args.notice or "",
}
@@ -177,8 +178,9 @@ def main() -> int:
p_ver = sub.add_parser("write-version", help="Rewrite version.json")
p_ver.add_argument("--app-version", default="1.0.0")
p_ver.add_argument("--res-version", required=True)
p_ver.add_argument("--cdn-root", default="https://www.xworld.ren/wechat/1.0.0/DefaultPackage")
p_ver.add_argument("--res-version", required=True, help="MD5 of the Art index bytes")
p_ver.add_argument("--pack-version", default="", help="MD5 of the DefaultPackage index bytes")
p_ver.add_argument("--cdn-root", default="https://www.xworld.ren/game/Friends/webgl/yoo/DefaultPackage")
p_ver.add_argument("--force-update", action="store_true")
p_ver.add_argument("--notice", default="")
p_ver.set_defaults(func=cmd_write_version)
+125
View File
@@ -0,0 +1,125 @@
"""Read TokenRig GLB weights and match quantized joint heads to supplied bones.
Writes JSON only; does not rewrite binary assets. A global one-to-one assignment
avoids greedy nearest-bone matching errors around closely spaced fingers.
"""
import json
import struct
import sys
from pathlib import Path
import numpy as np
from scipy.optimize import linear_sum_assignment
from scipy.spatial import cKDTree
class GLB:
def __init__(self,path):
data=Path(path).read_bytes()
assert data[:4]==b'glTF'
self.bin=b''
offset=12
while offset<len(data):
size,kind=struct.unpack_from('<II',data,offset)
chunk=data[offset+8:offset+8+size]
if kind==0x4E4F534A:
self.doc=json.loads(chunk)
elif kind==0x004E4942:
self.bin=chunk
offset+=8+size
self.world={}
def visit(index,parent):
node=self.doc['nodes'][index]
if 'matrix' in node:
local=np.array(node['matrix']).reshape(4,4,order='F')
else:
x,y,z,w=node.get('rotation',[0,0,0,1])
rotation=np.array([[1-2*(y*y+z*z),2*(x*y-z*w),2*(x*z+y*w)],
[2*(x*y+z*w),1-2*(x*x+z*z),2*(y*z-x*w)],
[2*(x*z-y*w),2*(y*z+x*w),1-2*(x*x+y*y)]])
local=np.eye(4)
local[:3,:3]=rotation @ np.diag(node.get('scale',[1,1,1]))
local[:3,3]=node.get('translation',[0,0,0])
self.world[index]=parent@local
for child in node.get('children',[]):
visit(child,self.world[index])
for node in self.doc['scenes'][self.doc.get('scene',0)]['nodes']:
visit(node,np.eye(4))
def accessor(self,index):
a=self.doc['accessors'][index]
v=self.doc['bufferViews'][a['bufferView']]
dtype=np.dtype({5120:'i1',5121:'u1',5122:'<i2',5123:'<u2',5125:'<u4',5126:'<f4'}[a['componentType']])
count={'SCALAR':1,'VEC2':2,'VEC3':3,'VEC4':4,'MAT4':16}[a['type']]
stride=v.get('byteStride',dtype.itemsize*count)
result=np.ndarray((a['count'],count),dtype=dtype,buffer=self.bin,
offset=v.get('byteOffset',0)+a.get('byteOffset',0),strides=(stride,dtype.itemsize)).copy()
if a.get('normalized'):
result=result.astype(float)/np.iinfo(dtype).max
return result
def mesh(self):
index,node=next((i,n) for i,n in enumerate(self.doc['nodes']) if 'mesh' in n)
primitive=self.doc['meshes'][node['mesh']]['primitives'][0]
p=self.accessor(primitive['attributes']['POSITION'])
p4=np.column_stack((p,np.ones(len(p))))
if 'skin' in node:
# For skinned glTF nodes the mesh-node world transform cancels out
# of the skin transform. Evaluate the supplied inverse bind matrices
# rather than applying the armature object's scale a second time.
skin=self.doc['skins'][node['skin']]
inverse=self.accessor(skin['inverseBindMatrices'])
transforms=np.array([self.world[j] @ inverse[k].reshape(4,4,order='F') for k,j in enumerate(skin['joints'])])
ids=self.accessor(primitive['attributes']['JOINTS_0'])
weights=self.accessor(primitive['attributes']['WEIGHTS_0'])
p=np.zeros_like(p4,dtype=float)
for slot in range(ids.shape[1]):
p+=weights[:,slot,None]*np.einsum('nij,nj->ni',transforms[ids[:,slot]],p4)
p=p[:,:3]
else:
p=(p4 @ self.world[index].T)[:,:3]
return p,primitive,node
folder=Path(sys.argv[1])
raw_name=sys.argv[2] if len(sys.argv)>2 else 'rigged_raw.glb'
prefix=sys.argv[3] if len(sys.argv)>3 else ''
source=GLB(folder/'source.glb')
raw=GLB(folder/raw_name)
src_skin=source.doc['skins'][0]
raw_skin=raw.doc['skins'][0]
assert len(src_skin['joints'])==len(raw_skin['joints'])==41
src_heads=np.array([source.world[i][:3,3] for i in src_skin['joints']])
raw_heads=np.array([raw.world[i][:3,3] for i in raw_skin['joints']])
cost=np.linalg.norm(raw_heads[:,None,:]-src_heads[None,:,:],axis=2)
rows,cols=linear_sum_assignment(cost)
names=[source.doc['nodes'][i]['name'] for i in src_skin['joints']]
mapping={int(i):names[j] for i,j in zip(rows,cols)}
report={'joint_count':41,'max_joint_quantization_drift_m':float(cost[rows,cols].max()),
'joint_mapping':[{'output_skin_index':int(i),'output_name':raw.doc['nodes'][raw_skin['joints'][i]]['name'],
'input_name':names[j],'distance_m':float(cost[i,j]),
'row_second_best_distance_m':float(np.sort(cost[i])[1])} for i,j in zip(rows,cols)]}
assert report['max_joint_quantization_drift_m']<.015, report
positions,primitive,_=raw.mesh()
source_positions,_,_=source.mesh()
distances,_=cKDTree(positions).query(source_positions)
report['max_vertex_nearest_distance_m']=float(distances.max())
assert distances.max()<1e-5
reverse_distances,_=cKDTree(source_positions).query(positions)
report['max_reverse_vertex_nearest_distance_m']=float(reverse_distances.max())
report['source_render_vertices']=len(source_positions)
assert reverse_distances.max()<1e-5
attributes=primitive['attributes']
joints=raw.accessor(attributes['JOINTS_0'])
weights=raw.accessor(attributes['WEIGHTS_0'])
# Transfer may split render vertices at normal/UV corners. Both-direction
# geometric correspondence above is authoritative; render counts may differ.
decoded=[]
for p,ids,w in zip(positions,joints,weights):
named={mapping[int(i)]:float(value) for i,value in zip(ids,w) if value>0}
decoded.append({'position_y_up':p.tolist(),'weights':named})
report['vertices']=len(decoded)
report['weight_sum_range']=[float(weights.sum(1).min()),float(weights.sum(1).max())]
(folder/(prefix+'joint_mapping.json')).write_text(json.dumps(report,indent=2),encoding='utf-8')
(folder/(prefix+'decoded_weights.json')).write_text(json.dumps(decoded,separators=(',',':')),encoding='utf-8')
print(json.dumps({k:v for k,v in report.items() if k!='joint_mapping'},indent=2))
+40
View File
@@ -0,0 +1,40 @@
"""Replay a failed WebGL clang invocation without modifying Unity build artifacts."""
import argparse
import json
import os
from pathlib import Path
import re
import subprocess
import time
parser = argparse.ArgumentParser()
parser.add_argument("--source", default="UnityEngine.CoreModule__4.cpp")
parser.add_argument("--optimization", default="O3", choices=["O0", "O1", "O2", "O3", "Os", "Oz"])
args = parser.parse_args()
repo = Path(__file__).resolve().parent.parent
project = repo / "AIClient"
output = repo / "Tools/output/hybridclr_genall"
output.mkdir(parents=True, exist_ok=True)
log = Path(os.environ["LOCALAPPDATA"]) / "Unity/Editor/Editor.log"
commands = re.findall(r"emcc: error: '(.*?)' failed", log.read_text(encoding="utf-8", errors="replace"))
command = next(c for c in reversed(commands) if args.source in c and "clang++.exe" in c)
command = re.sub(r" -O[0-3sz](?= )", " -" + args.optimization, command)
stem = args.source.replace(".cpp", "") + "_" + args.optimization
object_file = output / (stem + ".o")
command = re.sub(r" -o .*$", ' -o "' + object_file.as_posix() + '"', command)
environment = os.environ.copy()
temp = output / "tmp"
temp.mkdir(exist_ok=True)
environment["TEMP"] = str(temp)
environment["TMP"] = str(temp)
started = time.monotonic()
result = subprocess.run(command, cwd=project, env=environment, capture_output=True, timeout=180)
(output / (stem + ".log")).write_bytes(result.stdout + result.stderr)
report = {
"source": args.source, "optimization": args.optimization,
"returncode": result.returncode, "seconds": round(time.monotonic() - started, 3),
"objectBytes": object_file.stat().st_size if object_file.exists() else 0,
"command": command,
}
(output / (stem + ".json")).write_text(json.dumps(report, indent=2), encoding="utf-8")
print(json.dumps({k: v for k, v in report.items() if k != "command"}))
+31
View File
@@ -0,0 +1,31 @@
"""Replay wasm-opt outside Unity build output, with an explicit worker limit."""
import argparse
import json
import os
from pathlib import Path
import re
import subprocess
import time
parser = argparse.ArgumentParser()
parser.add_argument("--cores", type=int, default=4)
args = parser.parse_args()
repo = Path(__file__).resolve().parent.parent
output = repo / "Tools/output/hybridclr_genall"
output.mkdir(parents=True, exist_ok=True)
editor_log = Path(os.environ["LOCALAPPDATA"]) / "Unity/Editor/Editor.log"
commands = re.findall(r"emcc: error: '(.*?)' failed", editor_log.read_text(encoding="utf-8", errors="replace"))
command = next(c for c in reversed(commands) if "wasm-opt" in c)
stem = "wasm_opt_cores_" + str(args.cores)
target = output / (stem + ".wasm")
command = re.sub(r" -o \S+", ' -o "' + target.as_posix() + '"', command)
environment = os.environ.copy()
environment["BINARYEN_CORES"] = str(args.cores)
started = time.monotonic()
result = subprocess.run(command, cwd=repo / "AIClient", env=environment, capture_output=True, timeout=240)
(output / (stem + ".log")).write_bytes(result.stdout + result.stderr)
report = {"cores": args.cores, "returncode": result.returncode,
"seconds": round(time.monotonic() - started, 3),
"outputBytes": target.stat().st_size if target.exists() else 0}
(output / (stem + ".json")).write_text(json.dumps(report, indent=2), encoding="utf-8")
print(json.dumps(report))
+175
View File
@@ -0,0 +1,175 @@
"""HomeCamp static scene assets: Studio low-poly -> Studio UV -> bake -> conditional UV-preserving reduction.
Step 01 uses Tools/hunyuan_3d_generate.py, with explicit user upload authorization.
Run steps 02-05 in the LLM3DGraphics virtualenv; no Unity serialized assets are written here.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import shutil
import subprocess
import sys
from pathlib import Path
sys.dont_write_bytecode = True
ROOT = Path(__file__).resolve().parents[1]
WORKBENCH = Path('D:/UD/AI/XModel/LLM3DGraphics')
BLENDER = Path('D:/Tools/Blender/blender-5.0.0-windows-x64/blender.exe')
MANIFEST = ROOT / 'Doc/Design/Scene/HomeCamp/homecamp_scene_manifest.json'
def read(path: Path) -> dict:
return json.loads(path.read_text(encoding='utf-8-sig'))
def save(path: Path, data: dict) -> None:
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
def sha(path: Path) -> str:
return hashlib.sha256(path.read_bytes()).hexdigest()
def update(**values) -> None:
print(json.dumps(values, ensure_ascii=True), flush=True)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--part', required=True, choices=('ground', 'mainhouse', 'cottage', 'fountain'))
parser.add_argument('--refine-bake', action='store_true', help='After step 05, rebake onto its retained UV to repair projection seams')
parser.add_argument('--revise-budget', action='store_true', help='Archive the old receipt when only the reviewed triangle budget changes')
args = parser.parse_args()
manifest = read(MANIFEST)
if manifest['uploadAuthorization']['status'] != 'authorized_by_user':
raise RuntimeError('Explicit Hunyuan image/model upload authorization is required')
part = next(p for p in manifest['components'] if p['id'] == args.part)
source = ROOT / f'Tools/output/homecamp_3d/{args.part}/{args.part}.glb'
image_path = ROOT / part['designImage']
expected_image = next(p for p in manifest['designImageAudit'] if p['id'] == args.part)
if sha(image_path) != expected_image['sha256']:
raise RuntimeError('Design input changed after authorization; review the new input first')
if not source.is_file():
raise FileNotFoundError(f'Complete Hunyuan web high mesh first: {source}')
sys.path.insert(0, str(ROOT / 'Tools'))
from hunyuan_3d import auth
from hunyuan_3d.glb_check import assert_glb_textures_ok
assert_glb_textures_ok(source)
sys.path.insert(0, str(WORKBENCH))
from backend import hunyuan_studio as studio
from backend.retopology import _run_blender_step, compress_bake_maps
import trimesh
sys.path.insert(0, str(ROOT / 'Tools/model_tools'))
from mt_run import run_blender_script
from png_compress import compress_dir_pngs
out = ROOT / 'Tools/output/homecamp_studio' / args.part
out.mkdir(parents=True, exist_ok=True)
receipt_path = out / 'production.json'
receipt = read(receipt_path) if receipt_path.exists() else {}
target = int(part['targetTriangles'])
size = int(manifest['modelPipeline']['textureSize'])
settings = dict(source=source.relative_to(ROOT).as_posix(), sourceSha256=sha(source),
targetTriangles=target, textureSize=size)
if receipt and any(receipt.get(k) != v for k, v in settings.items()):
if (args.revise_budget and receipt.get('sourceSha256') == settings['sourceSha256']
and receipt.get('textureSize') == size):
save(out / ('production_target_' + str(receipt['targetTriangles']) + '.json'), receipt)
receipt['previousTargetTriangles'] = receipt['targetTriangles']
receipt['targetTriangles'] = target
receipt['status'] = 'budget_revised_for_surface_and_uv_integrity'
else:
raise RuntimeError('Source/settings changed; keep the old receipt and use a new production directory')
if receipt.get('status') == 'ready_for_unity' and not args.refine_bake:
for path, digest in receipt['outputs'].items():
if sha(ROOT / path) != digest:
raise RuntimeError(f'Completed output changed: {path}')
update(part=args.part, status='ready_for_unity', cached=True)
return 0
receipt.update(settings, part=args.part, status='studio', designImage=part['designImage'],
designSha256=sha(image_path))
receipt.setdefault('steps', {})['01'] = 'highpoly_validated'
save(receipt_path, receipt)
shutil.copy2(source, out / 'model.glb')
studio.is_logged_in = lambda client_id=None: auth.is_logged_in()
studio.load_storage_state = lambda client_id=None: (read(auth.storage_state_path()), None)
uv = out / 'model_lowpoly_uv.glb'
if uv.is_file():
studio.validate_uv_glb(uv)
else:
meta_path = out / 'hunyuan_studio.json'
meta = read(meta_path) if meta_path.exists() else {}
studio.process_existing(source, out, update, target_faces=target,
resume_low_id=meta.get('lowpoly_works_id'), resume_uv_id=meta.get('uv_works_id'))
receipt.update(status='baking', studio=read(out / 'hunyuan_studio.json'))
receipt['steps'].update({'02': 'studio_lowpoly_completed', '03': 'studio_uv_completed'})
save(receipt_path, receipt)
def blender(script: str, script_args: list[str], log_name: str) -> None:
with (out / log_name).open('w', encoding='utf-8') as log:
subprocess.run([str(BLENDER), '-b', '-t', '2', '--factory-startup', '--python-exit-code', '1', '--python',
str(ROOT / 'Tools/model_tools' / script), '--', *script_args],
check=True, stdout=log, stderr=subprocess.STDOUT, timeout=240)
aligned = out / 'model_lowpoly_uv_aligned.glb'
if not aligned.is_file():
blender('bl_align_studio_uv.py', [str(out)], 'alignment.log')
if not receipt.get('bakeAligned'):
_run_blender_step(out, str(BLENDER), 'blender_lowpoly_bake.py',
(out / 'model.glb', out / 'model_baked.glb', 1, size, aligned),
'bake.log', marker='Lowpoly bake saved to')
compress_bake_maps(out)
receipt['bakeAligned'] = True
receipt['steps']['04'] = 'blender_bake_completed_on_studio_uv'
save(receipt_path, receipt)
receipt['steps']['04'] = 'blender_bake_completed_on_studio_uv'
shutil.copy2(out / 'model_baked.glb', out / 'model_ex.glb')
mesh = trimesh.load(out / 'model_ex.glb', force='mesh', process=False)
reduction = dict(triggered=False, faces_before=len(mesh.faces), faces_after=len(mesh.faces), target_faces=target, threshold_percent=30)
if len(mesh.faces) * 10 > target * 13:
update(stage='Blender welded geometry / preserved corner UV reduction', part=args.part)
blender('bl_decimate_homecamp_uv.py', [str(out / 'model_ex.glb'), str(out / 'model_decimated_welded.glb'), str(target)], 'decimate_welded.log')
reduction = read(out / 'model_decimated_welded.json')
shutil.copy2(out / 'model_decimated_welded.glb', out / 'model_ex.glb')
mesh = studio.validate_uv_glb(out / 'model_ex.glb')
if len(mesh.faces) * 10 > target * 13:
raise RuntimeError('Final mesh still exceeds the target by more than 30 percent')
receipt['steps']['05'] = 'uv_preserving_decimation_completed' if reduction['triggered'] else 'skipped_within_130_percent'
receipt.update(triangles=len(mesh.faces), vertices=len(mesh.vertices), decimation=reduction,
bakeAlignment=read(out / 'bake_alignment.json'))
save(receipt_path, receipt)
blender('bl_repair_homecamp_surface.py', [str(out / 'model_ex.glb'), str(out / 'model_surface_repaired.glb'), args.part], 'surface_repair.log')
receipt['surfaceRepair'] = read(out / 'model_surface_repaired.json')
shutil.copy2(out / 'model_surface_repaired.glb', out / 'model_ex.glb')
receipt['triangles'] = receipt['surfaceRepair']['triangles']
if args.refine_bake:
# Collapse retains charts, but coarse triangles can move the sampled
# texels. Correct the projection on the final mesh, without re-unwrap.
original_final = out / 'model_decimated_before_rebake.glb'
shutil.copy2(out / 'model_ex.glb', original_final)
_run_blender_step(out, str(BLENDER), 'blender_lowpoly_bake.py',
(out / 'model.glb', out / 'model_refined.glb', 1, size, original_final),
'refine_bake.log', marker='Lowpoly bake saved to')
compress_bake_maps(out)
refined = studio.validate_uv_glb(out / 'model_refined.glb')
if len(refined.faces) != receipt['triangles']:
raise RuntimeError('Refinement changed the final mesh topology')
shutil.copy2(out / 'model_refined.glb', out / 'model_ex.glb')
receipt['projectionRefinement'] = 'Same source high -> final reduced low; UV preserved, no unwrap'
blender('bl_prepare_studio_trial.py', [str(out), args.part], 'static_export.log')
receipt['xpbr'] = run_blender_script(str(BLENDER), 'bl_convert.py',
[str(out / 'static' / (args.part + '.fbx')), str(out / 'xpbr'), str(size)], blender_args=('-t', '2'))
compress_dir_pngs(str(out / 'xpbr'), suffixes=('_base.png', '_mix.png'))
# Only these three source files are imported. Unity owns .mat and .meta generation.
outputs = [out / 'xpbr' / (args.part + suffix) for suffix in ('.fbx', '_base.png', '_mix.png')]
receipt.update(status='ready_for_unity', staticExport=read(out / 'static_export.json'),
outputs={p.relative_to(ROOT).as_posix(): sha(p) for p in outputs})
save(receipt_path, receipt)
update(part=args.part, status=receipt['status'], triangles=receipt['triangles'])
return 0
if __name__ == '__main__':
raise SystemExit(main())
+130
View File
@@ -0,0 +1,130 @@
"""LmGirl T-pose: Hunyuan 50k, then Studio low-poly, Studio UV, bake, and 5000-face reduction.
No skeleton. Step 05 runs only when the baked mesh is more than 30% over 5000 triangles.
"""
from __future__ import annotations
import json
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
STUDIO = Path(r"D:\UD\AI\XModel\LLM3DGraphics")
BLENDER = Path(r"D:\Tools\Blender\blender-5.0.0-windows-x64\blender.exe")
PY = Path(r"D:\UD\AI\XModel\LLM3DGraphics\.venv\Scripts\python.exe")
IMAGE = ROOT / "Doc/Design/Character/LmGirl/lm_girl_tpose.jpg"
HIGH = ROOT / "Tools/output/lmgirl_tpose_3d/lmgirl.glb"
OUT = ROOT / "Tools/output/lmgirl_tpose_studio"
NAME = "LmGirlTpose"
TARGET = 5000
SIZE = 1024
sys.path.insert(0, str(ROOT / "Tools"))
from hunyuan_3d import auth # noqa: E402
from hunyuan_3d.glb_check import assert_glb_textures_ok # noqa: E402
from model_tools.bl_decimate import FBX_YUP_EXPORT_VERSION # noqa: E402
sys.path.insert(0, str(STUDIO))
from backend import hunyuan_studio as studio # noqa: E402
from backend.retopology import _run_blender_step, compress_bake_maps, limit_studio_faces # noqa: E402
def update(**values):
print(json.dumps(values, ensure_ascii=False), flush=True)
def generate_high() -> None:
if HIGH.is_file() and HIGH.stat().st_size > 1_000_000:
update(stage="reuse high mesh", path=str(HIGH))
return
HIGH.parent.mkdir(parents=True, exist_ok=True)
update(stage="hunyuan 50k", image=str(IMAGE))
subprocess.run(
[str(PY), str(ROOT / "Tools/hunyuan_3d_generate.py"),
"--image", str(IMAGE), "--output", str(HIGH), "--faces", "50k",
"--no-interactive-login", "--result-only"],
check=True,
timeout=1500,
)
assert_glb_textures_ok(HIGH)
def main() -> int:
if not IMAGE.is_file():
raise SystemExit(f"missing design image: {IMAGE}")
generate_high()
assert_glb_textures_ok(HIGH)
OUT.mkdir(parents=True, exist_ok=True)
shutil.copy2(HIGH, OUT / "model.glb")
studio.is_logged_in = lambda client_id=None: auth.is_logged_in()
studio.load_storage_state = lambda client_id=None: (
json.loads(auth.storage_state_path().read_text(encoding="utf-8")),
None,
)
uv = OUT / "model_lowpoly_uv.glb"
if uv.is_file():
studio.validate_uv_glb(uv)
update(stage="reuse studio uv", path=str(uv))
else:
meta_path = OUT / "hunyuan_studio.json"
meta = json.loads(meta_path.read_text(encoding="utf-8")) if meta_path.is_file() else {}
studio.process_existing(
HIGH,
OUT,
update,
target_faces=TARGET,
resume_low_id=meta.get("lowpoly_works_id"),
resume_uv_id=meta.get("uv_works_id"),
)
aligned = OUT / "model_lowpoly_uv_aligned.glb"
if not aligned.is_file():
with (OUT / "alignment.log").open("w", encoding="utf-8") as log:
subprocess.run(
[str(BLENDER), "-b", "--factory-startup", "--python",
str(ROOT / "Tools/model_tools/bl_align_studio_uv.py"), "--", str(OUT)],
stdout=log, stderr=subprocess.STDOUT, check=True, timeout=180,
)
baked = OUT / "model_baked.glb"
if not baked.is_file():
_run_blender_step(
OUT, str(BLENDER), "blender_lowpoly_bake.py",
(OUT / "model.glb", baked, 1, SIZE, aligned),
"bake.log", marker="Lowpoly bake saved to",
)
compress_bake_maps(OUT)
shutil.copy2(baked, OUT / "model_ex.glb")
mesh, decimation = limit_studio_faces(OUT, TARGET, str(BLENDER), update)
static = OUT / "static" / f"{NAME}.fbx"
export_receipt = OUT / "static_export.json"
export_info = json.loads(export_receipt.read_text(encoding="utf-8")) if export_receipt.is_file() else {}
if not static.is_file() or export_info.get("fbxAxes", {}).get("exportVersion") != FBX_YUP_EXPORT_VERSION:
with (OUT / "static_export.log").open("w", encoding="utf-8") as log:
subprocess.run(
[str(BLENDER), "-b", "-t", "1", "--factory-startup", "--python",
str(ROOT / "Tools/model_tools/bl_prepare_studio_trial.py"), "--", str(OUT), NAME],
stdout=log, stderr=subprocess.STDOUT, check=True, timeout=180,
)
receipt = {
"ok": True,
"character": NAME,
"image": "Doc/Design/Character/LmGirl/lm_girl_tpose.jpg",
"source": "Tools/output/lmgirl_tpose_3d/lmgirl.glb",
"targetTriangles": TARGET,
"textureSize": SIZE,
"triangles": len(mesh.faces),
"vertices": len(mesh.vertices),
"decimation": decimation,
"final": "Tools/output/lmgirl_tpose_studio/model_ex.glb",
"fbx": f"Tools/output/lmgirl_tpose_studio/static/{NAME}.fbx",
"fbxAxes": json.loads(export_receipt.read_text(encoding="utf-8")).get("fbxAxes"),
"rig": False,
}
(OUT / "mesh_receipt.json").write_text(json.dumps(receipt, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(receipt, ensure_ascii=False, indent=2), flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+12
View File
@@ -19,6 +19,18 @@
- Pillow(UV 观察图):`pip install Pillow`
- UVPackmaster 4 可选
## FBX 轴向约定
Studio 五步的 GLB 使用标准 glTF Y-up;Blender 默认导入后应在 Z-up 场景中站立。
`prepare_game_export` 为历史调用保留 Y-up 顶点缓冲;最终必须由
`export_fbx_source_yup` 在临时副本上恢复 Blender Z-up,再烘焙为 Y-up FBX。
不要直接把准备后的 Y-up 顶点交给默认 FBX 导出器,否则会重复转换,留下 X=-90° 节点旋转。
版本 2 导出器会校验场景 +Y-up、节点旋转 0 和缩放 1;静态导出回执写入 `fbxAxes.exportVersion`。
`lmgirl_tpose_mesh.py` 只复用版本匹配的 FBX 缓存,历史资源不会在此次修复中批量重导。
Blender 回归入口:`Tools/model_tools/tests/bl_test_fbx_axes.py`。验收读取完整节点变换后的世界几何,
同时检查脚底中心原点、UV、源网格不变及重复导入导出,不以顶点包围盒或 `axis_up` 参数单独判定成功。
## 一键(推荐)
```powershell
@@ -0,0 +1,41 @@
"""Audit FBX axis metadata, raw geometry and evaluated Blender world bounds."""
import sys,json
from pathlib import Path
import bpy
import numpy as np
from io_scene_fbx import parse_fbx
ROOT=Path(__file__).resolve().parents[2]
OUT=ROOT/'Tools/output/lmgirl_tpose_studio/mixamo_upload'
def properties(element):
block=next((e for e in element.elems if e.id==b'Properties70'),None)
return {p.props[0].decode():[v.decode(errors='replace') if isinstance(v,bytes) else v for v in p.props[4:]] for p in block.elems} if block else {}
def bounds(points):
p=np.asarray(points,dtype=float)
return dict(min=p.min(0).tolist(),max=p.max(0).tolist(),span=np.ptp(p,axis=0).tolist())
def audit(path):
tree,version=parse_fbx.parse(str(path))
settings=next(e for e in tree.elems if e.id==b'GlobalSettings')
objects=next(e for e in tree.elems if e.id==b'Objects')
raw=[]
for e in objects.elems:
if e.id==b'Model':raw.append(dict(type='Model',name=str(e.props[1]),properties=properties(e)))
if e.id==b'Geometry':
vs=next((x for x in e.elems if x.id==b'Vertices'),None)
if vs:raw.append(dict(type='Geometry',name=str(e.props[1]),bounds=bounds(np.asarray(vs.props[0]).reshape(-1,3))))
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=str(path),use_anim=False)
imported=[]
for o in bpy.context.scene.objects:
if o.type=='MESH':imported.append(dict(name=o.name,vertices=len(o.data.vertices),triangles=sum(len(p.vertices)-2 for p in o.data.polygons),
rotation=list(o.rotation_euler),matrix=[list(r) for r in o.matrix_world],worldBounds=bounds([o.matrix_world@v.co for v in o.data.vertices])))
return dict(path=str(path),fbxVersion=version,globalSettings=properties(settings),raw=raw,imported=imported)
paths=[Path(p) for p in sys.argv[sys.argv.index('--')+1:]]
OUT.mkdir(parents=True,exist_ok=True)
report=[audit(p) for p in paths]
(OUT/'axis_audit.json').write_text(json.dumps(report,indent=2),encoding='utf-8')
print(json.dumps(report,indent=2))
@@ -0,0 +1,47 @@
"""Inspect the completed five-step outputs using each format's default importer."""
import json,sys,hashlib
from pathlib import Path
import bpy
import numpy as np
from io_scene_fbx import parse_fbx
ROOT=Path(__file__).resolve().parents[2]
FOLDER=ROOT/'Tools/output/lmgirl_tpose_studio'
OUT=FOLDER/'axis_pipeline_audit'
def inspect(stage,path):
bpy.ops.wm.read_factory_settings(use_empty=True)
if path.suffix.lower()=='.fbx':bpy.ops.import_scene.fbx(filepath=str(path),use_anim=False)
else:bpy.ops.import_scene.gltf(filepath=str(path),merge_vertices=False)
objects=[o for o in bpy.context.scene.objects if o.type=='MESH']
points=np.array([o.matrix_world@v.co for o in objects for v in o.data.vertices])
lo=points.min(0);hi=points.max(0);span=hi-lo
result=dict(stage=stage,path=str(path.relative_to(ROOT)),sha256=hashlib.sha256(path.read_bytes()).hexdigest(),
vertices=sum(len(o.data.vertices) for o in objects),triangles=sum(len(p.vertices)-2 for o in objects for p in o.data.polygons),
blender_world_min=lo.tolist(),blender_world_max=hi.tolist(),blender_world_span=span.tolist(),
longest_axis_in_blender='XYZ'[int(np.argmax(span))],
upright_in_blender=bool(np.argmax(span)==2))
if path.suffix.lower()=='.fbx':
root,_=parse_fbx.parse(str(path));objects=next(e for e in root.elems if e.id==b'Objects')
result['fbx_nodes']=[]
for e in objects.elems:
if e.id!=b'Model':continue
props=next((p for p in e.elems if p.id==b'Properties70'),None)
values={p.props[0].decode():list(p.props[4:]) for p in props.elems} if props else {}
result['fbx_nodes'].append(dict(name=str(e.props[1]),rotation=values.get('Lcl Rotation',[0,0,0]),scale=values.get('Lcl Scaling',[1,1,1])))
return result
stages=[('01 Hunyuan high',FOLDER/'model.glb'),('02 Studio low',FOLDER/'model_lowpoly.glb'),
('03 Studio UV',FOLDER/'model_lowpoly_uv.glb'),('03b alignment',FOLDER/'model_lowpoly_uv_aligned.glb'),
('04 bake',FOLDER/'model_baked.glb'),('05 UV-preserving decimation',FOLDER/'model_decimated.glb'),
('05 final GLB',FOLDER/'model_ex.glb'),('After 05 static FBX',FOLDER/'static/LmGirlTpose.fbx'),
('XPbr FBX',FOLDER/'xpbr/LmGirlTpose.fbx'),('Corrected upload FBX',FOLDER/'mixamo_upload/LmGirlTpose_Mixamo.fbx'),
('Fixed production static FBX',FOLDER/'axis_pipeline_audit/production/static/LmGirlTpose.fbx'),
('Fixed production XPbr FBX',FOLDER/'axis_pipeline_audit/production/xpbr/LmGirlTpose.fbx')]
OUT.mkdir(parents=True,exist_ok=True)
report=[inspect(stage,path) for stage,path in stages]
(OUT/'stages.json').write_text(json.dumps(report,indent=2),encoding='utf-8')
for r in report:print('STAGE',r['stage'],r['triangles'],r['longest_axis_in_blender'],[round(x,5) for x in r['blender_world_span']],r.get('fbx_nodes',[]))
assert all(r['upright_in_blender'] for r in report[:7]),'Earlier GLB stage has a tilt'
assert not report[7]['upright_in_blender'],'Expected original static FBX regression'
assert all(r['upright_in_blender'] for r in report[9:]),'A repaired FBX is not upright'
+119
View File
@@ -0,0 +1,119 @@
"""Read-only Blender audit of the CampGuide SOMA trial; writes JSON diagnostics."""
import json
import sys
from pathlib import Path
import bpy
import numpy as np
def points(mesh, evaluated=False):
obj = mesh.evaluated_get(bpy.context.evaluated_depsgraph_get()) if evaluated else mesh
data = obj.to_mesh() if evaluated else obj.data
result = np.array([obj.matrix_world @ v.co for v in data.vertices])
if evaluated:
obj.to_mesh_clear()
return result
def audit(path):
bpy.ops.wm.read_factory_settings(use_empty=True)
if path.suffix == '.fbx':
bpy.ops.import_scene.fbx(filepath=str(path))
elif path.suffix == '.bvh':
bpy.ops.import_anim.bvh(filepath=str(path), global_scale=0.01, rotate_mode='QUATERNION', update_scene_fps=True)
else:
bpy.context.scene.render.fps = 30
bpy.ops.import_scene.gltf(filepath=str(path))
arm = next((o for o in bpy.context.scene.objects if o.type == 'ARMATURE'), None)
result = {'path': str(path)}
if arm:
result['bones'] = {b.name: {'parent': b.parent.name if b.parent else None,
'head': list(arm.matrix_world @ b.head_local),
'tail': list(arm.matrix_world @ b.tail_local),
'world_basis': [list((arm.matrix_world @ b.matrix_local).to_3x3().col[i].normalized()) for i in range(3)],
'deform': b.use_deform} for b in arm.data.bones}
result['gaps'] = {b.name: (arm.matrix_world @ b.head_local - arm.matrix_world @ b.parent.tail_local).length
for b in arm.data.bones if b.parent}
meshes = [o for o in bpy.context.scene.objects if o.type == 'MESH']
if meshes:
mesh = max(meshes, key=lambda o: len(o.data.vertices))
rest = points(mesh)
lo, hi = rest.min(0), rest.max(0)
height = float(hi[2]-lo[2])
weights = [{mesh.vertex_groups[g.group].name: g.weight for g in v.groups if g.weight > 1e-5}
for v in mesh.data.vertices]
groups = sorted({g for w in weights for g in w})
def region(mask):
ids = np.flatnonzero(mask)
return {'vertices': len(ids), 'weights': {g: round(sum(weights[i].get(g, 0) for i in ids)/max(1, len(ids)), 5)
for g in groups if any(weights[i].get(g, 0) > .01 for i in ids)}}
result['mesh'] = {'vertices': len(rest), 'triangles': sum(len(p.vertices)-2 for p in mesh.data.polygons),
'bounds': [lo.tolist(), hi.tolist()],
'head_region': region(rest[:, 2] > lo[2]+height*.78),
'outer_hands': region(np.abs(rest[:, 0]) > max(abs(lo[0]), abs(hi[0]))*.78),
'weight_sums': [min(sum(w.values()) for w in weights), max(sum(w.values()) for w in weights)]}
# Weld coincident UV/normal seam vertices only for connectivity statistics.
_, inverse = np.unique(np.round(rest, 5), axis=0, return_inverse=True)
parent = np.arange(inverse.max()+1)
def root(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
edges = np.array([e.vertices[:] for e in mesh.data.edges])
for a, b in inverse[edges]:
parent[root(a)] = root(b)
labels = np.array([root(v) for v in inverse])
result['mesh']['component_sizes'] = sorted(np.unique(labels, return_counts=True)[1].tolist(), reverse=True)
result['mesh']['components'] = [dict(region(labels == label), bounds=[rest[labels == label].min(0).tolist(), rest[labels == label].max(0).tolist()])
for label, count in zip(*np.unique(labels, return_counts=True)) if count >= 25]
lengths = np.linalg.norm(rest[edges[:, 1]]-rest[edges[:, 0]], axis=1)
valid = lengths > height*.002
action = arm.animation_data.action if arm and arm.animation_data else None
if action:
first, last = map(int, action.frame_range)
result['animation'] = {'range': [first, last], 'fps': bpy.context.scene.render.fps, 'frames': []}
endpoints = []
for frame in range(first, last+1):
bpy.context.scene.frame_set(frame)
deformed = points(mesh, True)
ratios = np.linalg.norm(deformed[edges[:, 1]]-deformed[edges[:, 0]], axis=1)/np.maximum(lengths, 1e-12)
ratios[~valid] = 0
worst = np.argsort(ratios)[-5:][::-1]
hand_mask = valid & (np.abs(rest[edges[:, 0], 0]) > height*.255) & (rest[edges[:, 0], 2] > height*.55) & (rest[edges[:, 0], 2] < height*.67)
result['animation']['frames'].append({'frame': frame, 'bounds': [deformed.min(0).tolist(), deformed.max(0).tolist()],
'hips_location': list(arm.pose.bones['Hips'].location),
'hand_max_stretch': float(ratios[hand_mask].max()),
'edges_over_2x': int((ratios > 2).sum()), 'max_stretch': float(ratios.max()),
'worst': [{'ratio': float(ratios[e]), 'ids': edges[e].tolist(),
'rest': rest[edges[e]].tolist(), 'weights': [weights[v] for v in edges[e]]} for e in worst]})
if frame in (first, last):
endpoints.append({b.name: b.rotation_quaternion.copy() for b in arm.pose.bones})
if len(endpoints) == 2:
result['animation']['loop_rotation_gap_degrees'] = {n: float(endpoints[0][n].rotation_difference(endpoints[1][n]).angle*180/np.pi)
for n in endpoints[0]}
elif arm and arm.animation_data and arm.animation_data.action:
first, last = map(int, arm.animation_data.action.frame_range)
samples = []
for frame in range(first, last+1):
bpy.context.scene.frame_set(frame)
samples.append({n: {'location': list(arm.pose.bones[n].location),
'world_head': list(arm.matrix_world @ arm.pose.bones[n].head)}
for n in ('Root', 'Hips', 'LeftFoot', 'RightFoot')})
result['motion'] = {'range': [first, last], 'fps': bpy.context.scene.render.fps, 'samples': samples}
return result
args = sys.argv[sys.argv.index('--')+1:]
out = Path(args[0])
result = []
for filename in args[1:]:
item = audit(Path(filename))
result.append(item)
print('AUDIT', filename, 'bones', len(item.get('bones', {})), 'mesh', item.get('mesh'))
if 'animation' in item:
worst = max(item['animation']['frames'], key=lambda f: f['max_stretch'])
print('WORST', json.dumps(worst))
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding='utf-8')
@@ -0,0 +1,56 @@
"""Render high/final Studio accessory and audit geometry/UV without changing it."""
import json
import sys
from pathlib import Path
import bpy
import numpy as np
from mathutils import Vector
folder = Path(sys.argv[sys.argv.index('--') + 1]).resolve()
report = []
filenames = ('model.glb',) if '--high-only' in sys.argv else ('model_ex.glb',) if '--final-only' in sys.argv else ('model.glb', 'model_ex.glb')
for filename in filenames:
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=str(folder / filename))
objects = [o for o in bpy.context.scene.objects if o.type == 'MESH']
points = [o.matrix_world @ v.co for o in objects for v in o.data.vertices]
lo = Vector([min(v[i] for v in points) for i in range(3)])
hi = Vector([max(v[i] for v in points) for i in range(3)])
uv = np.array([tuple(v.uv) for o in objects if o.data.uv_layers.active for v in o.data.uv_layers.active.data])
assert len(objects) == 1 and np.isfinite(uv).all() and len(uv) > 0
triangles = sum(len(p.vertices) - 2 for o in objects for p in o.data.polygons)
report.append(dict(file=filename, objects=len(objects), triangles=triangles,
vertices=sum(len(o.data.vertices) for o in objects), minimum=list(lo), maximum=list(hi),
uvLoops=len(uv), finiteUV=True))
scene = bpy.context.scene
scene.render.engine = 'CYCLES'
scene.cycles.samples = 16
scene.render.resolution_x = scene.render.resolution_y = 640
scene.render.resolution_percentage = 100
world = bpy.data.worlds.new('ReviewWorld')
world.use_nodes = True
world.node_tree.nodes['Background'].inputs[0].default_value = (.32, .38, .44, 1)
world.node_tree.nodes['Background'].inputs[1].default_value = .6
scene.world = world
center = (lo + hi) / 2
extent = max(hi - lo)
camera = bpy.data.objects.new('ReviewCamera', bpy.data.cameras.new('ReviewCamera'))
scene.collection.objects.link(camera)
scene.camera = camera
camera.data.type = 'ORTHO'
camera.data.ortho_scale = extent * 1.3
for direction, power in (((2, -3, 4), 400), ((-3, -1, 2), 250)):
light = bpy.data.objects.new('ReviewLight', bpy.data.lights.new('ReviewLight', 'AREA'))
scene.collection.objects.link(light)
light.location = center + Vector(direction) * extent
light.rotation_euler = (center - light.location).to_track_quat('-Z', 'Y').to_euler()
light.data.energy = power * extent ** 2
light.data.size = extent * 3
for name, direction in [('front', (0, -3, .5)), ('side', (3, 0, .5)), ('back', (0, 3, .5)), ('perspective', (2, -3, 1))]:
camera.location = center + Vector(direction) * extent
camera.rotation_euler = (center - camera.location).to_track_quat('-Z', 'Y').to_euler()
scene.render.filepath = str(folder / ('audit_' + Path(filename).stem + '_' + name + '.png'))
bpy.ops.render.render(write_still=True)
(folder / ('geometry_high.json' if '--high-only' in sys.argv else 'geometry_audit.json')).write_text(json.dumps(report, indent=2) + '\n', encoding='utf-8')
print('MT_SUMMARY ' + json.dumps(report))
@@ -0,0 +1,24 @@
"""Run identical Mixamo motions on website and TokenRig skin weights."""
import json
import sys
from pathlib import Path
sys.path.insert(0,str(Path(__file__).resolve().parent))
from bl_compare_mixamo_soma import audit
root=Path(sys.argv[sys.argv.index('--')+1])
folder=root/'Tools/output/campguide_studio/rig_mixamo41'
out=folder/'review'
out.mkdir(parents=True,exist_ok=True)
base=root/'AIClient/Assets/Bundles/Characters/Human/test/Boy_mixamo'
results=[]
jobs=[('Mixamo',base/'CampGuideStudio.fbx'),('TokenRig41',folder/'tokenrig_mixamo41.blend')]
if '--local-only' in sys.argv:
jobs=[('TokenRigLocal',folder/'tokenrig_mixamo41_local.blend')]
for skin,mesh in jobs:
for name in ['Walking','Breathing Idle','Happy Idle']:
result=audit(skin+' '+name,mesh,base/('CampGuideStudio@'+name+'.fbx'),out)
results.append(result)
result_name='comparison_local.json' if '--local-only' in sys.argv else 'comparison.json'
(folder/result_name).write_text(json.dumps(results,indent=2),encoding='utf-8')
print('COMPARISON_DONE',folder/result_name)
@@ -0,0 +1,47 @@
"""Bake supplied Mixamo clips onto both TokenRig skin variants for review."""
import json
import sys
from pathlib import Path
import bpy
root=Path(sys.argv[sys.argv.index('--')+1])
folder=root/'Tools/output/campguide_studio/rig_mixamo41'
base=root/'AIClient/Assets/Bundles/Characters/Human/test/Boy_mixamo'
manifest=[]
for variant,filename in [('raw','tokenrig_mixamo41.blend'),('local','tokenrig_mixamo41_local.blend')]:
for label in ['Walking','Breathing Idle','Happy Idle']:
bpy.ops.wm.open_mainfile(filepath=str(folder/filename))
target=next(o for o in bpy.context.scene.objects if o.type=='ARMATURE')
before=set(bpy.data.objects)
bpy.ops.import_scene.fbx(filepath=str(base/('CampGuideStudio@'+label+'.fbx')))
source=next(o for o in bpy.data.objects if o not in before and o.type=='ARMATURE')
first,last=map(int,source.animation_data.action.frame_range)
for b in target.pose.bones:
b.rotation_mode='QUATERNION'
for frame in range(first,last+1):
bpy.context.scene.frame_set(frame)
bpy.context.view_layer.update()
matrices={b.name:target.matrix_world.inverted()@source.matrix_world@b.matrix for b in source.pose.bones}
for b in target.pose.bones:
b.matrix=matrices[b.name]
bpy.context.view_layer.update()
b.keyframe_insert('location',frame=frame)
b.keyframe_insert('rotation_quaternion',frame=frame)
b.keyframe_insert('scale',frame=frame)
action=target.animation_data.action
action.name=variant+'_'+label.replace(' ','_')
bpy.data.objects.remove(source,do_unlink=True)
for a in list(bpy.data.actions):
if a!=action:
bpy.data.actions.remove(a)
scene=bpy.context.scene
scene.frame_start,scene.frame_end=first,last
scene.render.fps=30
dst=folder/'animations'/variant/(label.replace(' ','_')+'.glb')
dst.parent.mkdir(parents=True,exist_ok=True)
bpy.ops.export_scene.gltf(filepath=str(dst),export_format='GLB',export_skins=True,export_animations=True,
export_animation_mode='ACTIVE_ACTIONS',export_force_sampling=True,export_def_bones=False)
manifest.append({'file':str(dst),'skin':variant,'frames':[first,last],'fps':30,'source_clip':str(base/('CampGuideStudio@'+label+'.fbx'))})
print('BAKED',dst)
(folder/'animations/manifest.json').write_text(json.dumps(manifest,indent=2),encoding='utf-8')
@@ -0,0 +1,34 @@
"""Controlled local-influence cleanup of TokenRig-41 hand weights only."""
import json
import sys
from pathlib import Path
import bpy
folder=Path(sys.argv[sys.argv.index('--')+1])
bpy.ops.wm.open_mainfile(filepath=str(folder/'tokenrig_mixamo41.blend'))
arm=next(o for o in bpy.context.scene.objects if o.type=='ARMATURE')
mesh=next(o for o in bpy.context.scene.objects if o.type=='MESH')
height=max((mesh.matrix_world@v.co).z for v in mesh.data.vertices)
changes=[]
for v in mesh.data.vertices:
p=mesh.matrix_world@v.co
if not (abs(p.x)>height*.255 and height*.55<p.z<height*.67):
continue
side='Left' if p.x*(arm.matrix_world@arm.data.bones['mixamorig:LeftHand'].head_local).x>0 else 'Right'
old={mesh.vertex_groups[g.group].name:g.weight for g in v.groups}
allowed={n:w for n,w in old.items() if n in (f'mixamorig:{side}Arm',f'mixamorig:{side}ForeArm') or n.startswith(f'mixamorig:{side}Hand')}
total=sum(allowed.values())
rejected=sum(w for n,w in old.items() if n not in allowed)
assert total>1e-8
for g in list(v.groups):
mesh.vertex_groups[g.group].remove([v.index])
for n,w in allowed.items():
mesh.vertex_groups[n].add([v.index],w/total,'REPLACE')
if rejected>.001:
changes.append({'vertex':v.index,'removed':rejected,'old':old})
(folder/'local_cleanup.json').write_text(json.dumps({'method':'CampGuide-specific T-pose hand-region whitelist; same-side Arm/ForeArm/Hand/fingers only; renormalize; no reference weights',
'changed_vertices_over_0_001':len(changes),'changes':changes},indent=2),encoding='utf-8')
bpy.ops.wm.save_as_mainfile(filepath=str(folder/'tokenrig_mixamo41_local.blend'),compress=True)
bpy.ops.export_scene.gltf(filepath=str(folder/'tokenrig_mixamo41_local.glb'),export_format='GLB',export_skins=True,export_animations=False,export_def_bones=False)
print('LOCAL_CLEANUP',len(changes))
+243
View File
@@ -0,0 +1,243 @@
"""Offline skin/motion comparison. Never modifies the source FBXs or Unity assets.
Mixamo clips are skeleton-only. Their evaluated world bone matrices are applied
to the same-name bones of the original skinned model, preserving its bind pose.
This explicitly handles the differing bind/rest poses; it is not Unity Humanoid.
"""
import hashlib
import json
import math
import sys
from pathlib import Path
import bpy
import numpy as np
from mathutils import Matrix, Vector
REFERENCE_POINTS = None
GEOMETRY_CHECKS = {}
def coords(obj, evaluated=False):
item = obj.evaluated_get(bpy.context.evaluated_depsgraph_get()) if evaluated else obj
data = item.to_mesh() if evaluated else item.data
result = np.array([item.matrix_world @ v.co for v in data.vertices], dtype=np.float64)
if evaluated:
item.to_mesh_clear()
return result
def imported(path):
before = set(bpy.data.objects)
if path.suffix.lower() == '.blend':
bpy.ops.wm.open_mainfile(filepath=str(path))
elif path.suffix.lower() == '.fbx':
bpy.ops.import_scene.fbx(filepath=str(path))
else:
bpy.context.scene.render.fps = 30
bpy.ops.import_scene.gltf(filepath=str(path))
return [o for o in bpy.data.objects if o not in before]
def configure_render(height, center):
scene = bpy.context.scene
scene.render.engine = 'CYCLES'
scene.cycles.samples = 16
scene.render.resolution_x, scene.render.resolution_y = 512, 640
scene.render.resolution_percentage = 100
scene.view_settings.view_transform = 'AgX'
world = bpy.data.worlds.new('AuditWorld')
world.use_nodes = True
world.node_tree.nodes['Background'].inputs[0].default_value = (.35, .38, .42, 1)
world.node_tree.nodes['Background'].inputs[1].default_value = .7
scene.world = world
camera = bpy.data.objects.new('AuditCamera', bpy.data.cameras.new('AuditCamera'))
scene.collection.objects.link(camera)
scene.camera = camera
camera.data.type = 'ORTHO'
camera.data.ortho_scale = height*1.3
for direction, power in [((2, -3, 4), 180), ((-3, 1, 2), 120)]:
lamp = bpy.data.objects.new('AuditLight', bpy.data.lights.new('AuditLight', 'AREA'))
scene.collection.objects.link(lamp)
lamp.location = center + Vector(direction)*height
lamp.rotation_euler = (center-lamp.location).to_track_quat('-Z', 'Y').to_euler()
lamp.data.energy, lamp.data.size = power*height**2, height*3
return camera
def audit(label, mesh_path, clip_path, out):
global REFERENCE_POINTS
bpy.ops.wm.read_factory_settings(use_empty=True)
objects = imported(mesh_path)
arm = next(o for o in objects if o.type == 'ARMATURE')
mesh = max((o for o in objects if o.type == 'MESH'), key=lambda o: len(o.data.vertices))
rest = coords(mesh)
up = int(np.argmax(np.ptp(rest, axis=0)))
normalized = rest[:, [0, 2, 1]].copy() if up == 1 else rest.copy()
if up == 1:
normalized[:, 1] *= -1
lo, hi = normalized.min(0), normalized.max(0)
height = float(hi[2]-lo[2])
if REFERENCE_POINTS is None:
REFERENCE_POINTS = normalized.copy()
if str(mesh_path) not in GEOMETRY_CHECKS:
matches=[]
for sign in (1,-1):
candidate=normalized.copy()
candidate[:,:2] *= sign
distances=[]
for start in range(0,len(candidate),128):
distance2=((candidate[start:start+128,None,:]-REFERENCE_POINTS[None,:,:])**2).sum(2)
distances.extend(np.sqrt(distance2.min(1)).tolist())
matches.append({'yaw_degrees': 0 if sign==1 else 180, 'max_nearest_vertex_distance_m': max(distances),
'mean_nearest_vertex_distance_m': float(np.mean(distances))})
GEOMETRY_CHECKS[str(mesh_path)] = min(matches,key=lambda m:m['mean_nearest_vertex_distance_m'])
groups = [g.name for g in mesh.vertex_groups]
weights = np.zeros((len(rest), len(groups)))
for v in mesh.data.vertices:
for g in v.groups:
weights[v.index, g.group] = g.weight
edges = np.array([e.vertices[:] for e in mesh.data.edges])
lengths = np.linalg.norm(rest[edges[:, 1]]-rest[edges[:, 0]], axis=1)
valid = lengths > height*.002
z = (normalized[:, 2]-lo[2])/height
hand = (np.abs(normalized[:, 0]) > height*.255) & (z > .55) & (z < .67)
neck = (z > .67) & (z < .78) & (np.abs(normalized[:, 0]) < height*.12)
head = z > .78
regions = {'all': np.ones(len(rest), dtype=bool), 'hands': hand, 'neck': neck, 'head': head}
region_edges = {n: valid & mask[edges[:, 0]] & mask[edges[:, 1]] for n, mask in regions.items()}
foot_groups = [i for i,n in enumerate(groups) if any(k in n for k in ('Foot', 'Toe', 'Shin', 'UpLeg')) or n.endswith('Leg')]
head_groups = [i for i,n in enumerate(groups) if any(k in n for k in ('Head', 'Neck'))]
def region_summary(mask):
w = weights[mask]
return {'vertices': int(mask.sum()), 'average_weights': {n: float(w[:,i].mean()) for i,n in enumerate(groups) if w[:,i].max() > .01},
'max_foot_influence': float(w[:,foot_groups].sum(1).max()),
'max_head_neck_influence': float(w[:,head_groups].sum(1).max())}
def norm_point(p):
return [p.x, -p.z, p.y] if up == 1 else list(p)
bones = {b.name: {'head': norm_point(arm.matrix_world @ b.head_local), 'tail': norm_point(arm.matrix_world @ b.tail_local),
'parent': b.parent.name if b.parent else None} for b in arm.data.bones}
result = {'label': label, 'mesh_source': str(mesh_path), 'clip_source': str(clip_path) if clip_path else None,
'geometry_match_to_first_mixamo': GEOMETRY_CHECKS[str(mesh_path)],
'mesh': {'vertices': len(rest), 'triangles': sum(len(p.vertices)-2 for p in mesh.data.polygons), 'height_m': height,
'bounds_normalized_z_up': [lo.tolist(), hi.tolist()], 'hands': region_summary(hand), 'head': region_summary(head),
'max_influences_over_0_00001': int((weights > .00001).sum(1).max()),
'max_influences_over_0_01': int((weights > .01).sum(1).max()),
'max_weight_discarded_if_top4': float(np.sort(weights,axis=1)[:,:-4].sum(1).max()),
'weight_sum_range': [float(weights.sum(1).min()), float(weights.sum(1).max())],
'rest_edge_lengths_sha256': hashlib.sha256(np.round(np.sort(lengths), 5).tobytes()).hexdigest()},
'bones': bones}
# Motion-independent LBS stress test: head yaw 30 degrees and both upper arms
# down 70 degrees, in normalized world coordinates. Same angles for both rigs.
# Descendants inherit their parent's deformation; joint pivots remain those
# of each delivered rig, so this tests the combined bind placement + weights.
canonical = Matrix.Rotation(math.pi/2,4,'X') if up == 1 else Matrix.Identity(4)
bind = {b.name: canonical @ arm.matrix_world @ b.matrix_local for b in arm.data.bones}
def stress(arm_down, head_yaw):
deform = {}
for b in arm.data.bones:
inherited = deform[b.parent.name] if b.parent else Matrix.Identity(4)
posed = inherited @ bind[b.name]
suffix = b.name.split(':')[-1]
angle, axis = 0, 'Z'
if suffix == 'Head':
angle = head_yaw
elif suffix in ('LeftArm','RightArm'):
angle, axis = arm_down*(1 if bind[b.name].translation.x > 0 else -1), 'Y'
if angle:
pivot = posed.translation
posed = Matrix.Translation(pivot) @ Matrix.Rotation(math.radians(angle),4,axis) @ Matrix.Translation(-pivot) @ posed
deform[b.name] = posed @ bind[b.name].inverted()
points4 = np.column_stack((normalized,np.ones(len(normalized))))
deformed = np.zeros((len(rest),3))
for i,name in enumerate(groups):
deformed += weights[:,i,None]*(points4 @ np.array(deform[name]).T)[:,:3]
ratios = np.linalg.norm(deformed[edges[:,1]]-deformed[edges[:,0]],axis=1)/np.maximum(lengths,1e-12)
return {n: {'max_edge_stretch': float(ratios[mask].max()), 'edges_over_2x': int((ratios[mask] > 2).sum())}
for n,mask in region_edges.items()}
result['controlled_lbs_stress'] = {'neutral': stress(0,0), 'arms_down_70_head_yaw_30': stress(70,30)}
source = None
if clip_path:
objects = imported(clip_path)
source = next(o for o in objects if o.type == 'ARMATURE')
assert set(arm.pose.bones.keys()) == set(source.pose.bones.keys()), 'skeleton names differ'
action_arm = source or arm
action = action_arm.animation_data.action if action_arm.animation_data else None
if not action:
return result
first, last = map(int, action.frame_range)
fps = bpy.context.scene.render.fps/bpy.context.scene.render.fps_base
samples, endposes, worldposes = [], [], []
named = lambda suffix: next((b.name for b in action_arm.pose.bones if b.name == suffix or b.name.endswith(':'+suffix)), None)
hips_name = named('Hips')
tracks = {s: named(s) for s in ['Hips','Head','LeftFoot','RightFoot','LeftToeBase','RightToeBase']}
for frame in range(first, last+1):
bpy.context.scene.frame_set(frame)
bpy.context.view_layer.update()
if source:
matrices = {b.name: arm.matrix_world.inverted() @ source.matrix_world @ b.matrix for b in source.pose.bones}
for b in arm.pose.bones:
b.matrix = matrices[b.name]
bpy.context.view_layer.update()
deformed = coords(mesh, True)
ratios = np.linalg.norm(deformed[edges[:, 1]]-deformed[edges[:, 0]], axis=1)/np.maximum(lengths, 1e-12)
worst = int(np.argmax(np.where(valid, ratios, 0)))
values = {n: {'max': float(ratios[mask].max()) if mask.any() else None,
'p99': float(np.percentile(ratios[mask],99)) if mask.any() else None,
'over_2x': int((ratios[mask] > 2).sum())} for n,mask in region_edges.items()}
feet = {s: [float(deformed[weights[:,i] > .25,2].min()) for i,n in enumerate(groups) if s+'Foot' in n and (weights[:,i] > .25).any()]
for s in ['Left','Right']}
samples.append({'frame': frame, 'regions': values,
'floor_min_z': float(deformed[:,2].min()), 'feet_min_z': feet,
'hips_world': list(action_arm.matrix_world @ action_arm.pose.bones[hips_name].head),
'worst_edge': {'ids': edges[worst].tolist(), 'rest': normalized[edges[worst]].tolist(),
'ratio': float(ratios[worst]), 'weights': [{n: float(weights[v,i]) for i,n in enumerate(groups) if weights[v,i]>.00001} for v in edges[worst]]}})
poses = {b.name: b.matrix_basis.to_quaternion().copy() for b in action_arm.pose.bones}
world = {n: list(action_arm.matrix_world @ action_arm.pose.bones[b].head) for n,b in tracks.items() if b}
worldposes.append(world)
if frame in (first, last):
endposes.append(poses)
seam = {n: min(float(endposes[0][n].rotation_difference(endposes[-1][n]).angle),2*math.pi-float(endposes[0][n].rotation_difference(endposes[-1][n]).angle))*180/math.pi for n in endposes[0]}
result['animation'] = {'range': [first,last], 'fps': fps, 'duration_between_endpoints_s': (last-first)/fps,
'samples': samples, 'joint_tracks': worldposes, 'loop_local_rotation_gap_degrees': seam,
'summary': {n: {'max_edge_stretch': max(s['regions'][n]['max'] for s in samples if s['regions'][n]['max'] is not None),
'max_p99_edge_stretch': max(s['regions'][n]['p99'] for s in samples if s['regions'][n]['p99'] is not None),
'max_edges_over_2x': max(s['regions'][n]['over_2x'] for s in samples)} for n in regions}}
print('METRICS', label, result['animation']['summary'], 'SEAM', sorted(seam.items(),key=lambda x:x[1],reverse=True)[:4])
if label.startswith(('Mixamo','TokenRig41','TokenRigLocal')) and '--metrics-only' not in sys.argv:
mid = first+(last-first)//3
# Re-evaluate one representative source frame before rendering.
bpy.context.scene.frame_set(mid)
bpy.context.view_layer.update()
matrices = {b.name: arm.matrix_world.inverted() @ source.matrix_world @ b.matrix for b in source.pose.bones}
for b in arm.pose.bones:
b.matrix = matrices[b.name]
bpy.context.view_layer.update()
points = coords(mesh, True)
center = Vector(((points[:,0].min()+points[:,0].max())/2, (points[:,1].min()+points[:,1].max())/2, (points[:,2].min()+points[:,2].max())/2))
camera = configure_render(height, center)
for view, direction in [('front',(0,-3,.12)), ('side',(3,0,.12)), ('back',(0,3,.12))]:
camera.location = center+Vector(direction)*height
camera.rotation_euler = (center-camera.location).to_track_quat('-Z','Y').to_euler()
bpy.context.scene.render.filepath = str(out/(label.replace(' ','_')+'_'+view+'.png'))
bpy.ops.render.render(write_still=True)
return result
def main():
root, out = map(Path, sys.argv[sys.argv.index('--')+1:][:2])
out.mkdir(parents=True, exist_ok=True)
base = root/'AIClient/Assets/Bundles/Characters/Human/test/Boy_mixamo'
soma = root/'Tools/output/campguide_studio/rig_soma'
jobs = [('Mixamo '+name, base/'CampGuideStudio.fbx', base/('CampGuideStudio@'+name+'.fbx')) for name in ['Walking','Breathing Idle','Happy Idle']]
jobs += [('SOMA '+name, soma/'kimodo'/name/'baked.glb',None) for name in ['Idle','Walk']]
results=[]
for label,mesh,clip in jobs:
result=audit(label,mesh,clip,out)
results.append(result)
(out/'comparison.json').write_text(json.dumps(results,indent=2),encoding='utf-8')
print('AUDIT_DONE',out/'comparison.json')
if __name__ == '__main__':
main()
+81 -11
View File
@@ -7,6 +7,7 @@ import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
SUPPORTED_MODEL_EXTENSIONS = (".fbx", ".glb", ".gltf")
FBX_YUP_EXPORT_VERSION = 2
def model_format(path):
@@ -87,18 +88,87 @@ def prepare_game_export(obj, restore_axis=None):
def export_fbx_source_yup(filepath):
"""导出与混元 GLB 相同的 Y-up FBX。调用前顶点须已是 Y-up。"""
"""Export prepared Y-up geometry as an actual Y-up FBX scene.
prepare_game_export stores Y-up vertices inside Blender's Z-up workspace.
Passing those straight to the FBX exporter adds a -90 X object rotation,
so consumers applying the complete FBX transform see a horizontal model.
Restore Blender Z-up on temporary copies, then bake the export conversion.
Leave source meshes, UVs, transforms and selection unchanged for callers.
"""
import math
import bpy
bpy.ops.export_scene.fbx(
filepath=filepath,
use_selection=True,
object_types={"MESH"},
axis_forward="-Z",
axis_up="Y",
bake_space_transform=False,
path_mode="STRIP",
embed_textures=False,
)
from mathutils import Matrix
selected = list(bpy.context.selected_objects)
active = bpy.context.view_layer.objects.active
meshes = [obj for obj in selected if obj.type == "MESH"]
if not meshes:
raise ValueError("Select at least one prepared Y-up mesh for FBX export")
copies = []
source_names = [(source, source.name) for source in meshes]
try:
bpy.ops.object.select_all(action="DESELECT")
for source, source_name in source_names:
duplicate = source.copy()
duplicate.data = source.data.copy()
copies.append(duplicate)
bpy.context.scene.collection.objects.link(duplicate)
source.name = source_name + "__fbx_export_source"
duplicate.name = source_name
duplicate.parent = None
duplicate.matrix_world = Matrix.Rotation(math.pi / 2, 4, "X") @ source.matrix_world
duplicate.select_set(True)
bpy.context.view_layer.objects.active = copies[0]
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
bpy.ops.export_scene.fbx(
filepath=filepath,
use_selection=True,
object_types={"MESH"},
axis_forward="-Z",
axis_up="Y",
bake_space_transform=True,
apply_unit_scale=True,
apply_scale_options="FBX_SCALE_ALL",
bake_anim=False,
path_mode="STRIP",
embed_textures=False,
)
return validate_fbx_yup_scene(filepath)
finally:
for duplicate in copies:
mesh = duplicate.data
bpy.data.objects.remove(duplicate, do_unlink=True)
if mesh.users == 0:
bpy.data.meshes.remove(mesh)
for obj in selected:
obj.select_set(True)
for source, source_name in source_names:
source.name = source_name
bpy.context.view_layer.objects.active = active
def validate_fbx_yup_scene(filepath):
"""Reject the old extra node rotation even when raw vertices look Y-up."""
from io_scene_fbx import parse_fbx
root, _ = parse_fbx.parse(str(filepath))
def properties(element):
block = next(e for e in element.elems if e.id == b"Properties70")
return {p.props[0].decode(): list(p.props[4:]) for p in block.elems}
settings = properties(next(e for e in root.elems if e.id == b"GlobalSettings"))
if settings.get("UpAxis") != [1] or settings.get("UpAxisSign") != [1]:
raise ValueError("Exported FBX scene is not +Y-up")
objects = next(e for e in root.elems if e.id == b"Objects")
for model in (e for e in objects.elems if e.id == b"Model"):
values = properties(model)
if any(abs(v) > 1e-5 for v in values.get("Lcl Rotation", [0, 0, 0])):
raise ValueError("Exported FBX retains an unbaked node rotation")
if any(abs(v - 1) > 1e-5 for v in values.get("Lcl Scaling", [1, 1, 1])):
raise ValueError("Exported FBX retains an unbaked node scale")
return {"exportVersion": FBX_YUP_EXPORT_VERSION, "sceneUp": "+Y",
"nodeRotationBaked": True, "nodeScaleBaked": True,
"unitScaleFactor": settings.get("UnitScaleFactor", [1])[0]}
TOLERANCE = 0.03 # 面数相对误差容忍
MAX_RETRY = 2 # ratio 修正轮数上限
@@ -0,0 +1,81 @@
"""Weld coincident geometry, retain corner UV seams, then collapse to target.
Studio GLBs split vertices at normals and UV seams. Decimating those as loose
triangles drops patches rather than simplifying a continuous surface.
"""
import hashlib
import json
import sys
from pathlib import Path
import bpy
import bmesh
source, target, requested = sys.argv[sys.argv.index('--') + 1:][:3]
source, target, requested = Path(source), Path(target), int(requested)
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=str(source), merge_vertices=False)
objects = [o for o in bpy.context.scene.objects if o.type == 'MESH']
assert len(objects) == 1
obj = objects[0]
bpy.context.view_layer.objects.active = obj
bpy.ops.object.select_all(action='DESELECT')
obj.select_set(True)
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
assert obj.data.uv_layers.active
def corners():
# Reordering by bmesh is harmless; the (position, per-corner UV) multiset
# must stay the same through welding, including UV seam discontinuities.
uv = obj.data.uv_layers.active.data
return sorted(tuple(round(c, 6) for c in (*obj.data.vertices[l.vertex_index].co, *uv[l.index].uv)) for l in obj.data.loops)
before_corners = corners()
vertices_before = len(obj.data.vertices)
bm = bmesh.new()
bm.from_mesh(obj.data)
bmesh.ops.remove_doubles(bm, verts=list(bm.verts), dist=max(obj.dimensions) * 1e-7)
bm.to_mesh(obj.data)
bm.free()
obj.data.update()
assert before_corners == corners(), 'Welding changed the existing corner UV mapping'
vertices_welded = len(obj.data.vertices)
tri = obj.modifiers.new('Triangulate', 'TRIANGULATE')
bpy.ops.object.modifier_apply(modifier=tri.name)
before = len(obj.data.polygons)
area_before = sum(p.area for p in obj.data.polygons)
modifier = obj.modifiers.new('PreserveCornerUVDecimate', 'DECIMATE')
modifier.decimate_type = 'COLLAPSE'
modifier.use_collapse_triangulate = True
lower, upper = 0.0, 1.0
candidate = min(1.0, requested / before)
best = None
for _ in range(22):
modifier.ratio = candidate
bpy.context.view_layer.update()
count = len(obj.evaluated_get(bpy.context.evaluated_depsgraph_get()).data.polygons)
rank = (abs(count - requested), count > requested)
if best is None or rank < best[0]: best = (rank, candidate)
if count == requested: break
if count > requested: upper = candidate
else: lower = candidate
candidate = (lower + upper) / 2
modifier.ratio = best[1]
bpy.ops.object.modifier_apply(modifier=modifier.name)
after = len(obj.data.polygons)
area_after = sum(p.area for p in obj.data.polygons)
print('Final faces', after, 'target', requested, 'best', best, flush=True)
assert after <= requested * 1.30, (after, requested, best)
assert area_after >= area_before * .80, 'Reduction lost too much surface area'
assert obj.data.uv_layers.active
bpy.ops.export_scene.gltf(filepath=str(target), export_format='GLB', use_selection=True,
export_materials='EXPORT', export_image_format='AUTO')
report = dict(faces_before=before, faces_after=after, target_faces=requested,
vertices_before=vertices_before, vertices_after_weld=vertices_welded,
cornerUVPreservedDuringWeld=True, uv_mode='preserved_no_unwrap',
area_before=area_before, area_after=area_after, area_ratio=area_after / area_before,
ratio=best[1], threshold_percent=30, triggered=True)
target.with_suffix('.json').write_text(json.dumps(report, indent=2), encoding='utf-8')
print('MT_SUMMARY ' + json.dumps(report), flush=True)
@@ -0,0 +1,194 @@
"""Measure bone trajectories independently of skinning for the shared walk."""
import json
import sys
import math
from pathlib import Path
import bpy
import numpy as np
from mathutils import Matrix,Vector,Quaternion
ROOT=Path(__file__).resolve().parents[2]
FOLDER=ROOT/'Tools/output/lmgirl_tpose_studio/rig_mixamo41'
SOURCE=json.loads((ROOT/'Tools/output/campguide_studio/rig_mixamo41/unity_review/review_data.json').read_text())
def quaternion(q):return Quaternion((q[3],*q[:3]))
def sample(data,frame):
source_indices={b['name']:i for i,b in enumerate(SOURCE['bones'])}
values=[SOURCE['clips'][0]['samples'][frame][source_indices[b['name']]] for b in data['bones']]
source_rot=[];mapped=[];world=[]
for i,(b,v) in enumerate(zip(data['bones'],values)):
parent=b['parent'];q=quaternion(v[3:7]);source_rot.append(source_rot[parent]@q if parent>=0 else q)
mapped.append(source_rot[i]@quaternion(data['bindRotationCorrections'][i]) if data.get('bindRotationCorrections') else source_rot[i])
q=mapped[parent].inverted()@mapped[i] if parent>=0 else mapped[i]
p=Vector(v[:3])
if data.get('translationOffsets'):
p*=data['translationScale']
if data.get('translationDeltaRotations'):p=quaternion(data['translationDeltaRotations'][i])@p
p+=Vector(data['translationOffsets'][i])
local=Matrix.LocRotScale(p,q,Vector(v[7:10]));world.append(world[parent]@local if parent>=0 else local)
return {b['name'].split(':')[-1]:world[i] for i,b in enumerate(data['bones'])}
def audit():
results={}
for label,data in [('boy',SOURCE),('girl_tokenrig',json.loads((FOLDER/'unity_review_data.json').read_text())),
('girl_mixamo',json.loads((FOLDER/'website/unity_review_data.json').read_text()))]:
samples=[]
rest={b['name'].split(':')[-1]:Matrix(b['bindpose']).inverted() for b in data['bones']}
for f in range(32):
pose=sample(data,f);hips=pose['Hips'].translation
lateral=(pose['LeftUpLeg'].translation-pose['RightUpLeg'].translation).normalized()
feet={side:list(pose[side+'Foot'].translation-hips) for side in ['Left','Right']}
gap=(pose['LeftFoot'].translation-pose['RightFoot'].translation).dot(lateral)
knees=(pose['LeftLeg'].translation-pose['RightLeg'].translation).dot(lateral)
samples.append({'frame':f+1,'footSignedGap':gap,'kneeSignedGap':knees,'feetRelativeHips':feet})
results[label]={'restHipWidth':(rest['LeftUpLeg'].translation-rest['RightUpLeg'].translation).length,
'restAnkleWidth':(rest['LeftFoot'].translation-rest['RightFoot'].translation).length,
'leftRestLegVector':list(rest['LeftFoot'].translation-rest['LeftUpLeg'].translation),
'minimumFootSignedGap':min(s['footSignedGap'] for s in samples),'minimumKneeSignedGap':min(s['kneeSignedGap'] for s in samples),
'crossedFrames':[s['frame'] for s in samples if s['footSignedGap']<0],
'samples':samples}
(FOLDER/'gait_diagnosis_before.json').write_text(json.dumps(results,indent=2),encoding='utf-8')
print(json.dumps({k:{n:v for n,v in d.items() if n!='samples'} for k,d in results.items()},indent=2))
def corrected_motion(data,label):
names=[b['name'].split(':')[-1] for b in data['bones']];indices={n:i for i,n in enumerate(names)}
parents=[b['parent'] for b in data['bones']]
rest={b['name'].split(':')[-1]:Matrix(b['bindpose']).inverted() for b in data['bones']}
source_rest={b['name'].split(':')[-1]:Matrix(b['bindpose']).inverted() for b in SOURCE['bones']}
target_width=(rest['LeftUpLeg'].translation-rest['RightUpLeg'].translation).length
source_width=(source_rest['LeftUpLeg'].translation-source_rest['RightUpLeg'].translation).length
scale=Vector((target_width/source_width,data['translationScale'],data['translationScale']))
samples=[];tracks=[];previous={}
for frame in range(32):
source=sample(SOURCE,frame);initial=sample(data,frame)
local=[]
for i,n in enumerate(names):
m=initial[names[parents[i]]].inverted()@initial[n] if parents[i]>=0 else initial[n]
p,q,s=m.decompose();local.append([p.copy(),q.copy(),s.copy()])
def fk():
result=[]
for i,(p,q,s) in enumerate(local):
m=Matrix.LocRotScale(p,q,s);result.append(result[parents[i]]@m if parents[i]>=0 else m)
return result
world=fk();target_hips=world[indices['Hips']].translation.copy();source_hips=source['Hips'].translation.copy()
goals={};errors={}
for side in ['Left','Right']:
upper=indices[side+'UpLeg'];lower=indices[side+'Leg'];foot=indices[side+'Foot']
source_foot=source[side+'Foot'].translation-source_hips;source_knee=source[side+'Leg'].translation-source_hips
goal=target_hips+Vector(tuple(source_foot[i]*scale[i] for i in range(3)))
hint=target_hips+Vector(tuple(source_knee[i]*scale[i] for i in range(3)))
h=world[upper].translation.copy();k=world[lower].translation.copy();f=world[foot].translation.copy()
foot_q=world[foot].to_quaternion().copy();a=(k-h).length;b=(f-k).length
direction=goal-h;wanted_distance=direction.length;direction.normalize()
distance=max(abs(a-b)+1e-7,min(wanted_distance,a+b-1e-7))
reachable=h+direction*distance
bend=hint-h-direction*(hint-h).dot(direction)
if bend.length<1e-7:bend=k-h-direction*(k-h).dot(direction)
if bend.length<1e-7:bend=Vector((0,0,-1))-direction*direction.dot(Vector((0,0,-1)))
bend.normalize();along=(a*a+distance*distance-b*b)/(2*distance)
desired_knee=h+direction*along+bend*math.sqrt(max(0,a*a-along*along))
upper_q=(k-h).rotation_difference(desired_knee-h)@world[upper].to_quaternion()
local[upper][1]=world[parents[upper]].to_quaternion().inverted()@upper_q
world=fk()
lower_q=(world[foot].translation-world[lower].translation).rotation_difference(reachable-world[lower].translation)@world[lower].to_quaternion()
local[lower][1]=world[parents[lower]].to_quaternion().inverted()@lower_q
world=fk();local[foot][1]=world[parents[foot]].to_quaternion().inverted()@foot_q;world=fk()
goals[side]=list(goal);errors[side]=(world[foot].translation-goal).length
values=[]
for i,(p,q,s) in enumerate(local):
if i in previous and previous[i].dot(q)<0:q.negate()
previous[i]=q.copy();values.append(list(p)+[q.x,q.y,q.z,q.w]+list(s))
samples.append(values)
lateral=(world[indices['LeftUpLeg']].translation-world[indices['RightUpLeg']].translation).normalized()
gap=(world[indices['LeftFoot']].translation-world[indices['RightFoot']].translation).dot(lateral)
tracks.append({'frame':frame+1,'footSignedGap':gap,'goalErrors':errors,'goals':goals,
'positions':{n:list(world[i].translation) for n,i in indices.items() if n in ['Hips','LeftUpLeg','RightUpLeg','LeftLeg','RightLeg','LeftFoot','RightFoot']}})
return {'name':'Walking_LmGirl'+('Mixamo33' if label=='mixamo' else '41'),'fps':30,'samples':samples}, {
'footMotionScale':list(scale),'minimumFootSignedGap':min(t['footSignedGap'] for t in tracks),
'crossedFrames':[t['frame'] for t in tracks if t['footSignedGap']<0],
'maxUnreachableGoalError':max(max(t['goalErrors'].values()) for t in tracks),'tracks':tracks}
def correct():
result={'method':'Pelvis-relative source ankle paths scaled by target hip width and hip-to-ankle span; analytic two-bone IK with source knee hints, fixed segment lengths and preserved mapped foot world rotations.',
'sourceClipUnmodified':True,'sourceClips':'CampGuide native Walking','motions':{},'reports':{}}
for label,path in [('tokenrig',FOLDER/'unity_review_data.json'),('mixamo',FOLDER/'website/unity_review_data.json')]:
result['motions'][label],result['reports'][label]=corrected_motion(json.loads(path.read_text()),label)
result['deformation']={}
for label,path in [('tokenrig',FOLDER/'unity_review_data.json'),('mixamo',FOLDER/'website/unity_review_data.json')]:
data=json.loads(path.read_text());motion=result['motions'][label];parents=[b['parent'] for b in data['bones']]
for variant in data['variants']:
points=np.array(variant['vertices']);p4=np.column_stack((points,np.ones(len(points))))
weights=np.zeros((len(points),len(parents)))
for i,row in enumerate(variant['weights']):
for j,w in row:weights[i,j]=w
edges=np.array(variant['auditEdges']);ids=edges[:,:2].astype(int);lengths=edges[:,2];masks=edges[:,3].astype(int)
maxima={name:0 for name in ['all','hands','neck']};over=maxima.copy();floors=[]
for values in motion['samples']:
world=[]
for i,v in enumerate(values):
local=Matrix.LocRotScale(Vector(v[:3]),quaternion(v[3:7]),Vector(v[7:10]));world.append(world[parents[i]]@local if parents[i]>=0 else local)
deformed=np.zeros_like(points)
for i,b in enumerate(data['bones']):deformed+=weights[:,i,None]*(p4@np.array(world[i]@Matrix(b['bindpose'])).T)[:,:3]
ratios=np.linalg.norm(deformed[ids[:,0]]-deformed[ids[:,1]],axis=1)/lengths;floors.append(float(deformed[:,1].min()))
for name,mask in [('all',1),('hands',2),('neck',4)]:
region=ratios[(masks&mask)!=0];maxima[name]=max(maxima[name],float(region.max()));over[name]=max(over[name],int((region>2).sum()))
result['deformation'][variant['name']]={'maxStretch':maxima,'maxEdgesOver2x':over,'floorMinRange':[min(floors),max(floors)]}
(FOLDER/'gait_correction.json').write_text(json.dumps(result,indent=2),encoding='utf-8')
print(json.dumps({k:{n:v for n,v in d.items() if n!='tracks'} for k,d in result['reports'].items()},indent=2))
def export_corrected():
result=json.loads((FOLDER/'gait_correction.json').read_text());C=Matrix(((1,0,0,0),(0,0,1,0),(0,1,0,0),(0,0,0,1)))
receipts=[]
for tag in ['raw','post','clean','clean_eps','local']:
bpy.ops.wm.open_mainfile(filepath=str(FOLDER/('lmgirl_'+tag+'.blend')))
arm=next(o for o in bpy.context.scene.objects if o.type=='ARMATURE');arm.animation_data_clear();arm.data.pose_position='POSE'
names=[b.name for b in arm.data.bones];data=json.loads((FOLDER/'unity_review_data.json').read_text());parents=[b['parent'] for b in data['bones']]
for b in arm.pose.bones:b.rotation_mode='QUATERNION'
for f,values in enumerate(result['motions']['tokenrig']['samples'],1):
bpy.context.scene.frame_set(f);world=[]
for i,v in enumerate(values):
m=Matrix.LocRotScale(Vector(v[:3]),quaternion(v[3:7]),Vector(v[7:10]));world.append(world[parents[i]]@m if parents[i]>=0 else m)
for i,n in enumerate(names):
b=arm.pose.bones[n];b.matrix=arm.matrix_world.inverted()@C@world[i]@C;bpy.context.view_layer.update()
for attr in ['location','rotation_quaternion','scale']:b.keyframe_insert(attr,frame=f)
bpy.context.scene.frame_start=1;bpy.context.scene.frame_end=32;bpy.context.scene.render.fps=30
path=FOLDER/('Walking_'+tag+'_corrected.glb')
bpy.ops.export_scene.gltf(filepath=str(path),export_format='GLB',export_skins=True,export_animations=True,
export_animation_mode='ACTIVE_ACTIONS',export_force_sampling=True,export_def_bones=False)
receipts.append({'file':str(path),'frames':[1,32],'source':result['motions']['tokenrig']['name']})
(FOLDER/'gait_export_manifest.json').write_text(json.dumps(receipts,indent=2),encoding='utf-8')
def verify_exports():
from mathutils.kdtree import KDTree
data=json.loads((FOLDER/'unity_review_data.json').read_text());result=json.loads((FOLDER/'gait_correction.json').read_text());receipts=[]
for tag in ['raw','post','clean','clean_eps','local']:
variant=next(v for v in data['variants'] if v['name']==tag)
bpy.ops.wm.read_factory_settings(use_empty=True);bpy.context.scene.render.fps=30
bpy.ops.import_scene.gltf(filepath=str(FOLDER/('Walking_'+tag+'_corrected.glb')))
arm=next(o for o in bpy.context.scene.objects if o.type=='ARMATURE');mesh=next(o for o in bpy.context.scene.objects if o.type=='MESH')
# Mesh raw geometry is Blender Z-up; convert to native review Y-up.
points=np.array([mesh.matrix_world@v.co for v in mesh.data.vertices]);points=points[:,[0,2,1]]
tree=KDTree(len(points))
for i,p in enumerate(points):tree.insert(Vector(p),i)
tree.balance();mapping=[]
for p in variant['vertices']:
_,index,distance=tree.find(Vector(p));assert distance<1e-5;mapping.append(index)
edges=np.array(variant['auditEdges']);ids=edges[:,:2].astype(int);max_stretch=0;min_gap=100
for frame in range(1,33):
bpy.context.scene.frame_set(frame);bpy.context.view_layer.update()
obj=mesh.evaluated_get(bpy.context.evaluated_depsgraph_get());geometry=obj.to_mesh()
deformed=np.array([obj.matrix_world@v.co for v in geometry.vertices])[mapping];obj.to_mesh_clear()
max_stretch=max(max_stretch,float((np.linalg.norm(deformed[ids[:,0]]-deformed[ids[:,1]],axis=1)/edges[:,2]).max()))
p={n:arm.matrix_world@arm.pose.bones['mixamorig:'+n].head for n in ['LeftUpLeg','RightUpLeg','LeftFoot','RightFoot']}
lateral=(p['LeftUpLeg']-p['RightUpLeg']).normalized();min_gap=min(min_gap,(p['LeftFoot']-p['RightFoot']).dot(lateral))
error=abs(max_stretch-result['deformation'][tag]['maxStretch']['all']);assert error<.003,(tag,error)
assert min_gap>.08,(tag,min_gap)
receipts.append({'variant':tag,'maxStretch':max_stretch,'error':error,'minimumFootSignedGap':min_gap})
(FOLDER/'gait_export_verification.json').write_text(json.dumps({'passed':True,'results':receipts},indent=2),encoding='utf-8')
print('GAIT_EXPORTS_VERIFIED',len(receipts))
if __name__=='__main__':
if '--correct' in sys.argv:correct()
elif '--export' in sys.argv:export_corrected()
elif '--verify-exports' in sys.argv:verify_exports()
else:audit()
@@ -0,0 +1,109 @@
"""Export exact comparison mesh/bind/pose data for Pipeline-authored Unity assets.
Blender Z-up -> Unity Y-up is (x,z,y), so anatomical -Y faces Unity -Z.
All asset creation is subsequently performed inside Unity through Pipeline.
"""
import json
import sys
from pathlib import Path
import bpy
import numpy as np
from math import pi
from mathutils import Matrix
root=Path(sys.argv[sys.argv.index('--')+1])
folder=root/'Tools/output/campguide_studio/rig_mixamo41'
base=root/'AIClient/Assets/Bundles/Characters/Human/test/Boy_mixamo'
out=folder/'unity_review'
out.mkdir(exist_ok=True)
C=Matrix(((1,0,0,0),(0,0,1,0),(0,1,0,0),(0,0,0,1)))
def trs(m):
p,q,s=m.decompose()
return list(p)+[q.x,q.y,q.z,q.w]+list(s)
result={'coordinateSystem':'Unity Y-up, -Z front; positions in metres','variants':[],'bones':[],'clips':[]}
names=None
for label,path in [('Mixamo',base/'CampGuideStudio.fbx'),('TokenRigRaw',folder/'tokenrig_mixamo41.blend'),('TokenRigLocal',folder/'tokenrig_mixamo41_local.blend')]:
bpy.ops.wm.read_factory_settings(use_empty=True)
if path.suffix=='.blend':bpy.ops.wm.open_mainfile(filepath=str(path))
else:bpy.ops.import_scene.fbx(filepath=str(path))
arm=next(o for o in bpy.data.objects if o.type=='ARMATURE')
mesh=next(o for o in bpy.data.objects if o.type=='MESH')
pts=np.array([mesh.matrix_world@v.co for v in mesh.data.vertices])
normalize=Matrix.Rotation(pi/2,4,'X') if np.argmax(np.ptp(pts,axis=0))==1 else Matrix.Identity(4)
world={b.name:C@normalize@arm.matrix_world@b.matrix_local@C for b in arm.data.bones}
if names is None:
names=[b.name for b in arm.data.bones]
for b in arm.data.bones:
parent=b.parent.name if b.parent else None
local=world[parent].inverted()@world[b.name] if parent else world[b.name]
result['bones'].append({'name':b.name,'parent':names.index(parent) if parent else -1,
'rest':trs(local),'bindpose':[list(row) for row in world[b.name].inverted()]})
assert set(names)==set(arm.data.bones.keys())
weights=[]
for v in mesh.data.vertices:
w={mesh.vertex_groups[g.group].name:g.weight for g in v.groups if g.weight>1e-8}
total=sum(w.values())
weights.append([[names.index(n),value/total] for n,value in sorted(w.items(),key=lambda x:-x[1])])
transform=C@normalize@mesh.matrix_world
normal_transform=transform.to_3x3().inverted().transposed()
mesh.data.calc_loop_triangles()
uv_layer=mesh.data.uv_layers.active
vertices=[];normals=[];uv=[];influences=[];triangles=[];lookup={};first={}
for triangle in mesh.data.loop_triangles:
ids=[]
for loop_index in triangle.loops:
loop=mesh.data.loops[loop_index]
vi=loop.vertex_index
n=normal_transform@mesh.data.corner_normals[loop_index].vector
n.normalize()
tex=list(uv_layer.data[loop_index].uv) if uv_layer else [0,0]
key=(vi,*[round(x,6) for x in tex],*[round(x,6) for x in n])
if key not in lookup:
index=len(vertices);lookup[key]=index;first.setdefault(vi,index)
vertices.append(list(transform@mesh.data.vertices[vi].co));normals.append(list(n));uv.append(tex);influences.append(weights[vi])
ids.append(lookup[key])
triangles.extend([ids[0],ids[2],ids[1]])
# A reflection reverses the winding; the reversed triangle order above is
# checked visually in Unity and does not alter the comparison edge lengths.
raw=np.array([normalize@mesh.matrix_world@v.co for v in mesh.data.vertices])
height=float(np.ptp(raw[:,2]));z=(raw[:,2]-raw[:,2].min())/height
hand=(np.abs(raw[:,0])>height*.255)&(z>.55)&(z<.67)
neck=(z>.67)&(z<.78)&(np.abs(raw[:,0])<height*.12)
head=z>.78
edges=[]
for edge in mesh.data.edges:
a,b=edge.vertices
length=float(np.linalg.norm(raw[a]-raw[b]))
if length<=height*.002:continue
mask=1|(2 if hand[a] and hand[b] else 0)|(4 if neck[a] and neck[b] else 0)|(8 if head[a] and head[b] else 0)
edges.append([first[a],first[b],length,mask])
result['variants'].append({'name':label,'vertices':vertices,'normals':normals,'uv':uv,'triangles':triangles,
'weights':influences,'auditEdges':edges,'originalVertices':len(mesh.data.vertices),'trianglesCount':len(triangles)//3})
print('MESH',label,len(vertices),len(triangles)//3)
for label in ['Walking','Breathing Idle','Happy Idle']:
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=str(base/('CampGuideStudio@'+label+'.fbx')))
arm=next(o for o in bpy.data.objects if o.type=='ARMATURE')
first,last=map(int,arm.animation_data.action.frame_range)
samples=[];previous={}
for frame in range(first,last+1):
bpy.context.scene.frame_set(frame);bpy.context.view_layer.update()
world={n:C@arm.matrix_world@arm.pose.bones[n].matrix@C for n in names}
sample=[]
for i,n in enumerate(names):
parent=result['bones'][i]['parent']
local=world[names[parent]].inverted()@world[n] if parent>=0 else world[n]
values=trs(local)
if n in previous and sum(a*b for a,b in zip(previous[n],values[3:7]))<0:
values[3:7]=[-x for x in values[3:7]]
previous[n]=values[3:7]
sample.append(values)
samples.append(sample)
result['clips'].append({'name':label.replace(' ','_'),'fps':30,'samples':samples})
print('CLIP',label,len(samples))
(out/'review_data.json').write_text(json.dumps(result,separators=(',',':')),encoding='utf-8')
print('EXPORTED',out/'review_data.json')
+387
View File
@@ -0,0 +1,387 @@
"""Export a reusable Mixamo rest pose, or fit it to a humanoid mesh.
Blender:
blender -b --python Tools/model_tools/bl_fit_mixamo.py -- template <src.fbx> <dst.fbx>
blender -b --python Tools/model_tools/bl_fit_mixamo.py -- fit <template.fbx> <mesh.fbx> <out.glb> [palm|full]
blender -b --python Tools/model_tools/bl_fit_mixamo.py -- remap <fitted.glb> <tokenrig.glb> <out.glb>
"""
import json
import sys
from pathlib import Path
import bpy
import numpy as np
from math import pi
from mathutils import Matrix, Vector
BODY = [
"mixamorig:Hips", "mixamorig:Spine", "mixamorig:Spine1", "mixamorig:Spine2",
"mixamorig:Neck", "mixamorig:Head", "mixamorig:HeadTop_End",
"mixamorig:LeftShoulder", "mixamorig:LeftArm", "mixamorig:LeftForeArm", "mixamorig:LeftHand",
"mixamorig:RightShoulder", "mixamorig:RightArm", "mixamorig:RightForeArm", "mixamorig:RightHand",
"mixamorig:LeftUpLeg", "mixamorig:LeftLeg", "mixamorig:LeftFoot", "mixamorig:LeftToeBase", "mixamorig:LeftToe_End",
"mixamorig:RightUpLeg", "mixamorig:RightLeg", "mixamorig:RightFoot", "mixamorig:RightToeBase", "mixamorig:RightToe_End",
]
def argv():
return sys.argv[sys.argv.index("--") + 1:]
def mesh_objects():
return [o for o in bpy.context.scene.objects if o.type == "MESH"]
def armature():
arms = [o for o in bpy.context.scene.objects if o.type == "ARMATURE"]
if len(arms) != 1:
raise SystemExit(f"expected one armature, found {len(arms)}")
return arms[0]
def apply_transform(obj):
bpy.ops.object.select_all(action="DESELECT")
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
def bone_heads(arm):
return {b.name: arm.matrix_world @ b.head_local for b in arm.data.bones}
def points_of(obj):
if obj.type == "MESH":
return np.array([obj.matrix_world @ v.co for v in obj.data.vertices], dtype=np.float64)
return np.array([obj.matrix_world @ b.head_local for b in obj.data.bones], dtype=np.float64)
def rotate_axis_to_y(obj, axis):
if axis == 1:
return
rotation = Matrix.Rotation(-pi / 2, 4, "X") if axis == 2 else Matrix.Rotation(pi / 2, 4, "Z")
obj.matrix_world = rotation @ obj.matrix_world
apply_transform(obj)
def put_height_on_y(obj):
if obj.type == "ARMATURE":
heads = bone_heads(obj)
delta = heads["mixamorig:Head"] - heads["mixamorig:Hips"]
axis = int(np.argmax(np.abs([delta.x, delta.y, delta.z])))
else:
span = points_of(obj).max(0) - points_of(obj).min(0)
order = np.argsort(span)
axis = int(order[-1] if span[order[-1]] > span[order[-2]] * 1.25 else order[-2])
rotate_axis_to_y(obj, axis)
def face_negative_z(arm):
heads = bone_heads(arm)
forward = heads["mixamorig:LeftToeBase"] - heads["mixamorig:LeftFoot"]
if abs(forward.x) > abs(forward.z):
arm.matrix_world = Matrix.Rotation(pi / 2 if forward.x > 0 else -pi / 2, 4, "Y") @ arm.matrix_world
apply_transform(arm)
heads = bone_heads(arm)
forward = heads["mixamorig:LeftToeBase"] - heads["mixamorig:LeftFoot"]
if forward.z > 0:
arm.matrix_world = Matrix.Rotation(pi, 4, "Y") @ arm.matrix_world
apply_transform(arm)
def clear_motion(arm):
if arm.animation_data:
arm.animation_data_clear()
arm.data.pose_position = "REST"
def export_fbx(arm, path):
bpy.ops.object.select_all(action="DESELECT")
arm.select_set(True)
bpy.context.view_layer.objects.active = arm
bpy.ops.export_scene.fbx(
filepath=str(path), use_selection=True, object_types={"ARMATURE"},
add_leaf_bones=False, bake_anim=False,
axis_forward="-Z", axis_up="Y", bake_space_transform=False,
)
def template(src, dst):
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=str(src))
for obj in list(mesh_objects()):
bpy.data.objects.remove(obj, do_unlink=True)
arm = armature()
clear_motion(arm)
put_height_on_y(arm)
face_negative_z(arm)
heads = bone_heads(arm)
feet = [heads[n] for n in heads if n.endswith("Foot") or n.endswith("ToeBase") or n.endswith("Toe_End")]
min_y = min(p.y for p in feet)
center_x = (heads["mixamorig:LeftUpLeg"].x + heads["mixamorig:RightUpLeg"].x) * 0.5
center_z = heads["mixamorig:Hips"].z
arm.location = Vector((-center_x, -min_y, -center_z))
apply_transform(arm)
dst = Path(dst)
dst.parent.mkdir(parents=True, exist_ok=True)
export_fbx(arm, dst)
heads = bone_heads(arm)
top = max(p.y for p in heads.values())
info = {
"bones": len(arm.data.bones),
"names": [b.name for b in arm.data.bones],
"height": round(top, 4),
"up": "Y",
"front": "-Z",
"left": "-X",
"source": str(src),
}
dst.with_suffix(".json").write_text(json.dumps(info, ensure_ascii=False, indent=2), encoding="utf-8")
print("TEMPLATE", json.dumps({k: info[k] for k in ("bones", "height", "up", "front")}))
def slice_centers(pts, y0, y1):
sl = pts[(pts[:, 1] >= y0) & (pts[:, 1] < y1)]
return sl
def landmarks(pts):
lo, hi = pts.min(0), pts.max(0)
height = hi[1] - lo[1]
bands = np.linspace(lo[1], hi[1], 36)
spans = []
for a, b in zip(bands[:-1], bands[1:]):
sl = slice_centers(pts, a, b)
spans.append(0 if len(sl) < 8 else sl[:, 0].max() - sl[:, 0].min())
arm_i = int(np.argmax(spans))
arm_y = (bands[arm_i] + bands[arm_i + 1]) * 0.5
arm = slice_centers(pts, arm_y - height * 0.04, arm_y + height * 0.04)
reach = max(abs(arm[:, 0].min()), abs(arm[:, 0].max()))
left_hand = arm[arm[:, 0] < -reach * 0.55].mean(0)
right_hand = arm[arm[:, 0] > reach * 0.55].mean(0)
torso = arm[np.abs(arm[:, 0]) < reach * 0.38]
if len(torso) < 8:
torso = arm
shoulders = np.array([torso[:, 0].min(), arm_y, torso[:, 2].mean()])
feet = pts[pts[:, 1] < lo[1] + height * 0.12]
left_foot = feet[feet[:, 0] < 0].mean(0)
right_foot = feet[feet[:, 0] > 0].mean(0)
crotch_y = arm_y
for a, b in zip(bands[:arm_i][::-1], bands[1:arm_i + 1][::-1]):
sl = slice_centers(pts, a, b)
if len(sl) < 8:
continue
xs = np.sort(sl[:, 0])
if np.diff(xs).max() > reach * 0.18:
crotch_y = (a + b) * 0.5
break
hip_y = min(arm_y - height * 0.05, crotch_y + height * 0.06)
head = pts[pts[:, 1] > lo[1] + height * 0.78]
return {
"height": height,
"arm_y": arm_y,
"hip_y": hip_y,
"head": head.mean(0),
"top": hi[1],
"left_hand": left_hand,
"right_hand": right_hand,
"left_shoulder_x": float(torso[:, 0].min()),
"right_shoulder_x": float(torso[:, 0].max()),
"left_foot": left_foot,
"right_foot": right_foot,
"depth": head[:, 2].mean() if False else float(head[:, 2].mean()),
}
def chain_fraction(arm, names):
ys = [(arm.matrix_world @ arm.data.bones[n].head_local).y for n in names if n in arm.data.bones]
span = ys[-1] - ys[0]
if abs(span) < 1e-6:
return [0.0 for _ in ys]
return [(y - ys[0]) / span for y in ys]
def move_bones(arm, marks):
bpy.ops.object.select_all(action="DESELECT")
arm.select_set(True)
bpy.context.view_layer.objects.active = arm
torso_names = [n for n in ("mixamorig:Hips", "mixamorig:Spine", "mixamorig:Spine1", "mixamorig:Spine2", "mixamorig:Neck", "mixamorig:Head", "mixamorig:HeadTop_End") if n in arm.data.bones]
torso_fr = chain_fraction(arm, torso_names)
side_fr = {}
for side in ("Left", "Right"):
arm_names = [f"mixamorig:{side}{p}" for p in ("Shoulder", "Arm", "ForeArm", "Hand") if f"mixamorig:{side}{p}" in arm.data.bones]
leg_names = [n for n in (f"mixamorig:{side}UpLeg", f"mixamorig:{side}Leg", f"mixamorig:{side}Foot") if n in arm.data.bones]
side_fr[side] = (arm_names, chain_fraction(arm, arm_names), leg_names, chain_fraction(arm, leg_names))
bpy.ops.object.mode_set(mode="EDIT")
bones = arm.data.edit_bones
rolls = {b.name: b.roll for b in bones}
present = {b.name: b for b in bones}
def put(name, head, tail):
bone = present[name]
bone.use_connect = False
bone.head = Vector(head)
bone.tail = Vector(tail)
bone.roll = rolls[name]
def between(a, b, t):
return a * (1 - t) + b * t
fr = torso_fr
hip = np.array([0.0, marks["hip_y"], marks["depth"]])
crown = np.array([marks["head"][0] * 0.2, marks["top"], marks["head"][2]])
placed = {}
for name, t in zip(torso_names, fr):
placed[name] = between(hip, crown, t)
for i, name in enumerate(torso_names):
nxt = placed[torso_names[i + 1]] if i + 1 < len(torso_names) else placed[name] + np.array([0, marks["height"] * 0.03, 0])
put(name, placed[name], nxt)
for side, sign, hand, foot, shoulder_x in (
("Left", -1, marks["left_hand"], marks["left_foot"], marks["left_shoulder_x"]),
("Right", 1, marks["right_hand"], marks["right_foot"], marks["right_shoulder_x"]),
):
shoulder = np.array([shoulder_x, marks["arm_y"], marks["depth"]])
hand = np.array(hand)
arm_names, afr, leg_core, lfr = side_fr[side]
joints = [between(shoulder, hand, t) for t in afr]
for i, name in enumerate(arm_names):
nxt = joints[i + 1] if i + 1 < len(joints) else joints[i] + (hand - shoulder) * 0.08
put(name, joints[i], nxt)
hip_j = np.array([foot[0] * 0.72, marks["hip_y"], marks["depth"]])
foot_j = np.array([foot[0], max(foot[1], 0.0), foot[2]])
leg_names = leg_core + [n for n in (f"mixamorig:{side}ToeBase", f"mixamorig:{side}Toe_End") if n in present]
leg = [between(hip_j, foot_j, t) for t in lfr]
toe = foot_j + np.array([0.0, 0.0, -abs(marks["height"]) * 0.06])
toe_end = toe + np.array([0.0, 0.0, -abs(marks["height"]) * 0.035])
extra = {f"mixamorig:{side}ToeBase": toe, f"mixamorig:{side}Toe_End": toe_end}
seq = leg + [extra[n] for n in leg_names[3:]]
for i, name in enumerate(leg_names):
nxt = seq[i + 1] if i + 1 < len(seq) else seq[i] + np.array([0, 0, -0.02])
put(name, seq[i], nxt)
bpy.ops.object.mode_set(mode="OBJECT")
def drop_fingers(arm):
bpy.ops.object.mode_set(mode="EDIT")
keys = ("Thumb", "Index", "Middle", "Ring", "Pinky")
guard = 0
while guard < 8:
guard += 1
bones = list(arm.data.edit_bones)
leaves = [b for b in bones if any(k in b.name for k in keys) and not any(c.parent == b for c in bones)]
if not leaves:
break
for bone in leaves:
arm.data.edit_bones.remove(bone)
bpy.ops.object.mode_set(mode="OBJECT")
def fit(template_path, mesh_path, out_path, fingers):
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=str(mesh_path))
mesh = mesh_objects()[0]
put_height_on_y(mesh)
before = set(bpy.data.objects)
bpy.ops.import_scene.fbx(filepath=str(template_path))
arm = next(o for o in bpy.context.scene.objects if o not in before and o.type == "ARMATURE")
clear_motion(arm)
put_height_on_y(arm)
face_negative_z(arm)
if fingers == "palm":
drop_fingers(arm)
marks = landmarks(points_of(mesh))
move_bones(arm, marks)
heads = bone_heads(arm)
print("MARKS", json.dumps({k: (round(float(v), 3) if np.ndim(v) == 0 else [round(float(x), 3) for x in v]) for k, v in marks.items() if k != "head"}))
for name in ("mixamorig:Hips", "mixamorig:Head", "mixamorig:LeftHand", "mixamorig:LeftFoot", "mixamorig:Spine2"):
p = heads[name]
print(name, round(p.x, 3), round(p.y, 3), round(p.z, 3))
mesh.parent = arm
arm.matrix_world = Matrix.Rotation(pi / 2, 4, "X") @ arm.matrix_world
apply_transform(arm)
out = Path(out_path)
out.parent.mkdir(parents=True, exist_ok=True)
bpy.ops.export_scene.gltf(filepath=str(out), export_format="GLB", export_yup=True, export_skins=True)
names = [b.name for b in arm.data.bones]
print("FIT", json.dumps({"bones": len(names), "fingers": fingers, "out": str(out)}))
def remap_names(source_glb, rigged_glb, out_glb):
"""TokenRig --use_skeleton renames joints to bone_* and can reverse a limb chain.
Match each output joint to the fitted Mixamo bone by head position, then copy
that bone's parent, head, tail and roll. Vertex groups follow the new names.
"""
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=str(source_glb))
src = armature()
bpy.context.view_layer.objects.active = src
bpy.ops.object.mode_set(mode="EDIT")
src_pose = {}
for bone in src.data.edit_bones:
src_pose[bone.name] = {
"parent": bone.parent.name if bone.parent else None,
"head": src.matrix_world @ bone.head,
"tail": src.matrix_world @ bone.tail,
"roll": bone.roll,
}
bpy.ops.object.mode_set(mode="OBJECT")
for obj in list(bpy.data.objects):
bpy.data.objects.remove(obj, do_unlink=True)
bpy.ops.import_scene.gltf(filepath=str(rigged_glb))
arm = armature()
mesh = mesh_objects()[0]
used = set()
mapping = {}
for bone in arm.data.bones:
head = arm.matrix_world @ bone.head_local
name, dist = min(
((n, (head - pose["head"]).length) for n, pose in src_pose.items() if n not in used),
key=lambda item: item[1],
)
used.add(name)
mapping[bone.name] = (name, dist)
bpy.context.view_layer.objects.active = arm
bpy.ops.object.mode_set(mode="EDIT")
for old, (new, _dist) in mapping.items():
arm.data.edit_bones[old].name = new
for bone in arm.data.edit_bones:
bone.use_connect = False
bone.parent = None
for bone in arm.data.edit_bones:
pose = src_pose[bone.name]
bone.head = arm.matrix_world.inverted() @ pose["head"]
bone.tail = arm.matrix_world.inverted() @ pose["tail"]
bone.roll = pose["roll"]
for bone in arm.data.edit_bones:
parent = src_pose[bone.name]["parent"]
bone.parent = arm.data.edit_bones[parent] if parent else None
bpy.ops.object.mode_set(mode="OBJECT")
for group in mesh.vertex_groups:
if group.name in mapping:
group.name = mapping[group.name][0]
worst = max(dist for _, dist in mapping.values())
if worst > 0.05:
raise SystemExit(f"bone heads moved, worst={worst:.4f}")
out = Path(out_glb)
out.parent.mkdir(parents=True, exist_ok=True)
bpy.ops.export_scene.gltf(filepath=str(out), export_format="GLB", export_yup=True, export_skins=True)
print("REMAP", json.dumps({"bones": len(mapping), "worst": round(worst, 4), "out": str(out)}))
def main():
args = argv()
if args[0] == "template":
template(args[1], args[2])
elif args[0] == "fit":
fit(args[1], args[2], args[3], args[4] if len(args) > 4 else "palm")
elif args[0] == "remap":
remap_names(args[1], args[2], args[3])
else:
raise SystemExit("use template, fit, or remap")
if __name__ == "__main__":
main()
+316
View File
@@ -0,0 +1,316 @@
"""Fit a Kimodo SOMA rest pose onto a humanoid mesh without changing bone axes.
Blender:
blender -b --python Tools/model_tools/bl_fit_soma.py -- template <motion.bvh> <dst.fbx>
blender -b --python Tools/model_tools/bl_fit_soma.py -- fit <template.fbx> <mesh.fbx> <out.glb> [palm|full]
"""
import json
import sys
from pathlib import Path
import bpy
import numpy as np
from math import pi
from mathutils import Matrix, Vector
DROP = ("Thumb", "Index", "Middle", "Ring", "Pinky", "Jaw", "Eye")
def argv():
return sys.argv[sys.argv.index("--") + 1:]
def meshes():
return [o for o in bpy.context.scene.objects if o.type == "MESH"]
def armature():
arms = [o for o in bpy.context.scene.objects if o.type == "ARMATURE"]
if len(arms) != 1:
raise SystemExit(f"expected one armature, found {len(arms)}")
return arms[0]
def apply_transform(obj):
bpy.ops.object.select_all(action="DESELECT")
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
def bone_heads(arm):
return {b.name: arm.matrix_world @ b.head_local for b in arm.data.bones}
def points_of(obj):
return np.array([obj.matrix_world @ v.co for v in obj.data.vertices], dtype=np.float64)
def rotate_up_to_z(obj):
if obj.type == "ARMATURE":
heads = bone_heads(obj)
delta = heads["Head"] - heads["Hips"]
axis = int(np.argmax(np.abs([delta.x, delta.y, delta.z])))
else:
span = points_of(obj).max(0) - points_of(obj).min(0)
axis = int(np.argmax(span))
if axis == 2:
return
rotation = Matrix.Rotation(pi / 2, 4, "X") if axis == 1 else Matrix.Rotation(-pi / 2, 4, "Y")
obj.matrix_world = rotation @ obj.matrix_world
apply_transform(obj)
def face_positive_y(arm):
heads = bone_heads(arm)
forward = heads["LeftToeBase"] - heads["LeftFoot"]
flat = Vector((forward.x, forward.y, 0.0))
if flat.length < 1e-6:
return
angle = flat.angle(Vector((0.0, 1.0, 0.0)), 0.0)
cross = flat.x * 1.0 - flat.y * 0.0
signed = angle if cross >= 0 else -angle
arm.matrix_world = Matrix.Rotation(signed, 4, "Z") @ arm.matrix_world
apply_transform(arm)
heads = bone_heads(arm)
if heads["LeftHand"].x > heads["RightHand"].x:
arm.matrix_world = Matrix.Rotation(pi, 4, "Z") @ arm.matrix_world
apply_transform(arm)
def clear_motion(arm):
if arm.animation_data:
arm.animation_data_clear()
arm.data.pose_position = "REST"
def plant(arm):
heads = bone_heads(arm)
feet = [heads[n] for n in heads if n.endswith("Foot") or "Toe" in n]
hips = heads["Hips"]
arm.location = Vector((-hips.x, -hips.y, -min(p.z for p in feet)))
apply_transform(arm)
def extend_stubs(arm):
bpy.ops.object.mode_set(mode="EDIT")
for bone in arm.data.edit_bones:
if (bone.tail - bone.head).length > 1e-4:
continue
direction = bone.parent.vector.normalized() if bone.parent and bone.parent.vector.length > 1e-6 else Vector((0.0, 1.0, 0.0))
bone.tail = bone.head + direction * 0.015
bpy.ops.object.mode_set(mode="OBJECT")
def export_fbx(arm, path):
bpy.ops.object.select_all(action="DESELECT")
arm.select_set(True)
bpy.context.view_layer.objects.active = arm
path = Path(path)
path.parent.mkdir(parents=True, exist_ok=True)
bpy.ops.export_scene.fbx(
filepath=str(path), use_selection=True, object_types={"ARMATURE"},
add_leaf_bones=False, bake_anim=False,
axis_forward="Y", axis_up="Z",
)
def template(bvh, dst):
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_anim.bvh(filepath=str(bvh), global_scale=0.01, rotate_mode="QUATERNION")
arm = armature()
clear_motion(arm)
rotate_up_to_z(arm)
plant(arm)
extend_stubs(arm)
export_fbx(arm, dst)
heads = bone_heads(arm)
info = {
"bones": len(arm.data.bones),
"names": [b.name for b in arm.data.bones],
"height": round(max(p.z for p in heads.values()) - min(p.z for p in heads.values()), 4),
"up": "Z-in-blender",
"front": "+Y-in-blender",
"left": "-X",
"source": str(bvh),
}
Path(dst).with_suffix(".json").write_text(json.dumps(info, ensure_ascii=False, indent=2), encoding="utf-8")
hand = heads["LeftHand"]
toe = heads["LeftToeBase"] - heads["LeftFoot"]
print("TEMPLATE", json.dumps({"bones": info["bones"], "height": info["height"], "leftHandX": round(hand.x, 3), "toeY": round(toe.y, 3)}))
def landmarks(pts):
lo, hi = pts.min(0), pts.max(0)
height = hi[2] - lo[2]
bands = np.linspace(lo[2] + height * 0.25, lo[2] + height * 0.8, 12)
spans = []
for a, b in zip(bands[:-1], bands[1:]):
sl = pts[(pts[:, 2] >= a) & (pts[:, 2] < b)]
spans.append(0 if len(sl) < 8 else sl[:, 0].max() - sl[:, 0].min())
arm_i = int(np.argmax(spans))
arm_z = (bands[arm_i] + bands[arm_i + 1]) * 0.5
arm = pts[np.abs(pts[:, 2] - arm_z) < height * 0.04]
reach = max(abs(arm[:, 0].min()), abs(arm[:, 0].max()))
torso = arm[np.abs(arm[:, 0]) < reach * 0.38]
if len(torso) < 8:
torso = arm
feet = pts[pts[:, 2] < lo[2] + height * 0.12]
head = pts[pts[:, 2] > lo[2] + height * 0.78]
hip_z = arm_z - height * 0.16
return {
"height": float(height),
"arm_z": float(arm_z),
"hip_z": float(hip_z),
"head": head.mean(0),
"top": float(hi[2]),
"left_hand": arm[arm[:, 0] > reach * 0.55].mean(0),
"right_hand": arm[arm[:, 0] < -reach * 0.55].mean(0),
"left_foot": feet[feet[:, 0] > 0].mean(0),
"right_foot": feet[feet[:, 0] < 0].mean(0),
"depth": float(torso[:, 1].mean()),
}
def subtree(bones, root):
names = [root]
pending = [root]
while pending:
parent = pending.pop()
for bone in bones:
if bone.parent and bone.parent.name == parent and bone.name not in names:
names.append(bone.name)
pending.append(bone.name)
return names
def scale_from(bones, names, pivot, factor):
for name in names:
bone = bones[name]
bone.head = pivot + (bone.head - pivot) * factor
bone.tail = pivot + (bone.tail - pivot) * factor
def drop_extras(arm):
bpy.ops.object.mode_set(mode="EDIT")
guard = 0
while guard < 8:
guard += 1
bones = list(arm.data.edit_bones)
leaves = [b for b in bones if any(k in b.name for k in DROP) and not any(c.parent == b for c in bones)]
if not leaves:
break
for bone in leaves:
arm.data.edit_bones.remove(bone)
bpy.ops.object.mode_set(mode="OBJECT")
def fit(template_path, mesh_path, out_path, fingers):
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=str(mesh_path))
mesh = max(meshes(), key=lambda item: len(item.data.vertices))
for extra in list(meshes()):
if extra != mesh:
bpy.data.objects.remove(extra, do_unlink=True)
rotate_up_to_z(mesh)
mesh.matrix_world = Matrix.Rotation(pi, 4, "Z") @ mesh.matrix_world
apply_transform(mesh)
before = set(bpy.data.objects)
if str(template_path).lower().endswith(".bvh"):
bpy.ops.import_anim.bvh(filepath=str(template_path), global_scale=0.01, rotate_mode="QUATERNION")
else:
bpy.ops.import_scene.fbx(filepath=str(template_path))
arm = next(o for o in bpy.context.scene.objects if o not in before and o.type == "ARMATURE")
clear_motion(arm)
rotate_up_to_z(arm)
plant(arm)
if fingers == "palm":
drop_extras(arm)
extend_stubs(arm)
marks = landmarks(points_of(mesh))
bpy.context.view_layer.objects.active = arm
bpy.ops.object.mode_set(mode="EDIT")
bones = arm.data.edit_bones
for bone in bones:
bone.use_connect = False
height = max(b.head.z for b in bones) - min(b.head.z for b in bones)
scale_from(bones, [b.name for b in bones], Vector((0.0, 0.0, 0.0)), marks["height"] / height)
hips = bones["Hips"].head.copy()
shift = Vector((0.0, marks["depth"] - hips.y, marks["hip_z"] - hips.z))
for bone in bones:
bone.head += shift
bone.tail += shift
before_dirs = {b.name: b.vector.normalized() for b in bones if b.vector.length > 1e-6}
hip = bones["Hips"].head.copy()
torso_names = subtree(bones, "Spine1")
head_z = bones["Head"].head.z
torso_factor = (marks["head"][2] - hip.z) / (head_z - hip.z)
print("FACTOR torso", round(torso_factor, 3))
if not 0.4 <= torso_factor <= 2.5:
raise SystemExit(f"torso factor {torso_factor}")
scale_from(bones, torso_names, hip, torso_factor)
for side, target in (("Left", marks["left_hand"]), ("Right", marks["right_hand"])):
pivot = bones[f"{side}Arm"].head.copy()
hand = bones[f"{side}Hand"].head
print("HAND", side, "bone", round(hand.x, 3), "target", round(float(target[0]), 3), "pivot", round(pivot.x, 3))
factor = (target[0] - pivot.x) / (hand.x - pivot.x)
print("FACTOR", side, "arm", round(factor, 3))
if not 0.2 <= factor <= 2.5:
raise SystemExit(f"{side} arm factor {factor}")
names = subtree(bones, f"{side}Arm")
scale_from(bones, names, pivot, factor)
hand = bones[f"{side}Hand"].head
delta = Vector((0.0, target[1] - hand.y, target[2] - hand.z))
for name in names:
bones[name].head += delta
bones[name].tail += delta
for side, target in (("Left", marks["left_foot"]), ("Right", marks["right_foot"])):
pivot = bones[f"{side}Leg"].head.copy()
foot = bones[f"{side}Foot"].head
factor = (target[2] - pivot.z) / (foot.z - pivot.z)
print("FACTOR", side, "leg", round(factor, 3))
if not 0.2 <= factor <= 2.5:
raise SystemExit(f"{side} leg factor {factor}")
names = subtree(bones, f"{side}Leg")
scale_from(bones, names, pivot, factor)
foot = bones[f"{side}Foot"].head
delta = Vector((target[0] - foot.x, target[1] - foot.y, 0.0))
for name in names:
bones[name].head += delta
bones[name].tail += delta
worst = 0.0
for name, direction in before_dirs.items():
if name in ("LeftShoulder", "RightShoulder"):
continue
now = bones[name].vector.normalized()
worst = max(worst, direction.angle(now, 0.0))
bpy.ops.object.mode_set(mode="OBJECT")
heads = bone_heads(arm)
print("MARKS", json.dumps({k: (round(float(v), 3) if np.ndim(v) == 0 else [round(float(x), 3) for x in v]) for k, v in marks.items() if k != "head"}))
for name in ("Hips", "Head", "LeftHand", "LeftFoot", "Chest"):
p = heads[name]
print(name, round(p.x, 3), round(p.y, 3), round(p.z, 3))
print("AXIS_DRIFT_DEG", round(worst * 180 / pi, 3))
if worst > 0.02:
raise SystemExit("bone axes moved")
mesh.parent = arm
arm.name = "Armature"
arm.data.name = "Armature"
out = Path(out_path)
out.parent.mkdir(parents=True, exist_ok=True)
bpy.ops.export_scene.gltf(filepath=str(out), export_format="GLB", export_yup=True, export_skins=True)
print("FIT", json.dumps({"bones": len(arm.data.bones), "fingers": fingers, "out": str(out)}))
def main():
args = argv()
if args[0] == "template":
template(args[1], args[2])
elif args[0] == "fit":
fit(args[1], args[2], args[3], args[4] if len(args) > 4 else "palm")
else:
raise SystemExit("use template or fit")
if __name__ == "__main__":
main()
@@ -0,0 +1,73 @@
"""Create an upright Mixamo upload FBX from the legacy Y-up-in-Blender mesh.
Do not replace the original: existing rigs and review assets use its coordinates.
"""
import sys,math,json,hashlib
from pathlib import Path
import bpy
import numpy as np
from mathutils import Matrix
from io_scene_fbx import parse_fbx
ROOT=Path(__file__).resolve().parents[2]
SOURCE=ROOT/'Tools/output/lmgirl_tpose_studio/static/LmGirlTpose.fbx'
OUT=ROOT/'Tools/output/lmgirl_tpose_studio/mixamo_upload'
TARGET=OUT/'LmGirlTpose_Mixamo.fbx'
def digest(path):return hashlib.sha256(path.read_bytes()).hexdigest()
def vertices(o):return np.array([o.matrix_world@v.co for v in o.data.vertices],dtype=float)
def uv(o):return np.array([tuple(v.uv) for v in o.data.uv_layers.active.data],dtype=np.float32)
def triangles(o):return sum(len(p.vertices)-2 for p in o.data.polygons)
OUT.mkdir(parents=True,exist_ok=True)
before_hash=digest(SOURCE)
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=str(SOURCE),use_anim=False)
meshes=[o for o in bpy.context.scene.objects if o.type=='MESH']
assert len(meshes)==1 and not any(o.type=='ARMATURE' for o in bpy.context.scene.objects)
obj=meshes[0];old_points=vertices(obj);old_uv=uv(obj);old_triangles=triangles(obj)
assert np.argmax(np.ptp(old_points,axis=0))==1,'This repair is only for the legacy Y-up Blender source'
# Blender's exporter expects a Z-up scene. Restore that before its Y-up conversion.
rotation=Matrix.Rotation(math.pi/2,4,'X')
obj.matrix_world=rotation@obj.matrix_world
bpy.context.view_layer.update()
bpy.ops.object.select_all(action='DESELECT');obj.select_set(True);bpy.context.view_layer.objects.active=obj
bpy.ops.object.transform_apply(location=True,rotation=True,scale=True)
expected=vertices(obj)
assert np.argmax(np.ptp(expected,axis=0))==2
bpy.ops.export_scene.fbx(filepath=str(TARGET),use_selection=True,object_types={'MESH'},
axis_forward='-Z',axis_up='Y',bake_space_transform=True,
apply_unit_scale=True,apply_scale_options='FBX_SCALE_ALL',bake_anim=False,
path_mode='STRIP',embed_textures=False)
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=str(TARGET),use_anim=False)
restored=next(o for o in bpy.context.scene.objects if o.type=='MESH')
points=vertices(restored);new_uv=uv(restored)
assert points.shape==expected.shape and np.max(np.abs(points-expected))<2e-6
assert np.array_equal(new_uv,old_uv),'UVs changed'
assert triangles(restored)==old_triangles
tree,_=parse_fbx.parse(str(TARGET))
objects=next(e for e in tree.elems if e.id==b'Objects')
model=next(e for e in objects.elems if e.id==b'Model')
props=next(e for e in model.elems if e.id==b'Properties70')
node_props={e.props[0].decode():list(e.props[4:]) for e in props.elems}
assert max(abs(v) for v in node_props.get('Lcl Rotation',[0,0,0]))<1e-5
assert all(abs(v-1)<1e-5 for v in node_props.get('Lcl Scaling',[1,1,1]))
geometry=next(e for e in objects.elems if e.id==b'Geometry')
raw_points=np.array(next(e for e in geometry.elems if e.id==b'Vertices').props[0]).reshape(-1,3)
assert np.max(np.abs(raw_points-old_points))<2e-6,'Y-up geometry/front changed'
assert digest(SOURCE)==before_hash,'Original source changed'
report=dict(source=str(SOURCE.relative_to(ROOT)),output=str(TARGET.relative_to(ROOT)),
source_sha256=before_hash,output_sha256=digest(TARGET),source_unchanged=True,
repair='Restore Blender Z-up scene (+90 X), apply object transforms, export Y-up with baked space conversion',
vertices=len(points),triangles=old_triangles,uv_unchanged=True,
raw_fbx_node_rotation_degrees=node_props.get('Lcl Rotation',[0,0,0]),
raw_fbx_node_scale=node_props.get('Lcl Scaling',[1,1,1]),
raw_yup_geometry_max_error_m=float(np.max(np.abs(raw_points-old_points))),
max_roundtrip_position_error_m=float(np.max(np.abs(points-expected))),
blender_roundtrip_rotation_degrees=[math.degrees(v) for v in restored.rotation_euler],
blender_roundtrip_bounds_min=points.min(0).tolist(),blender_roundtrip_bounds_max=points.max(0).tolist(),
online_validation='Not uploaded to Mixamo; local FBX metadata and independent reimport checked')
(OUT/'repair.json').write_text(json.dumps(report,indent=2),encoding='utf-8')
print(json.dumps(report,indent=2))
@@ -0,0 +1,30 @@
"""Inventory local Mixamo FBX files without editing or exporting the inputs."""
import json
import sys
from pathlib import Path
import bpy
src, out = map(Path, sys.argv[sys.argv.index('--')+1:])
results = []
for path in sorted(src.glob('*.fbx')):
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=str(path))
item = {'file': str(path), 'fps': bpy.context.scene.render.fps, 'objects': [],
'actions': [{'name': a.name, 'frames': list(a.frame_range)} for a in bpy.data.actions]}
for obj in bpy.context.scene.objects:
entry = {'name': obj.name, 'type': obj.type, 'matrix_world': [list(row) for row in obj.matrix_world]}
if obj.type == 'ARMATURE':
entry['bones'] = {b.name: {'head': list(obj.matrix_world @ b.head_local), 'tail': list(obj.matrix_world @ b.tail_local),
'parent': b.parent.name if b.parent else None} for b in obj.data.bones}
entry['action'] = obj.animation_data.action.name if obj.animation_data and obj.animation_data.action else None
elif obj.type == 'MESH':
coords = [obj.matrix_world @ v.co for v in obj.data.vertices]
entry.update(vertices=len(coords), triangles=sum(len(p.vertices)-2 for p in obj.data.polygons),
bounds=[[min(p[i] for p in coords) for i in range(3)], [max(p[i] for p in coords) for i in range(3)]],
groups=len(obj.vertex_groups), modifiers=[{'type': m.type, 'target': m.object.name if m.type == 'ARMATURE' and m.object else None} for m in obj.modifiers])
item['objects'].append(entry)
results.append(item)
print('INVENTORY', path.name, item['fps'], item['actions'], [(o['type'], o['name'], o.get('triangles'), len(o.get('bones', {}))) for o in item['objects']])
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(results, indent=2), encoding='utf-8')
+372
View File
@@ -0,0 +1,372 @@
"""LmGirl fixed Mixamo-41 preparation, weight variants and motion review data.
All website weights are discarded. Fitting preserves bone axes and hierarchy;
the source animation can keep its rotations with target bind translations.
"""
import json
import importlib.util
import hashlib
import math
import sys
from pathlib import Path
import bpy
import numpy as np
from mathutils import Matrix, Vector
ROOT=Path(__file__).resolve().parents[2]
OUT=ROOT/'Tools/output/lmgirl_tpose_studio/rig_mixamo41'
STATIC=ROOT/'Tools/output/lmgirl_tpose_studio/static/LmGirlTpose.fbx'
REFERENCE=ROOT/'AIClient/Assets/Bundles/Characters/Human/test/Boy_mixamo/CampGuideStudio.fbx'
def write(name,data):
OUT.mkdir(parents=True,exist_ok=True)
(OUT/name).write_text(json.dumps(data,indent=2),encoding='utf-8')
def mesh_points(mesh):return np.array([mesh.matrix_world@v.co for v in mesh.data.vertices])
def load():
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=str(STATIC))
mesh=next(o for o in bpy.context.scene.objects if o.type=='MESH')
assert not any(o.type=='ARMATURE' for o in bpy.context.scene.objects)
# Project static FBX contains Y-up mesh coordinates; glTF needs Blender Z-up.
if np.argmax(np.ptp(mesh_points(mesh),axis=0))==1:
mesh.matrix_world=Matrix.Rotation(math.pi/2,4,'X')@mesh.matrix_world
bpy.ops.object.select_all(action='DESELECT');mesh.select_set(True);bpy.context.view_layer.objects.active=mesh
bpy.ops.object.transform_apply(location=True,rotation=True,scale=True)
pts=mesh_points(mesh)
lo,hi=pts.min(0),pts.max(0)
mesh.location-=Vector(((lo[0]+hi[0])/2,(lo[1]+hi[1])/2,lo[2]))
bpy.ops.object.transform_apply(location=True,rotation=False,scale=False)
bpy.ops.import_scene.fbx(filepath=str(REFERENCE))
arm=next(o for o in bpy.context.scene.objects if o.type=='ARMATURE')
for obj in list(bpy.context.scene.objects):
if obj not in (mesh,arm):bpy.data.objects.remove(obj,do_unlink=True)
arm.animation_data_clear()
arm.matrix_world=Matrix.Rotation(math.pi/2,4,'X')@arm.matrix_world
bpy.context.view_layer.update()
return mesh,arm
def inspect():
mesh,arm=load();pts=mesh_points(mesh);lo,hi=pts.min(0),pts.max(0);h=hi[2]-lo[2]
bands=[]
for z in np.linspace(.03,.97,48):
a=pts[np.abs(pts[:,2]-z*h)<h*.012]
if len(a):bands.append({'z':round(float(z),3),'n':len(a),'min':a.min(0).tolist(),'max':a.max(0).tolist(),'mean':a.mean(0).tolist()})
data={'bounds':[lo.tolist(),hi.tolist()],'vertices':len(pts),'triangles':sum(len(p.vertices)-2 for p in mesh.data.polygons),'bands':bands,
'reference_bones':{b.name:list(arm.matrix_world@b.head_local) for b in arm.data.bones}}
write('inspection.json',data)
print('INSPECT',json.dumps(data))
# Plain clay inspection; source UV remains untouched.
scene=bpy.context.scene;scene.render.engine='BLENDER_WORKBENCH'
scene.display.shading.light='STUDIO';scene.display.shading.color_type='SINGLE';scene.display.shading.single_color=(.65,.68,.72)
scene.display.shading.show_shadows=True;scene.display.shading.show_cavity=True
camera=bpy.data.objects.new('InspectionCamera',bpy.data.cameras.new('InspectionCamera'));scene.collection.objects.link(camera)
camera.location=(0,-h*3,h*.5);camera.rotation_euler=(Vector((0,0,h*.5))-camera.location).to_track_quat('-Z','Y').to_euler()
camera.data.type='ORTHO';camera.data.ortho_scale=h*1.45;scene.camera=camera
scene.render.resolution_x=1100;scene.render.resolution_y=1000;scene.render.resolution_percentage=100
scene.render.filepath=str(OUT/'static_front.png');bpy.ops.render.render(write_still=True)
def prepare():
mesh,arm=load()
old={b.name:arm.matrix_world@b.matrix_local for b in arm.data.bones}
positions={
'Hips':(0,-.005,.610),'Spine':(0,-.005,.660),'Spine1':(0,-.005,.730),
'Spine2':(0,.005,.825),'Neck':(0,.010,.925),'Head':(0,.010,.965),'HeadTop_End':(0,.010,1.150)}
for side,sign in [('Left',1),('Right',-1)]:
for suffix,p in {'Shoulder':(.043,.018,.900),'Arm':(.120,.018,.877),
'ForeArm':(.248,.018,.862),'Hand':(.352,.018,.866),
'UpLeg':(.057,-.005,.571),'Leg':(.060,.006,.312),
'Foot':(.056,.018,.060),'ToeBase':(.056,-.039,.016),
'Toe_End':(.056,-.058,.016)}.items():
positions[side+suffix]=(p[0]*sign,p[1],p[2])
anchor=old['mixamorig:'+side+'Hand'].translation
for name,m in old.items():
if name.startswith('mixamorig:'+side+'Hand') and not name.endswith('Hand'):
d=m.translation-anchor
positions[name.split(':')[-1]]=Vector(positions[side+'Hand'])+Vector((d.x*.95,d.y*.35,d.z*.6))
bpy.context.view_layer.objects.active=arm
bpy.ops.object.mode_set(mode='EDIT')
inv=arm.matrix_world.inverted()
shapes={b.name:(b.tail-b.head,b.roll) for b in arm.data.edit_bones}
for b in arm.data.edit_bones:b.use_connect=False
for bone in arm.data.edit_bones:
target=inv@Vector(positions[bone.name.split(':')[-1]])
bone.head=target;bone.tail=target+shapes[bone.name][0];bone.roll=shapes[bone.name][1]
bpy.ops.object.mode_set(mode='OBJECT');bpy.context.view_layer.update()
drift=max(old[b.name].to_quaternion().rotation_difference((arm.matrix_world@b.matrix_local).to_quaternion()).angle for b in arm.data.bones)
assert drift<.001,drift
for modifier in list(mesh.modifiers):
if modifier.type=='ARMATURE':mesh.modifiers.remove(modifier)
mesh.vertex_groups.clear()
for b in arm.data.bones:mesh.vertex_groups.new(name=b.name)
mesh.vertex_groups['mixamorig:Hips'].add(list(range(len(mesh.data.vertices))),1,'REPLACE')
for i,b in enumerate(arm.data.bones):
if b.name!='mixamorig:Hips':
mesh.vertex_groups['mixamorig:Hips'].remove([i]);mesh.vertex_groups[b.name].add([i],1,'REPLACE')
world=mesh.matrix_world.copy();mesh.parent=arm;mesh.matrix_world=world
modifier=mesh.modifiers.new('TokenRigInputSkeleton','ARMATURE');modifier.object=arm
arm.data.pose_position='REST'
bpy.context.view_layer.update()
pts=mesh_points(mesh)
# Keep material payload small; final textured assets reuse the exact source
# UV and local baked maps. Transfer is still exercised on this original mesh.
write('input_manifest.json',{'static_mesh_source':str(STATIC),'skeleton_source':str(REFERENCE),
'bones':{b.name:{'parent':b.parent.name if b.parent else None,'world_matrix':[list(row) for row in arm.matrix_world@b.matrix_local],
'head':list(arm.matrix_world@b.head_local)} for b in arm.data.bones},
'vertices':len(pts),'triangles':sum(len(p.vertices)-2 for p in mesh.data.polygons),
'height':float(np.ptp(pts[:,2])),'max_preserved_axis_rotation_error_radians':float(drift),
'input_skin':'Only non-anatomical keepalive placeholders; no website weights',
'fit':'Manually specified anatomical landmark positions for this mesh; original bone axes and hierarchy retained',
'landmarks':{n:list(p) for n,p in positions.items()},'blender_up':'Z','blender_front':'-Y'})
bpy.ops.wm.save_as_mainfile(filepath=str(OUT/'prepared.blend'),compress=True)
bpy.ops.export_scene.gltf(filepath=str(OUT/'source.glb'),export_format='GLB',export_yup=True,export_skins=True,
export_animations=False,export_def_bones=False)
print('PREPARED',len(arm.data.bones),len(pts),'axis drift',drift)
def process():
from mathutils.kdtree import KDTree
from collections import deque
bpy.ops.wm.open_mainfile(filepath=str(OUT/'prepared.blend'))
arm=next(o for o in bpy.context.scene.objects if o.type=='ARMATURE')
mesh=next(o for o in bpy.context.scene.objects if o.type=='MESH')
arm.data.pose_position='POSE'
names=[b.name for b in arm.data.bones];pts=mesh_points(mesh)
parents=[names.index(b.parent.name) if b.parent else -1 for b in arm.data.bones]
heads=np.array([arm.matrix_world@b.head_local for b in arm.data.bones])
matrices={b.name:arm.matrix_world@b.matrix_local for b in arm.data.bones}
variants={}
for tag in ['raw','post']:
decoded=json.loads((OUT/(tag+'_decoded_weights.json')).read_text())
tree=KDTree(len(decoded))
for i,d in enumerate(decoded):
x,y,z=d['position_y_up'];tree.insert(Vector((x,-z,y)),i)
tree.balance();w=np.zeros((len(pts),41));dist=[]
for i,p in enumerate(pts):
_,j,distance=tree.find(Vector(p));dist.append(distance)
for name,value in decoded[j]['weights'].items():w[i,names.index(name)]+=value
assert max(dist)<1e-5
w/=w.sum(1,keepdims=True);variants[tag]=w
# Run the upstream pure-array repair on the RESTORED original graph and
# world coordinates. TokenRig's temporary graph/quantized axes are excluded.
spec=importlib.util.spec_from_file_location('weight_repair_upstream',OUT/'weight_repair_upstream.py')
repair=importlib.util.module_from_spec(spec);sys.modules[spec.name]=repair;spec.loader.exec_module(repair)
hop=np.full((41,41),99,dtype=int);adj=[[] for _ in names]
for i,p in enumerate(parents):
if p>=0:adj[i].append(p);adj[p].append(i)
for i in range(41):
hop[i,i]=0;q=deque([i])
while q:
a=q.popleft()
for b in adj[a]:
if hop[i,b]==99:hop[i,b]=hop[i,a]+1;q.append(b)
ids=np.argsort(-variants['post'],axis=1)[:,:4]
values=np.take_along_axis(variants['post'],ids,axis=1)
clean_ids,clean_weights,stats=repair.repair_weights_array(pts,ids,values,heads,hop,joint_names=names)
clean=np.zeros_like(variants['post'])
np.put_along_axis(clean,clean_ids,clean_weights,axis=1);clean/=clean.sum(1,keepdims=True)
variants['clean']=clean
eps=clean.copy();eps[eps<.01]=0;eps/=eps.sum(1,keepdims=True);variants['clean_eps']=eps
# Conservative character-specific alternative: the low-poly hands have no
# separated finger loops, and the braid must follow the head. Preserve 41
# joints, but use rigid hand/head influences in those identifiable regions.
local=variants['raw'].copy()
local_changes=[]
for i,p in enumerate(pts):
before=local[i].copy()
if p[2]>.945:
local[i]=0;local[i,names.index('mixamorig:Head')]=1
elif abs(p[0])>.335:
side='Left' if p[0]>0 else 'Right'
local[i]=0;local[i,names.index('mixamorig:'+side+'Hand')]=1
elif abs(p[0])>.15 and .77<p[2]<.94:
side='Left' if p[0]>0 else 'Right'
allowed=np.array([n in ['mixamorig:'+side+'Arm','mixamorig:'+side+'ForeArm','mixamorig:'+side+'Hand'] for n in names])
# Merge finger channels into wrist before applying the sleeve mask.
amount=sum(local[i,j] for j,n in enumerate(names) if n.startswith('mixamorig:'+side+'Hand'))
local[i,~allowed]=0;local[i,names.index('mixamorig:'+side+'Hand')]=amount
if local[i].sum()<1e-8:local[i,names.index('mixamorig:'+side+'ForeArm')]=1
local[i]/=local[i].sum()
elif p[2]<.48:
side='Left' if p[0]>0 else 'Right'
allowed=np.array([n=='mixamorig:Hips' or n in ['mixamorig:'+side+x for x in ['UpLeg','Leg','Foot','ToeBase','Toe_End']] for n in names])
local[i,~allowed]=0
if local[i].sum()<1e-8:local[i,names.index('mixamorig:'+side+'Leg')]=1
local[i]/=local[i].sum()
if np.abs(before-local[i]).sum()>.001:local_changes.append(i)
local[local<.01]=0;local/=local.sum(1,keepdims=True);variants['local']=local
write('cleanup.json',{'source':'https://github.com/stevelittlefish/ComfyUI-SkinTokens-NoBlender/blob/main/skintokens/weight_repair.py',
'algorithm':'Upstream repair_weights_array, original restored skeleton graph and world-space coordinates',
'min_hops':5,'repaired_vertices':stats.n_vertices_repaired,'removed_influences':stats.n_influences_removed,
'removed_by_bone':stats.removed_by_bone,'pairs':{' | '.join(k):v for k,v in stats.comingled_pairs.items()},
'epsilon':.01,'epsilon_changed_vertices':int(np.any(np.abs(eps-clean)>1e-7,axis=1).sum())})
write('local_constraints.json',{'base':'raw predicted weights; no voxel sharpening',
'rules':['head/braid above 0.945m rigid to Head','hands outside |x|=0.335m rigid to same-side Hand',
'sleeves outside |x|=0.15m and 0.77<z<0.94m allow same-side Arm/ForeArm/Hand only',
'below z=0.48m allow Hips and same-side UpLeg/Leg/Foot/Toe only; crotch blending retained','epsilon=0.01; renormalize'],
'changed_vertices_over_0_001':len(local_changes),'vertices':local_changes,
'limitation':'41 bones retained; separated finger articulation is intentionally not used for this mitten-like low-poly hand mesh; no mesh/topology changes'})
C=Matrix(((1,0,0,0),(0,0,1,0),(0,1,0,0),(0,0,0,1)))
def trs(m):
p,q,s=m.decompose();return list(p)+[q.x,q.y,q.z,q.w]+list(s)
world={n:C@m@C for n,m in matrices.items()}
bones=[]
for i,n in enumerate(names):
local=world[names[parents[i]]].inverted()@world[n] if parents[i]>=0 else world[n]
bones.append({'name':n,'parent':parents[i],'rest':trs(local),'bindpose':[list(r) for r in world[n].inverted()]})
source=json.loads((ROOT/'Tools/output/campguide_studio/rig_mixamo41/unity_review/review_data.json').read_text())
assert names==[b['name'] for b in source['bones']]
# Foot/hip span ratio determines root travel and vertical motion scaling.
ratio=(.610-.060)/(.5381249189-.0935430303)
offsets=[(np.array(b['rest'][:3])-np.array(s['rest'][:3])*ratio).tolist() for b,s in zip(bones,source['bones'])]
from mathutils import Quaternion
clip=source['clips'][0]
poses=[]
for sample in clip['samples']:
m=[]
for i,values in enumerate(sample):
p=Vector(np.array(values[:3])*ratio+offsets[i]);q=Quaternion((values[6],*values[3:6]))
local=Matrix.LocRotScale(p,q,Vector(values[7:10]));m.append(m[parents[i]]@local if parents[i]>=0 else local)
poses.append(m)
rest=np.array([C@Vector(p) for p in pts]);h=float(np.ptp(pts[:,2]))
edges=np.array([list(e.vertices) for e in mesh.data.edges]);lengths=np.linalg.norm(rest[edges[:,0]]-rest[edges[:,1]],axis=1)
masks={'all':lengths>h*.002,'hands':(np.abs(pts[:,0])>.32),'neck':(pts[:,2]>.91)&(pts[:,2]<.99)&(np.abs(pts[:,0])<.1)}
masks={k:(v if k=='all' else v[edges[:,0]]&v[edges[:,1]]&(lengths>h*.002)) for k,v in masks.items()}
report={};result={'bones':bones,'translationScale':ratio,'translationOffsets':offsets,'variants':[],
'height':h,'walkingFrames':len(poses),'reuseClips':'CampGuide original native AnimationClips, angular curves unchanged'}
for tag,w in variants.items():
mesh.vertex_groups.clear()
for n in names:mesh.vertex_groups.new(name=n)
for i,row in enumerate(w):
for j in np.flatnonzero(row>1e-8):mesh.vertex_groups[names[j]].add([i],float(row[j]),'REPLACE')
arm.animation_data_clear()
for b in arm.pose.bones:b.matrix_basis=Matrix.Identity(4)
bpy.context.view_layer.update()
bpy.ops.wm.save_as_mainfile(filepath=str(OUT/('lmgirl_'+tag+'.blend')),compress=True)
bpy.ops.export_scene.gltf(filepath=str(OUT/('lmgirl_'+tag+'.glb')),export_format='GLB',export_yup=True,export_skins=True,export_animations=False,export_def_bones=False)
maxs={k:0 for k in masks};over={k:0 for k in masks};samples=[];floors=[];worst={};p99=0
p4=np.column_stack((rest,np.ones(len(rest))))
for frame,pose in enumerate(poses):
deformed=np.zeros_like(rest)
for j,n in enumerate(names):
transform=np.array(pose[j]@world[n].inverted())
deformed+=w[:,j,None]*(p4@transform.T)[:,:3]
ratios=np.linalg.norm(deformed[edges[:,0]]-deformed[edges[:,1]],axis=1)/np.maximum(lengths,1e-10)
floors.append(float(deformed[:,1].min()))
ei=int(np.argmax(np.where(masks['all'],ratios,0)))
if float(ratios[ei])>worst.get('ratio',0):
worst={'frame':frame+1,'ratio':float(ratios[ei]),'vertices':edges[ei].tolist(),
'rest':pts[edges[ei]].tolist(),'weights':[{names[j]:float(w[i,j]) for j in np.flatnonzero(w[i]>1e-8)} for i in edges[ei]]}
p99=max(p99,float(np.percentile(ratios[masks['all']],99)))
for k,mask in masks.items():
maxs[k]=max(maxs[k],float(ratios[mask].max()));over[k]=max(over[k],int((ratios[mask]>2).sum()))
samples.append({'frame':frame+1,'maxStretch':float(ratios[masks['all']].max()),'floorMin':floors[-1]})
# Mesh exporter: exact source topology/UV/corner normals, reflected Y-up.
transform=C@mesh.matrix_world;normal_matrix=transform.to_3x3().inverted().transposed()
mesh.data.calc_loop_triangles();uv_layer=mesh.data.uv_layers.active
vertex=[];normal=[];uv=[];weights=[];tri=[];lookup={};first={}
for triangle in mesh.data.loop_triangles:
ids=[]
for li in triangle.loops:
vi=mesh.data.loops[li].vertex_index;n=normal_matrix@mesh.data.corner_normals[li].vector;n.normalize()
tex=list(uv_layer.data[li].uv);key=(vi,*[round(v,6) for v in tex],*[round(v,6) for v in n])
if key not in lookup:
index=len(vertex);lookup[key]=index;first.setdefault(vi,index)
vertex.append(list(transform@mesh.data.vertices[vi].co));normal.append(list(n));uv.append(tex)
weights.append([[int(j),float(w[vi,j])] for j in np.argsort(-w[vi]) if w[vi,j]>1e-8])
ids.append(lookup[key])
tri.extend([ids[0],ids[2],ids[1]])
audit_edges=[[first[a],first[b],float(lengths[i]),1|(2 if masks['hands'][i] else 0)|(4 if masks['neck'][i] else 0)] for i,(a,b) in enumerate(edges) if masks['all'][i]]
result['variants'].append({'name':tag,'vertices':vertex,'normals':normal,'uv':uv,'triangles':tri,'weights':weights,
'auditEdges':audit_edges,'originalVertices':len(pts),'trianglesCount':len(tri)//3})
foot_ids=[i for i,n in enumerate(names) if any(x in n for x in ['Leg','Foot','Toe'])]
report[tag]={'maxStretch':maxs,'maxEdgesOver2x':over,'floorMinRange':[min(floors),max(floors)],'samples':samples,'worstEdge':worst,'maxP99':p99,
'handMaxLowerBodyInfluence':float(w[np.abs(pts[:,0])>.32][:,foot_ids].sum(1).max()),
'maxInfluences':int((w>1e-8).sum(1).max()),'minimumNonzeroWeight':float(w[w>1e-8].min())}
# Bake Walking for external review; native Unity uses the shared clip.
for b in arm.pose.bones:b.rotation_mode='QUATERNION'
for f,pose in enumerate(poses,1):
bpy.context.scene.frame_set(f)
for i,b in enumerate(arm.pose.bones):
b.matrix=arm.matrix_world.inverted()@C@pose[i]@C;bpy.context.view_layer.update()
for attr in ['location','rotation_quaternion','scale']:b.keyframe_insert(attr,frame=f)
bpy.context.scene.frame_start=1;bpy.context.scene.frame_end=len(poses);bpy.context.scene.render.fps=30
bpy.ops.export_scene.gltf(filepath=str(OUT/('Walking_'+tag+'.glb')),export_format='GLB',export_skins=True,export_animations=True,
export_animation_mode='ACTIVE_ACTIONS',export_force_sampling=True,export_def_bones=False)
print('VARIANT',tag,json.dumps({k:v for k,v in report[tag].items() if k!='samples'}))
write('walking_comparison.json',report)
(OUT/'unity_review_data.json').write_text(json.dumps(result,separators=(',',':')),encoding='utf-8')
print('PROCESSED',len(variants),'variants')
def topology():
from collections import deque
bpy.ops.wm.open_mainfile(filepath=str(OUT/'prepared.blend'))
mesh=next(o for o in bpy.context.scene.objects if o.type=='MESH');pts=mesh_points(mesh)
# Restrict the graph BELOW the crotch: otherwise two separate pant legs
# would legitimately connect through the pelvis.
allowed=pts[:,2]<.50;adj=[[] for _ in pts]
for e in mesh.data.edges:
a,b=e.vertices
if allowed[a] and allowed[b]:adj[a].append(b);adj[b].append(a)
left=int(np.argmin(np.linalg.norm(pts-np.array((.06,.01,.26)),axis=1)))
right=int(np.argmin(np.linalg.norm(pts-np.array((-.06,.01,.26)),axis=1)))
prev={left:None};queue=deque([left])
while queue and right not in prev:
a=queue.popleft()
for b in adj[a]:
if b not in prev:prev[b]=a;queue.append(b)
path=[];a=right if right in prev else None
while a is not None:path.append(a);a=prev[a]
path.reverse()
faces=[p.index for p in mesh.data.polygons if all(pts[i,2]<.5 for i in p.vertices)
and min(pts[i,0] for i in p.vertices)<=1e-6 and max(pts[i,0] for i in p.vertices)>=-1e-6]
write('topology_audit.json',{'source_sha256':hashlib.sha256(STATIC.read_bytes()).hexdigest(),
'vertices':len(pts),'triangles':sum(len(p.vertices)-2 for p in mesh.data.polygons),
'test':'Mesh-edge connectivity restricted to z<0.50m, below the 0.56m crotch',
'left_right_lower_legs_connected_below_crotch':right in prev,'path_vertices':path,'path_positions':pts[path].tolist(),
'faces_touching_or_crossing_center_below_0_5m':faces,
'source_uv_sha256':hashlib.sha256(np.array([tuple(v.uv) for v in mesh.data.uv_layers.active.data],dtype='<f4').tobytes()).hexdigest(),
'conclusion':'Lower pant legs are topologically joined; separate the inner-leg mesh before final weight painting. Original mesh was not changed.' if right in prev else 'No below-crotch connection detected.'})
print('TOPOLOGY',right in prev,'path',len(path),'central faces',len(faces))
def verify_exports():
from mathutils.kdtree import KDTree
bpy.ops.wm.open_mainfile(filepath=str(OUT/'prepared.blend'))
original=next(o for o in bpy.context.scene.objects if o.type=='MESH');rest=mesh_points(original)
edges=np.array([list(e.vertices) for e in original.data.edges]);length=np.linalg.norm(rest[edges[:,0]]-rest[edges[:,1]],axis=1)
valid=length>np.ptp(rest[:,2])*.002
expected=json.loads((OUT/'walking_comparison.json').read_text())
results=[]
for tag in ['raw','post','clean','clean_eps','local']:
bpy.ops.wm.read_factory_settings(use_empty=True);bpy.context.scene.render.fps=30
bpy.ops.import_scene.gltf(filepath=str(OUT/('Walking_'+tag+'.glb')))
arm=next(o for o in bpy.context.scene.objects if o.type=='ARMATURE')
mesh=next(o for o in bpy.context.scene.objects if o.type=='MESH');points=mesh_points(mesh)
tree=KDTree(len(points))
for i,p in enumerate(points):tree.insert(Vector(p),i)
tree.balance();mapping=[];dist=[]
for p in rest:
_,index,distance=tree.find(Vector(p));mapping.append(index);dist.append(distance)
assert max(dist)<1e-5,(tag,'rest mismatch',max(dist))
actual=0;first,last=map(int,arm.animation_data.action.frame_range)
for frame in range(first,last+1):
bpy.context.scene.frame_set(frame);bpy.context.view_layer.update()
obj=mesh.evaluated_get(bpy.context.evaluated_depsgraph_get());geometry=obj.to_mesh()
deformed=np.array([obj.matrix_world@v.co for v in geometry.vertices])[mapping];obj.to_mesh_clear()
ratios=np.linalg.norm(deformed[edges[:,0]]-deformed[edges[:,1]],axis=1)/np.maximum(length,1e-10)
actual=max(actual,float(ratios[valid].max()))
diff=abs(actual-expected[tag]['maxStretch']['all'])
assert diff<.003,(tag,actual,diff)
assert [first,last]==[1,32]
results.append({'variant':tag,'frames':[1,32],'maxStretch':actual,'difference':diff})
write('export_verification.json',{'passed':True,'results':results})
if __name__=='__main__':
args=sys.argv[sys.argv.index('--')+1:]
if args[0]=='inspect':inspect()
elif args[0]=='prepare':prepare()
elif args[0]=='process':process()
elif args[0]=='topology':topology()
elif args[0]=='verify_exports':verify_exports()
else:raise SystemExit('Unknown stage')
@@ -0,0 +1,122 @@
"""Inspect/export the user's Mixamo LmGirl without changing its skin weights."""
import json
import sys
from pathlib import Path
import math
import bpy
import numpy as np
from mathutils import Matrix, Vector, Quaternion
ROOT=Path(__file__).resolve().parents[2]
OUT=ROOT/'Tools/output/lmgirl_tpose_studio/rig_mixamo41/website'
SOURCE=ROOT/'AIClient/Assets/Bundles/Characters/Human/test/girl/LmGirlTpose.fbx'
def inspect():
bpy.ops.wm.read_factory_settings(use_empty=True);bpy.ops.import_scene.fbx(filepath=str(SOURCE))
arm=next(o for o in bpy.context.scene.objects if o.type=='ARMATURE');mesh=next(o for o in bpy.context.scene.objects if o.type=='MESH')
p=np.array([mesh.matrix_world@v.co for v in mesh.data.vertices])
normalize=Matrix.Rotation(math.pi/2,4,'X') if np.argmax(np.ptp(p,axis=0))==1 else Matrix.Identity(4)
p=np.array([normalize@mesh.matrix_world@v.co for v in mesh.data.vertices])
info={'source':str(SOURCE),'vertices':len(p),'triangles':sum(len(x.vertices)-2 for x in mesh.data.polygons),
'bounds':[p.min(0).tolist(),p.max(0).tolist()],
'bones':{b.name:{'parent':b.parent.name if b.parent else None,'matrix':[list(r) for r in normalize@arm.matrix_world@b.matrix_local]} for b in arm.data.bones},
'maxInfluences':max(len(v.groups) for v in mesh.data.vertices),'hasAnimation':bool(arm.animation_data and arm.animation_data.action)}
OUT.mkdir(parents=True,exist_ok=True);(OUT/'inspection.json').write_text(json.dumps(info,indent=2),encoding='utf-8')
print(json.dumps({k:v for k,v in info.items() if k!='bones'}));print('BONES',len(info['bones']),list(info['bones']))
def export():
from mathutils.kdtree import KDTree
inspect()
arm=next(o for o in bpy.context.scene.objects if o.type=='ARMATURE');mesh=next(o for o in bpy.context.scene.objects if o.type=='MESH')
arm.animation_data_clear()
points=np.array([mesh.matrix_world@v.co for v in mesh.data.vertices])
normalize=Matrix.Rotation(math.pi/2,4,'X') if np.argmax(np.ptp(points,axis=0))==1 else Matrix.Identity(4)
C=Matrix(((1,0,0,0),(0,0,1,0),(0,1,0,0),(0,0,0,1)))
transform=C@normalize@mesh.matrix_world
points=np.array([transform@v.co for v in mesh.data.vertices])
names=[b.name for b in arm.data.bones]
world={b.name:C@normalize@arm.matrix_world@b.matrix_local@C for b in arm.data.bones}
source=json.loads((ROOT/'Tools/output/campguide_studio/rig_mixamo41/unity_review/review_data.json').read_text())
source_index={b['name']:i for i,b in enumerate(source['bones'])}
source_world={b['name']:Matrix(b['bindpose']).inverted() for b in source['bones']}
assert set(names).issubset(source_index)
bones=[];offsets=[];delta_rotations=[];corrections=[]
def trs(m):
p,q,s=m.decompose();return list(p)+[q.x,q.y,q.z,q.w]+list(s)
def xyzw(q):return [q.x,q.y,q.z,q.w]
target_span=world['mixamorig:Hips'].translation.y-sum(world['mixamorig:'+s+'Foot'].translation.y for s in ['Left','Right'])/2
source_span=source_world['mixamorig:Hips'].translation.y-sum(source_world['mixamorig:'+s+'Foot'].translation.y for s in ['Left','Right'])/2
ratio=target_span/source_span
for b in arm.data.bones:
parent=b.parent.name if b.parent else None
local=world[parent].inverted()@world[b.name] if parent else world[b.name]
values=trs(local);src=source['bones'][source_index[b.name]]['rest']
delta=(world[parent].to_quaternion().inverted()@source_world[parent].to_quaternion()) if parent else Quaternion()
correction=source_world[b.name].to_quaternion().inverted()@world[b.name].to_quaternion()
bones.append({'name':b.name,'parent':names.index(parent) if parent else -1,'rest':values,'bindpose':[list(r) for r in world[b.name].inverted()]})
offsets.append(list(Vector(values[:3])-(delta@Vector(src[:3]))*ratio));delta_rotations.append(xyzw(delta));corrections.append(xyzw(correction))
weights=[]
for v in mesh.data.vertices:
row={mesh.vertex_groups[g.group].name:g.weight for g in v.groups if g.weight>1e-8}
assert all(n in names for n in row)
total=sum(row.values());weights.append([[names.index(n),float(w/total)] for n,w in sorted(row.items(),key=lambda x:-x[1])])
# Confirm identical geometry to the exact static/TokenRig input.
target=json.loads((OUT.parent/'unity_review_data.json').read_text())
reference=target['variants'][0]['vertices'];tree=KDTree(len(reference))
for i,p in enumerate(reference):tree.insert(Vector(p),i)
tree.balance();maximum=max(tree.find(Vector(p))[2] for p in points);assert maximum<1e-5,maximum
normal_matrix=transform.to_3x3().inverted().transposed();mesh.data.calc_loop_triangles();uv_layer=mesh.data.uv_layers.active
vertices=[];normals=[];uv=[];influences=[];triangles=[];lookup={};first={}
for triangle in mesh.data.loop_triangles:
ids=[]
for li in triangle.loops:
vi=mesh.data.loops[li].vertex_index;n=normal_matrix@mesh.data.corner_normals[li].vector;n.normalize();tex=list(uv_layer.data[li].uv)
key=(vi,*[round(x,6) for x in tex],*[round(x,6) for x in n])
if key not in lookup:
index=len(vertices);lookup[key]=index;first.setdefault(vi,index)
vertices.append(points[vi].tolist());normals.append(list(n));uv.append(tex);influences.append(weights[vi])
ids.append(lookup[key])
triangles.extend([ids[0],ids[2],ids[1]])
height=float(np.ptp(points[:,1]));edges=np.array([list(e.vertices) for e in mesh.data.edges])
lengths=np.linalg.norm(points[edges[:,0]]-points[edges[:,1]],axis=1);valid=lengths>height*.002
hands=np.abs(points[:,0])>.32;neck=(points[:,1]>.91)&(points[:,1]<.99)&(np.abs(points[:,0])<.1)
masks={'all':valid,'hands':valid&hands[edges[:,0]]&hands[edges[:,1]],'neck':valid&neck[edges[:,0]]&neck[edges[:,1]]}
audit_edges=[[first[a],first[b],float(lengths[i]),1|(2 if masks['hands'][i] else 0)|(4 if masks['neck'][i] else 0)] for i,(a,b) in enumerate(edges) if valid[i]]
variant={'name':'mixamo','vertices':vertices,'normals':normals,'uv':uv,'triangles':triangles,'weights':influences,
'auditEdges':audit_edges,'originalVertices':len(points),'trianglesCount':len(triangles)//3}
data={'bones':bones,'variants':[variant],'height':height,'translationScale':ratio,'translationOffsets':offsets,
'translationDeltaRotations':delta_rotations,'bindRotationCorrections':corrections,'rotationParents':[b['parent'] for b in bones]}
(OUT/'unity_review_data.json').write_text(json.dumps(data,separators=(',',':')),encoding='utf-8')
# Independent matrix-based LBS reference for native Unity verification.
dense=np.zeros((len(points),len(names)))
for i,row in enumerate(weights):
for j,w in row:dense[i,j]=w
p4=np.column_stack((points,np.ones(len(points))));maxs={k:0 for k in masks};over={k:0 for k in masks};samples=[]
for frame,sample in enumerate(source['clips'][0]['samples']):
source_pose=[]
for i,values in enumerate(sample):
local=Matrix.LocRotScale(Vector(values[:3]),Quaternion((values[6],*values[3:6])),Vector(values[7:10]))
parent=source['bones'][i]['parent'];source_pose.append(source_pose[parent]@local if parent>=0 else local)
target_pose=[];mapped_q=[]
for i,b in enumerate(bones):
n=b['name'];si=source_index[n];values=sample[si];parent=b['parent']
rot=source_pose[si].to_quaternion()@Quaternion((corrections[i][3],*corrections[i][:3]));mapped_q.append(rot)
local_q=mapped_q[parent].inverted()@rot if parent>=0 else rot
delta=Quaternion((delta_rotations[i][3],*delta_rotations[i][:3]))
local_p=delta@(Vector(values[:3])*ratio)+Vector(offsets[i])
local=Matrix.LocRotScale(local_p,local_q,Vector(values[7:10]));target_pose.append(target_pose[parent]@local if parent>=0 else local)
deformed=np.zeros_like(points)
for i,n in enumerate(names):deformed+=dense[:,i,None]*(p4@np.array(target_pose[i]@world[n].inverted()).T)[:,:3]
stretch=np.linalg.norm(deformed[edges[:,0]]-deformed[edges[:,1]],axis=1)/np.maximum(lengths,1e-10)
for k,mask in masks.items():maxs[k]=max(maxs[k],float(stretch[mask].max()));over[k]=max(over[k],int((stretch[mask]>2).sum()))
samples.append({'frame':frame+1,'maxStretch':float(stretch[valid].max())})
report={'source':str(SOURCE),'bones':len(names),'triangles':4999,'maxInfluences':max(len(w) for w in weights),
'geometryNearestError':maximum,'translationScale':ratio,'maxStretch':maxs,'maxEdgesOver2x':over,'samples':samples,
'weights':'Original website weights, normalized only; no top4 truncation or cleanup',
'motion':'Same shared Walking; source bind-relative world rotations mapped to website rest axes; target bind translations preserved',
'missingSourceBones':sorted(set(source_index)-set(names))}
(OUT/'walking_comparison.json').write_text(json.dumps(report,indent=2),encoding='utf-8')
print('WEBSITE_EXPORTED',json.dumps({k:v for k,v in report.items() if k!='samples'}))
if __name__=='__main__':
export() if '--export' in sys.argv else inspect()
+2 -2
View File
@@ -26,12 +26,12 @@ low.data.materials.clear()
prepare_game_export(low,restore_axis=True)
assert uv_hash()==before,'Coordinate conversion changed Studio UVs'
bpy.ops.object.select_all(action='DESELECT');low.select_set(True);bpy.context.view_layer.objects.active=low
export_fbx_source_yup(str(out/(name+'.fbx')))
axes=export_fbx_source_yup(str(out/(name+'.fbx')))
mapping={'diffuse':'color','normal':'normal','ao':'ao','metallic':'metallic','roughness':'roughness'}
for src,dest in mapping.items():
candidates=list((folder/'bake_maps').glob(src+'*.png'))
assert len(candidates)==1,(src,candidates)
shutil.copy2(candidates[0],out/(name+'_'+dest+'.png'))
report=dict(uvHashBefore=before,uvHashAfter=uv_hash(),uvPreserved=True,vertices=len(low.data.vertices),triangles=sum(len(p.vertices)-2 for p in low.data.polygons))
report=dict(uvHashBefore=before,uvHashAfter=uv_hash(),uvPreserved=True,vertices=len(low.data.vertices),triangles=sum(len(p.vertices)-2 for p in low.data.polygons),fbxAxes=axes)
(folder/'static_export.json').write_text(json.dumps(report,indent=2),encoding='utf-8')
print('MT_SUMMARY '+json.dumps(report))
+18
View File
@@ -0,0 +1,18 @@
import bpy
import sys
from pathlib import Path
folder=Path(sys.argv[sys.argv.index('--')+1])
for p in [folder/'CampGuideStudio.fbx', folder/'CampGuideStudio@Walking.fbx']:
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=str(p))
arm=next(o for o in bpy.context.scene.objects if o.type=='ARMATURE')
print('FILE',p.name,'arm pose',arm.data.pose_position)
for n in ['Hips','Head','LeftArm','LeftHand','LeftFoot']:
b=arm.pose.bones['mixamorig:'+n]
print(n,'rest',tuple(arm.matrix_world@b.bone.head_local),'POSE',tuple(arm.matrix_world@b.head),'basis',list(b.matrix_basis.to_quaternion()),'loc',list(b.location))
for o in bpy.context.scene.objects:
if o.type=='MESH':
data=o.evaluated_get(bpy.context.evaluated_depsgraph_get()).to_mesh()
pts=[o.matrix_world@v.co for v in data.vertices]
print('EVALBOUND',[[min(v[i] for v in pts) for i in range(3)],[max(v[i] for v in pts) for i in range(3)]])
@@ -0,0 +1,41 @@
"""Render identical poses/cameras for SOMA baseline and hand-only trial."""
import sys
from pathlib import Path
import bpy
from mathutils import Vector
folder = Path(sys.argv[sys.argv.index('--')+1]).resolve()
for label, path in [('baseline', folder/'kimodo/Walk/baked.glb'), ('hand_cleanup', folder/'audit/hand_cleanup/Walk.glb')]:
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=str(path))
scene = bpy.context.scene
# glTF seconds are imported at Blender's current 24 fps; frame 24 = 1 second.
scene.frame_set(24)
scene.render.engine = 'CYCLES'
scene.cycles.samples = 12
scene.render.resolution_x, scene.render.resolution_y = 512, 640
scene.render.resolution_percentage = 100
scene.view_settings.view_transform = 'AgX'
world = bpy.data.worlds.new('AuditWorld')
world.use_nodes = True
world.node_tree.nodes['Background'].inputs[0].default_value = (.35, .38, .42, 1)
world.node_tree.nodes['Background'].inputs[1].default_value = .7
scene.world = world
center = Vector((0, 0, .56))
cam = bpy.data.objects.new('AuditCamera', bpy.data.cameras.new('AuditCamera'))
scene.collection.objects.link(cam)
scene.camera = cam
cam.data.type = 'ORTHO'
cam.data.ortho_scale = 1.43
for direction, power in [((2, 3, 4), 180), ((-3, -1, 2), 120)]:
light = bpy.data.objects.new('AuditLight', bpy.data.lights.new('AuditLight', 'AREA'))
scene.collection.objects.link(light)
light.location = center + Vector(direction)
light.rotation_euler = (center-light.location).to_track_quat('-Z', 'Y').to_euler()
light.data.energy, light.data.size = power, 3
for view, direction in [('side', (3, 0, .1)), ('back', (0, -3, .1))]:
cam.location = center + Vector(direction)
cam.rotation_euler = (center-cam.location).to_track_quat('-Z', 'Y').to_euler()
scene.render.filepath = str(folder/'audit'/f'{label}_{view}.png')
bpy.ops.render.render(write_still=True)
@@ -0,0 +1,42 @@
"""Repair Studio face winding on welded topology without generating UVs."""
import json
import sys
from pathlib import Path
import bpy
import bmesh
source, destination, kind = sys.argv[sys.argv.index('--') + 1:][:3]
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=source, merge_vertices=False)
obj = next(o for o in bpy.context.scene.objects if o.type == 'MESH')
bpy.context.view_layer.objects.active = obj
bpy.ops.object.select_all(action='DESELECT'); obj.select_set(True)
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
uv = obj.data.uv_layers.active
uv_before = sorted(tuple(round(c, 6) for c in v.uv) for v in uv.data)
bm = bmesh.new(); bm.from_mesh(obj.data)
bmesh.ops.remove_doubles(bm, verts=list(bm.verts), dist=max(obj.dimensions) * 1e-7)
bmesh.ops.recalc_face_normals(bm, faces=list(bm.faces))
flipped = 0
if kind == 'ground':
# Thin terrain is intentionally authored with a top and underside. Some
# Studio patches are disconnected, so orient each flat patch explicitly.
low = min(v.co.z for v in bm.verts); high = max(v.co.z for v in bm.verts)
midpoint = (low + high) / 2
for face in bm.faces:
face.normal_update()
if abs(face.normal.z) > .35:
# Terrain is viewed and collided from above. Its paving can be
# lower than raised grass, so a global height midpoint cannot
# distinguish the paving top from an underside patch.
if face.normal.z < 0:
face.normal_flip(); flipped += 1
bm.to_mesh(obj.data); bm.free(); obj.data.update()
uv_after = sorted(tuple(round(c, 6) for c in v.uv) for v in obj.data.uv_layers.active.data)
assert uv_before == uv_after, 'UV coordinates changed during winding repair'
bpy.ops.export_scene.gltf(filepath=destination, export_format='GLB', use_selection=True,
export_materials='EXPORT', export_image_format='AUTO')
report = dict(kind=kind, uvPreserved=True, flatTerrainFacesFlipped=flipped,
triangles=sum(len(p.vertices)-2 for p in obj.data.polygons))
Path(destination).with_suffix('.json').write_text(json.dumps(report, indent=2), encoding='utf-8')
print('MT_SUMMARY '+json.dumps(report), flush=True)
@@ -0,0 +1,43 @@
"""CampGuide-only hand influence isolation experiment, preserving the fitted rig.
This intentionally does not fix joint placement, facing, feet, or neck weights.
Usage: blender -b --python <script> -- <skinned.glb> <out.glb>
"""
import json
import sys
from pathlib import Path
import bpy
src, dst = map(Path, sys.argv[sys.argv.index('--')+1:])
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=str(src))
mesh = max((o for o in bpy.context.scene.objects if o.type == 'MESH'), key=lambda o: len(o.data.vertices))
arm = next(o for o in bpy.context.scene.objects if o.type == 'ARMATURE')
height = max((mesh.matrix_world @ v.co).z for v in mesh.data.vertices)
changed = []
for v in mesh.data.vertices:
p = mesh.matrix_world @ v.co
# Restrict to the hands in this particular T-pose mesh.
if not (abs(p.x) > height*.255 and height*.55 < p.z < height*.67):
continue
side = 'Left' if p.x * (arm.matrix_world @ arm.data.bones['LeftHand'].head_local).x > 0 else 'Right'
allowed = {side+'Arm', side+'ForeArm', side+'Hand'}
old = {mesh.vertex_groups[g.group].name: g.weight for g in v.groups}
new = {name: value for name, value in old.items() if name in allowed}
total = sum(new.values())
if total < 1e-8:
new, total = {side+'Hand': 1.0}, 1.0
rejected = sum(value for name, value in old.items() if name not in allowed)
for g in list(v.groups):
mesh.vertex_groups[g.group].remove([v.index])
for name, value in new.items():
mesh.vertex_groups[name].add([v.index], value/total, 'REPLACE')
if rejected > .001:
changed.append({'vertex': v.index, 'rejected': rejected, 'old': old})
dst.parent.mkdir(parents=True, exist_ok=True)
bpy.ops.export_scene.gltf(filepath=str(dst), export_format='GLB', export_yup=True, export_skins=True, export_animations=False)
dst.with_suffix('.json').write_text(json.dumps({'purpose': 'hand-only nonlocal influence isolation; not production-ready',
'source': str(src), 'changed_over_0_001': len(changed), 'changes': changed}, indent=2), encoding='utf-8')
print('HAND_CLEANUP', len(changed))
@@ -0,0 +1,138 @@
"""Prepare a weight-blind fixed Mixamo-41 TokenRig input from the static FBX.
No website skin weights are retained. Non-anatomical placeholder weights keep
all 41 joints from being trimmed by TokenRig. TokenRig predicts new weights.
"""
import itertools
import json
import math
import sys
from pathlib import Path
import bpy
import numpy as np
from mathutils import Matrix, Vector
from mathutils.kdtree import KDTree
def world_points(mesh):
return [mesh.matrix_world @ v.co for v in mesh.data.vertices]
def prepare(static, reference, out):
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=str(reference))
arm = next(o for o in bpy.context.scene.objects if o.type == 'ARMATURE')
assert len(arm.data.bones) == 41
refmesh = next(o for o in bpy.context.scene.objects if o.type == 'MESH')
refpoints = world_points(refmesh)
refarray = np.array(refpoints)
tree = KDTree(len(refpoints))
for i,p in enumerate(refpoints):
tree.insert(p,i)
tree.balance()
for obj in list(bpy.data.objects):
if obj != arm:
bpy.data.objects.remove(obj,do_unlink=True)
arm.animation_data_clear()
bpy.ops.import_scene.fbx(filepath=str(static))
mesh = next(o for o in bpy.context.scene.objects if o.type == 'MESH')
assert len(mesh.data.vertices) == len(refpoints)
rawpoints = world_points(mesh)
center = (refarray.min(0)+refarray.max(0))*.5
candidates=[]
for perm in itertools.permutations(range(3)):
for signs in itertools.product((-1,1),repeat=3):
m = np.zeros((3,3))
for i,j in enumerate(perm):
m[i,j] = signs[i]
if np.linalg.det(m) < .5:
continue
points = np.array(rawpoints) @ m.T
if np.max(np.abs(np.ptp(points,axis=0)-np.ptp(refarray,axis=0))) > 1e-4:
continue
shift = center-(points.min(0)+points.max(0))*.5
distances=[tree.find(Vector(p+shift))[2] for p in points]
candidates.append((max(distances),float(np.mean(distances)),m,shift))
worst,mean,rotation,shift = min(candidates,key=lambda x:x[1])
assert worst < 1e-5, f'static/reference geometry mismatch {worst}'
alignment = Matrix(rotation.tolist()).to_4x4()
alignment.translation = Vector(shift)
mesh.matrix_world = alignment @ mesh.matrix_world
for modifier in list(mesh.modifiers):
if modifier.type == 'ARMATURE':
mesh.modifiers.remove(modifier)
mesh.vertex_groups.clear()
mesh_world = mesh.matrix_world.copy()
mesh.parent = arm
mesh.matrix_world = mesh_world
for bone in arm.data.bones:
mesh.vertex_groups.new(name=bone.name)
mesh.vertex_groups['mixamorig:Hips'].add(list(range(len(mesh.data.vertices))),1.0,'REPLACE')
# TokenRig's Asset.trim_skeleton() removes unweighted joints. Give each bone
# one arbitrary vertex, without supplying any anatomical weighting prior.
for i,bone in enumerate(arm.data.bones):
if bone.name != 'mixamorig:Hips':
mesh.vertex_groups['mixamorig:Hips'].remove([i])
mesh.vertex_groups[bone.name].add([i],1.0,'REPLACE')
modifier = mesh.modifiers.new('TokenRigInputSkeleton','ARMATURE')
modifier.object = arm
arm.matrix_world = Matrix.Rotation(math.pi/2,4,'X') @ arm.matrix_world
bpy.context.view_layer.update()
out.mkdir(parents=True,exist_ok=True)
manifest = {'static_mesh_source': str(static),'skeleton_source':str(reference),
'bones':{b.name:{'parent':b.parent.name if b.parent else None,
'world_matrix':[list(row) for row in arm.matrix_world @ b.matrix_local],
'head':list(arm.matrix_world @ b.head_local)} for b in arm.data.bones},
'vertices':len(mesh.data.vertices),'triangles':sum(len(p.vertices)-2 for p in mesh.data.polygons),
'alignment_max_nearest_vertex_distance_m':worst,
'input_skin':'Non-anatomical keepalive placeholders: one arbitrary vertex per bone, remaining vertices Hips=1; all website weights removed',
'blender_up':'Z','blender_front':'-Y', 'alignment':[list(row) for row in alignment]}
(out/'input_manifest.json').write_text(json.dumps(manifest,indent=2),encoding='utf-8')
bpy.ops.export_scene.gltf(filepath=str(out/'source.glb'),export_format='GLB',export_yup=True,
export_skins=True,export_animations=False,export_def_bones=False)
print('PREPARED',len(arm.data.bones),len(mesh.data.vertices),manifest['triangles'],'alignment',worst)
return arm,mesh
def restore(static,reference,out):
arm,mesh=prepare(static,reference,out)
decoded=json.loads((out/'decoded_weights.json').read_text(encoding='utf-8'))
tree=KDTree(len(decoded))
for i,item in enumerate(decoded):
x,y,z=item['position_y_up']
tree.insert(Vector((x,-z,y)),i)
tree.balance()
mesh.vertex_groups.clear()
for bone in arm.data.bones:
mesh.vertex_groups.new(name=bone.name)
distances=[]
for v in mesh.data.vertices:
_,index,distance=tree.find(mesh.matrix_world @ v.co)
distances.append(distance)
weights=decoded[index]['weights']
total=sum(weights.values())
assert total>.9999
for name,weight in weights.items():
mesh.vertex_groups[name].add([v.index],weight/total,'REPLACE')
print('RESTORE_DISTANCE',max(distances),'TARGET_BOUNDS',np.array(world_points(mesh)).min(0),np.array(world_points(mesh)).max(0),
'DECODED_BOUNDS',np.array([item['position_y_up'] for item in decoded]).min(0),np.array([item['position_y_up'] for item in decoded]).max(0))
assert max(distances)<1e-5
(out/'restore_manifest.json').write_text(json.dumps({'vertex_nearest_max_distance_m':max(distances),
'bones':len(arm.data.bones),'vertices':len(mesh.data.vertices),
'skeleton':'Original website FBX skeleton, exact original rest matrices after common world-axis normalization',
'weights':'Only TokenRig predicted weights; no filtering, locality correction or website weight transfer',
'blender_up':'Z','blender_front':'-Y'},indent=2),encoding='utf-8')
bpy.ops.wm.save_as_mainfile(filepath=str(out/'tokenrig_mixamo41.blend'),compress=True)
bpy.ops.export_scene.gltf(filepath=str(out/'tokenrig_mixamo41.glb'),export_format='GLB',export_yup=True,
export_skins=True,export_animations=False,export_def_bones=False)
print('RESTORED',len(arm.data.bones),max(distances))
args=sys.argv[sys.argv.index('--')+1:]
if args[0]=='prepare':
prepare(*map(Path,args[1:]))
elif args[0]=='restore':
restore(*map(Path,args[1:]))
else:
raise SystemExit('use prepare|restore <static.fbx> <Mixamo.fbx> <out_dir>')
@@ -0,0 +1,23 @@
"""Reimport the six review GLBs and compare their deformation to source audits."""
import json
import sys
from pathlib import Path
sys.path.insert(0,str(Path(__file__).resolve().parent))
from bl_compare_mixamo_soma import audit
folder=Path(sys.argv[sys.argv.index('--')+1])
source=json.loads((folder/'comparison.json').read_text())+json.loads((folder/'comparison_local.json').read_text())
results=[]
for variant,prefix in [('raw','TokenRig41'),('local','TokenRigLocal')]:
for label in ['Walking','Breathing Idle','Happy Idle']:
path=folder/'animations'/variant/(label.replace(' ','_')+'.glb')
item=audit('Export '+variant+' '+label,path,None,folder/'review')
expected=next(x for x in source if x['label']==prefix+' '+label)
actual=item['animation']
assert actual['range']==expected['animation']['range'],(path,actual['range'])
errors={n:abs(actual['summary'][n]['max_edge_stretch']-expected['animation']['summary'][n]['max_edge_stretch']) for n in actual['summary']}
assert max(errors.values())<.001,(path,errors)
results.append({'file':str(path),'frames':actual['range'],'max_stretch_difference_from_source':errors,'bones':len(item['bones'])})
(folder/'export_verification.json').write_text(json.dumps(results,indent=2),encoding='utf-8')
print('EXPORTS_VERIFIED',len(results))
@@ -0,0 +1,83 @@
"""Blender regression: real characters, legacy input and repeated FBX conversion."""
import sys,json,math,hashlib
from pathlib import Path
import bpy
import numpy as np
from mathutils import Matrix
from io_scene_fbx import parse_fbx
ROOT=Path(__file__).resolve().parents[3]
sys.path.insert(0,str(ROOT/'Tools/model_tools'))
from bl_decimate import prepare_game_export,export_fbx_source_yup,validate_fbx_yup_scene
OUT=ROOT/'Tools/output/lmgirl_tpose_studio/axis_pipeline_audit/regression'
OUT.mkdir(parents=True,exist_ok=True)
def points(obj):return np.array([obj.matrix_world@v.co for v in obj.data.vertices])
def uv(obj):return np.array([tuple(v.uv) for v in obj.data.uv_layers.active.data])
def snapshot(obj):return dict(name=obj.name,vertices=np.array([tuple(v.co) for v in obj.data.vertices]),uv=uv(obj),matrix=np.array(obj.matrix_world),objects=len(bpy.data.objects),meshes=len(bpy.data.meshes))
def unchanged(obj,before):
assert obj.name==before['name']
assert np.array_equal(np.array([tuple(v.co) for v in obj.data.vertices]),before['vertices'])
assert np.array_equal(uv(obj),before['uv'])
assert np.array_equal(np.array(obj.matrix_world),before['matrix'])
assert len(bpy.data.objects)==before['objects'] and len(bpy.data.meshes)==before['meshes']
assert bpy.context.view_layer.objects.active==obj and set(bpy.context.selected_objects)=={obj}
def run(label,path,restore):
bpy.ops.wm.read_factory_settings(use_empty=True)
if path.suffix=='.fbx':bpy.ops.import_scene.fbx(filepath=str(path),use_anim=False)
else:bpy.ops.import_scene.gltf(filepath=str(path),merge_vertices=False)
obj=next(o for o in bpy.context.scene.objects if o.type=='MESH')
prepare_game_export(obj,restore_axis=restore)
before=snapshot(obj)
conversion=Matrix.Rotation(math.pi/2,4,'X')
expected=np.array([conversion@obj.matrix_world@v.co for v in obj.data.vertices])
target=OUT/(label+'.fbx')
export_fbx_source_yup(str(target));unchanged(obj,before)
tree,_=parse_fbx.parse(str(target))
settings=next(e for e in tree.elems if e.id==b'GlobalSettings')
props=next(e for e in settings.elems if e.id==b'Properties70')
globals={p.props[0].decode():list(p.props[4:]) for p in props.elems}
assert globals['UpAxis']==[1] and globals['UpAxisSign']==[1]
objects=next(e for e in tree.elems if e.id==b'Objects')
for model in [e for e in objects.elems if e.id==b'Model']:
props=next(e for e in model.elems if e.id==b'Properties70')
values={p.props[0].decode():list(p.props[4:]) for p in props.elems}
assert max(abs(v) for v in values.get('Lcl Rotation',[0,0,0]))<1e-5
assert all(abs(v-1)<1e-5 for v in values.get('Lcl Scaling',[1,1,1]))
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.fbx(filepath=str(target),use_anim=False)
obj=next(o for o in bpy.context.scene.objects if o.type=='MESH')
actual=points(obj);error=float(np.max(np.abs(actual-expected)))
assert actual.shape==expected.shape and error<2e-6,(label,error)
assert np.array_equal(uv(obj),before['uv']),(label,'UV changed')
assert np.argmax(np.ptp(actual,axis=0))==2,(label,'horizontal world geometry')
assert abs(actual[:,2].min())<2e-6
assert abs((actual[:,0].min()+actual[:,0].max())/2)<2e-6
assert abs((actual[:,1].min()+actual[:,1].max())/2)<2e-6
result=dict(case=label,path=str(target.relative_to(ROOT)),vertices=len(actual),max_position_error_m=error,
uv_unchanged=True,source_unchanged=True,upright=True,origin_bottom_center=True,zero_fbx_node_rotation=True)
return target,result
results=[]
girl=ROOT/'Tools/output/lmgirl_tpose_studio/model_ex.glb'
boy=ROOT/'Tools/output/campguide_studio/model_ex.glb'
_,r=run('boy_from_glb',boy,True);results.append(r)
path,r=run('girl_from_glb',girl,True);results.append(r)
for i in range(1,3):path,r=run('girl_repeat_'+str(i),path,None);results.append(r)
_,r=run('girl_from_legacy',ROOT/'Tools/output/lmgirl_tpose_studio/static/LmGirlTpose.fbx',None);results.append(r)
# Export failure must also restore selection, names, geometry and temporary data.
obj=next(o for o in bpy.context.scene.objects if o.type=='MESH')
prepare_game_export(obj);before=snapshot(obj)
try:export_fbx_source_yup(str(OUT/'missing_directory'/'fail.fbx'))
except (RuntimeError,OSError):pass
else:raise AssertionError('Expected export failure')
unchanged(obj,before)
results.append(dict(case='failure_cleanup',passed=True))
try:validate_fbx_yup_scene(str(ROOT/'Tools/output/lmgirl_tpose_studio/static/LmGirlTpose.fbx'))
except ValueError:pass
else:raise AssertionError('Legacy rotated FBX passed the export guard')
results.append(dict(case='reject_legacy_rotation',passed=True))
(OUT/'results.json').write_text(json.dumps(results,indent=2),encoding='utf-8')
print('FBX_AXIS_REGRESSION '+json.dumps(results))
+23
View File
@@ -0,0 +1,23 @@
[CmdletBinding()]
param([Parameter(Mandatory=$true)][string]$Executable)
$ErrorActionPreference = 'Stop'
$repo = Split-Path -Parent $PSScriptRoot
$Executable = (Resolve-Path -LiteralPath $Executable).Path
$stamp = Get-Date -Format yyyyMMdd-HHmmss
$release = Join-Path $repo "Tools/output/server_release/AIServer-Windows-x64-$stamp"
New-Item -ItemType Directory -Path $release -Force | Out-Null
Copy-Item -LiteralPath $Executable -Destination (Join-Path $release 'aiserver.exe')
Get-ChildItem -LiteralPath (Join-Path $repo 'AIServer/deploy/windows') -File | ForEach-Object {
Copy-Item -LiteralPath $_.FullName -Destination $release
}
$manifest = @{
platform='windows-x64'; runtime='static MSVC /MT'; generatedUtc=[DateTime]::UtcNow.ToString('o')
executableSha256=(Get-FileHash -LiteralPath $Executable -Algorithm SHA256).Hash
gameplayConfigSha256=(Get-FileHash -LiteralPath (Join-Path $repo 'Config/Gameplay/greenfield.json') -Algorithm SHA256).Hash
skillConfigSha256=(Get-FileHash -LiteralPath (Join-Path $repo 'Config/Combat/skill_runtime_v1.json') -Algorithm SHA256).Hash
containsDatabase=$false; defaultPort=15888; bindAddress='127.0.0.1'
}
[IO.File]::WriteAllText((Join-Path $release 'release.json'),($manifest | ConvertTo-Json), (New-Object Text.UTF8Encoding($false)))
$zip = "$release.zip"
Compress-Archive -LiteralPath (Get-ChildItem -LiteralPath $release -File | Select-Object -ExpandProperty FullName) -DestinationPath $zip
Write-Output $zip
Binary file not shown.
+75
View File
@@ -0,0 +1,75 @@
{
"bones": 65,
"names": [
"mixamorig:Hips",
"mixamorig:Spine",
"mixamorig:Spine1",
"mixamorig:Spine2",
"mixamorig:Neck",
"mixamorig:Head",
"mixamorig:HeadTop_End",
"mixamorig:LeftShoulder",
"mixamorig:LeftArm",
"mixamorig:LeftForeArm",
"mixamorig:LeftHand",
"mixamorig:LeftHandThumb1",
"mixamorig:LeftHandThumb2",
"mixamorig:LeftHandThumb3",
"mixamorig:LeftHandThumb4",
"mixamorig:LeftHandIndex1",
"mixamorig:LeftHandIndex2",
"mixamorig:LeftHandIndex3",
"mixamorig:LeftHandIndex4",
"mixamorig:LeftHandMiddle1",
"mixamorig:LeftHandMiddle2",
"mixamorig:LeftHandMiddle3",
"mixamorig:LeftHandMiddle4",
"mixamorig:LeftHandRing1",
"mixamorig:LeftHandRing2",
"mixamorig:LeftHandRing3",
"mixamorig:LeftHandRing4",
"mixamorig:LeftHandPinky1",
"mixamorig:LeftHandPinky2",
"mixamorig:LeftHandPinky3",
"mixamorig:LeftHandPinky4",
"mixamorig:RightShoulder",
"mixamorig:RightArm",
"mixamorig:RightForeArm",
"mixamorig:RightHand",
"mixamorig:RightHandThumb1",
"mixamorig:RightHandThumb2",
"mixamorig:RightHandThumb3",
"mixamorig:RightHandThumb4",
"mixamorig:RightHandIndex1",
"mixamorig:RightHandIndex2",
"mixamorig:RightHandIndex3",
"mixamorig:RightHandIndex4",
"mixamorig:RightHandMiddle1",
"mixamorig:RightHandMiddle2",
"mixamorig:RightHandMiddle3",
"mixamorig:RightHandMiddle4",
"mixamorig:RightHandRing1",
"mixamorig:RightHandRing2",
"mixamorig:RightHandRing3",
"mixamorig:RightHandRing4",
"mixamorig:RightHandPinky1",
"mixamorig:RightHandPinky2",
"mixamorig:RightHandPinky3",
"mixamorig:RightHandPinky4",
"mixamorig:LeftUpLeg",
"mixamorig:LeftLeg",
"mixamorig:LeftFoot",
"mixamorig:LeftToeBase",
"mixamorig:LeftToe_End",
"mixamorig:RightUpLeg",
"mixamorig:RightLeg",
"mixamorig:RightFoot",
"mixamorig:RightToeBase",
"mixamorig:RightToe_End"
],
"height": 1.7544,
"up": "Y",
"front": "-Z",
"left": "-X",
"source": "D:\\UD\\AI\\AIGame\\Res\\Human\\Anim\\Male\\Running.fbx"
}
Binary file not shown.
+88
View File
@@ -0,0 +1,88 @@
{
"bones": 78,
"names": [
"Root",
"Hips",
"Spine1",
"Spine2",
"Chest",
"Neck1",
"Neck2",
"Head",
"HeadEnd",
"Jaw",
"LeftEye",
"RightEye",
"LeftShoulder",
"LeftArm",
"LeftForeArm",
"LeftHand",
"LeftHandThumb1",
"LeftHandThumb2",
"LeftHandThumb3",
"LeftHandThumbEnd",
"LeftHandIndex1",
"LeftHandIndex2",
"LeftHandIndex3",
"LeftHandIndex4",
"LeftHandIndexEnd",
"LeftHandMiddle1",
"LeftHandMiddle2",
"LeftHandMiddle3",
"LeftHandMiddle4",
"LeftHandMiddleEnd",
"LeftHandRing1",
"LeftHandRing2",
"LeftHandRing3",
"LeftHandRing4",
"LeftHandRingEnd",
"LeftHandPinky1",
"LeftHandPinky2",
"LeftHandPinky3",
"LeftHandPinky4",
"LeftHandPinkyEnd",
"RightShoulder",
"RightArm",
"RightForeArm",
"RightHand",
"RightHandThumb1",
"RightHandThumb2",
"RightHandThumb3",
"RightHandThumbEnd",
"RightHandIndex1",
"RightHandIndex2",
"RightHandIndex3",
"RightHandIndex4",
"RightHandIndexEnd",
"RightHandMiddle1",
"RightHandMiddle2",
"RightHandMiddle3",
"RightHandMiddle4",
"RightHandMiddleEnd",
"RightHandRing1",
"RightHandRing2",
"RightHandRing3",
"RightHandRing4",
"RightHandRingEnd",
"RightHandPinky1",
"RightHandPinky2",
"RightHandPinky3",
"RightHandPinky4",
"RightHandPinkyEnd",
"LeftLeg",
"LeftShin",
"LeftFoot",
"LeftToeBase",
"LeftToeEnd",
"RightLeg",
"RightShin",
"RightFoot",
"RightToeBase",
"RightToeEnd"
],
"height": 1.7641,
"up": "Z-in-blender",
"front": "+Y-in-blender",
"left": "-X",
"source": "D:\\UD\\AI\\AIGame\\Tools\\output\\campguide_studio\\rig_fixed\\kimodo_regen\\Walk\\kimodo_motion.bvh"
}
+13
View File
@@ -0,0 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
JOB=/home/spark/SkinTokens/tmp_jobs/lmgirl_mixamo41_20261023_a1
docker exec -w /workspace/SkinTokens tokenrig bash -lc '
set -e
export PATH=/opt/py310/bin:$PATH
export PYTHONPATH=/workspace/SkinTokens:/workspace/blender_dev/build_bpy/bin
export BLENDER_SYSTEM_SCRIPTS=/workspace/blender_dev/blender/scripts
export BLENDER_SYSTEM_DATAFILES=/workspace/blender_dev/blender/release/datafiles
export PYTHONUNBUFFERED=1
python3 /workspace/SkinTokens/tmp_jobs/lmgirl_mixamo41_20261023_a1/tokenrig_lmgirl_trial_remote.py
'
echo LMGIRL_PAIR_DONE
+108
View File
@@ -0,0 +1,108 @@
"""Static T2 accessory: Studio topology + Studio UV, bake, UV-preserving finish.
Run in LLM3DGraphics's venv. This script only prepares external files; Unity
imports and prefab authoring are performed separately through Pipeline.
"""
import sys
sys.dont_write_bytecode = True
import argparse
import hashlib
import json
import shutil
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
WORKBENCH = Path('D:/UD/AI/XModel/LLM3DGraphics')
BLENDER = 'D:/Tools/Blender/blender-5.0.0-windows-x64/blender.exe'
sys.path.insert(0, str(ROOT / 'Tools'))
from hunyuan_3d import auth
sys.path.insert(0, str(WORKBENCH))
from backend import hunyuan_studio as studio
from backend.retopology import _run_blender_step, compress_bake_maps, limit_studio_faces
sys.path.insert(0, str(ROOT / 'Tools/model_tools'))
from mt_run import run_blender_script
from png_compress import compress_dir_pngs
def sha(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def update(**values):
print(json.dumps(values, ensure_ascii=True), flush=True)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--actor', default='TideWhale')
parser.add_argument('--source', type=Path, default=Path('Tools/output/t2_3d/TideWhale/tidewhale_t2.glb'))
parser.add_argument('--tris', type=int, default=400)
parser.add_argument('--size', type=int, default=512)
args = parser.parse_args()
if not args.actor.isalnum() or args.tris < 100 or args.size not in (512, 1024):
raise ValueError('Invalid accessory parameters')
source = (ROOT / args.source).resolve()
if not source.is_relative_to(ROOT) or not source.is_file():
raise ValueError('Missing source inside project')
out = ROOT / 'Tools/output/t2_studio' / args.actor
out.mkdir(parents=True, exist_ok=True)
receipt = out / 'production.json'
current = json.loads(receipt.read_text('utf-8')) if receipt.exists() else {}
digest = sha(source)
if current and (current['sourceSha256'] != digest or current['targetTriangles'] != args.tris or current['textureSize'] != args.size):
raise ValueError('Source or settings changed; use a separate production directory')
current.update(actor=args.actor, source=source.relative_to(ROOT).as_posix(), sourceSha256=digest,
targetTriangles=args.tris, textureSize=args.size, status='studio')
def save():
receipt.write_text(json.dumps(current, indent=2) + '\n', encoding='utf-8')
save()
shutil.copy2(source, out / 'model.glb')
studio.is_logged_in = lambda client_id=None: auth.is_logged_in()
studio.load_storage_state = lambda client_id=None: (json.loads(auth.storage_state_path().read_text('utf-8')), None)
uv = out / 'model_lowpoly_uv.glb'
if uv.exists():
studio.validate_uv_glb(uv)
else:
meta_path = out / 'hunyuan_studio.json'
meta = json.loads(meta_path.read_text('utf-8')) if meta_path.exists() else {}
studio.process_existing(source, out, update, target_faces=args.tris,
resume_low_id=meta.get('lowpoly_works_id'), resume_uv_id=meta.get('uv_works_id'))
current.update(status='baking', studio=json.loads((out / 'hunyuan_studio.json').read_text('utf-8')))
save()
aligned = out / 'model_lowpoly_uv_aligned.glb'
if not aligned.exists():
with (out / 'alignment.log').open('w', encoding='utf-8') as log:
subprocess.run([BLENDER, '-b', '-t', '1', '--factory-startup', '--python',
str(ROOT / 'Tools/model_tools/bl_align_studio_uv.py'), '--', str(out)],
stdout=log, stderr=subprocess.STDOUT, timeout=120, check=True)
if not current.get('bakeAligned'):
_run_blender_step(out, BLENDER, 'blender_lowpoly_bake.py',
(out / 'model.glb', out / 'model_baked.glb', 1, args.size, aligned),
'bake.log', marker='Lowpoly bake saved to')
compress_bake_maps(out)
current['bakeAligned'] = True
save()
shutil.copy2(out / 'model_baked.glb', out / 'model_ex.glb')
mesh, reduction = limit_studio_faces(out, args.tris, BLENDER, update)
current.update(triangles=len(mesh.faces), vertices=len(mesh.vertices), decimation=reduction,
bakeAlignment=json.loads((out / 'bake_alignment.json').read_text('utf-8')))
name = args.actor + 'T2'
with (out / 'static_export.log').open('w', encoding='utf-8') as log:
subprocess.run([BLENDER, '-b', '-t', '1', '--factory-startup', '--python',
str(ROOT / 'Tools/model_tools/bl_prepare_studio_trial.py'), '--', str(out), name],
stdout=log, stderr=subprocess.STDOUT, check=True, timeout=120)
xpbr = run_blender_script(BLENDER, 'bl_convert.py',
[str(out / 'static' / (name + '.fbx')), str(out / 'xpbr'), str(args.size)], blender_args=('-t', '1'))
compress_dir_pngs(str(out / 'xpbr'), suffixes=('_base.png', '_mix.png'))
current.update(status='ready_for_unity', xpbr=xpbr,
staticExport=json.loads((out / 'static_export.json').read_text('utf-8')),
outputs={p.relative_to(ROOT).as_posix(): sha(p) for p in sorted((out / 'xpbr').iterdir()) if p.is_file()})
save()
update(status=current['status'], triangles=current['triangles'], output=str(out / 'xpbr'))
if __name__ == '__main__':
main()
+77
View File
@@ -0,0 +1,77 @@
"""Record provenance, controlled comparisons and limits of the LmGirl trial."""
import hashlib
import json
from pathlib import Path
root=Path(__file__).resolve().parents[1]
folder=root/'Tools/output/lmgirl_tpose_studio/rig_mixamo41'
def read(name):return json.loads((folder/name).read_text(encoding='utf-8'))
def digest(path):return hashlib.sha256(path.read_bytes()).hexdigest()
comparison=read('walking_comparison.json');fit=read('input_manifest.json')
topology=read('topology_audit.json')
native=read('unity_verification.json');exports=read('export_verification.json')
original=json.loads((folder.parent/'static_export.json').read_text(encoding='utf-8'))
report={
'conclusion':'LmGirl reuses the exact CampGuide Walking clip through bind-translation adaptation. Character-specific locality constraints eliminate the tested large hand/leg stretches without editing mesh geometry or UVs.',
'source':'Tools/output/lmgirl_tpose_studio/static/LmGirlTpose.fbx',
'source_sha256':topology['source_sha256'],
'skeleton_reference':'AIClient/Assets/Bundles/Characters/Human/test/Boy_mixamo/CampGuideStudio.fbx',
'bones':41,'triangles':4999,'vertices':4205,'website_weights_used':False,
'geometry_modified':False,'uv_preserved':topology['source_uv_sha256']==original['uvHashAfter'],
'fit':{'method':fit['fit'],'max_preserved_axis_rotation_error_radians':fit['max_preserved_axis_rotation_error_radians'],
'translation_scale':1.23711741,'source_rotation_curves_modified':False},
'animation':{'source':'AIClient/Assets/Review/CharacterAnimationLab/Generated/Walking.anim','same_native_asset':True,'frames':32,'fps':30,
'adaptation':'After shared clip sampling, local translation = source translation * scale + target-bind/source-bind offset. In-place removal uses the same scale. All rotation curves unchanged.'},
'remote':{'host':'DGXA','job':'/home/spark/SkinTokens/tmp_jobs/lmgirl_mixamo41_20261023_a1','seed':20261023,
'flags':['use_skeleton','use_transfer','use_postprocess','skeleton_template=original','rig_cls=vroid'],
'same_prediction_for_raw_and_voxel':True,'installed_demo_sha256':(folder/'installed_demo.sha256').read_text().strip(),
'instrumentation':'Tools/tokenrig_lmgirl_trial_remote.py; shared TokenRig code unchanged; raw and post weight snapshots retained',
'texture_note':'Input GLB is weight-blind geometry+skeleton without texture payload. Final Unity textures use local original UVs and baked source maps; texture transfer itself was not separately tested.'},
'comparisons':{k:{n:v[n] for n in ['maxStretch','maxEdgesOver2x','maxP99','handMaxLowerBodyInfluence','maxInfluences','minimumNonzeroWeight','floorMinRange']} for k,v in comparison.items()},
'optimization_assessment':{
'use_postprocess':'Official voxel-distance multiplicative reweighting. Suppresses distant leaks but worsened neck and upper-tail edge distribution on this mesh; not enabled in the selected local variant.',
'clean_weights':'Third-party skeleton-hop + Euclidean-distance heuristic, not mesh-connectivity repair. Default min_hops=5 removed Head influence from braid vertices and worsened maximum stretch. Not selected globally.',
'use_transfer':'Enabled in inference. Exact original geometry and UVs are additionally restored in local outputs; local texture maps retained.',
'epsilon_and_top4':'Official transfer/export already uses group_per_vertex=4. Selected local variant also drops weights <0.01 and renormalizes. Epsilon alone cannot remove 0.2868 or 0.4509 contamination.',
'topology':'Below-crotch graph test found no connection between lower pant legs. Initial visual suspicion of welded trousers was rejected; opposite-leg weight leakage was corrected without topology changes.'},
'selected':'local','scene':'AIClient/Assets/Review/CharacterAnimationLab/LmGirl/LmGirlAnimationLab.unity',
'limits':['Character-specific T-pose geometric masks; not a universal cleanup for arbitrary models or poses.',
'41 bones retained; hands currently follow the wrist rigidly, so finger articulation is intentionally not preserved.',
'Edge stretch excludes rest edges shorter than 0.2% of height and does not score volume preservation, penetration or aesthetics.',
'Walking floor min spans about -10.6 to +23.7mm at source scale; foot IK, contact and sliding not solved.',
'Breathing and Happy Idle controls reuse the original clips, but quantitative deformation audit here covers Walking only.'],
'validation':{'native':native,'exports':exports,'controls':read('unity_controls.json')},
'references':['https://github.com/VAST-AI-Research/SkinTokens/blob/main/demo.py',
'https://github.com/VAST-AI-Research/SkinTokens/blob/main/src/data/vertex_group.py',
'https://github.com/stevelittlefish/ComfyUI-SkinTokens-NoBlender/blob/main/skintokens/weight_repair.py'],
'artifacts_sha256':{name:digest(folder/name) for name in ['source.glb','rigged_raw.glb','rigged_post.glb','raw_prediction.npz','post_prediction.npz','weight_repair_upstream.py','Walking_local.glb','lmgirl_local.blend']}}
if (folder/'website/walking_comparison.json').exists():
website=read('website/walking_comparison.json')
report['website_comparison']={k:v for k,v in website.items() if k!='samples'}
report['website_comparison']['limitation']='This compares delivered website binding (33 bones) with the fitted TokenRig binding (41 bones), not a weights-only controlled comparison. Both sample the same source clip and time, with bind-aware rotation/translation adaptation.'
report['scene_lineup']=['Mixamo website (33 bones, up to 5 influences)','TokenRig raw (41 bones)','Voxel postprocess (41 bones)','Local constraints (41 bones)']
report['artifacts_sha256']['website_source_fbx']=digest(root/'AIClient/Assets/Bundles/Characters/Human/test/girl/LmGirlTpose.fbx')
report['artifacts_sha256']['website_review_data']=digest(folder/'website/unity_review_data.json')
if (folder/'unity_gait_verification.json').exists():
gait=read('gait_correction.json');before=read('gait_diagnosis_before.json')
report['conclusion']='Girl ankle crossing was caused by angle-only retargeting from the boy wide-leg bind to the girl nearly-parallel bind. Active girl playback now uses target clips with proportionally mapped source ankle trajectories and analytic two-bone IK; skin weights and the boy source clip are unchanged.'
report['animation']['same_native_asset']=False
report['animation']['source_clip_unmodified']=True
report['animation']['target_clips']=['AIClient/Assets/Review/CharacterAnimationLab/LmGirl/Generated/Walking_LmGirl41.anim',
'AIClient/Assets/Review/CharacterAnimationLab/LmGirl/Generated/Walking_LmGirlMixamo33.anim']
report['animation']['adaptation']=gait['method']
report['baseline_comparisons']=report.pop('comparisons')
report['active_comparisons']=gait['deformation']
report['gait_before']={k:{n:v for n,v in d.items() if n!='samples'} for k,d in before.items()}
report['gait_after']={k:{n:v for n,v in d.items() if n!='tracks'} for k,d in gait['reports'].items()}
report['validation']['baseline_native']=report['validation'].pop('native')
report['validation']['baseline_exports']=report['validation'].pop('exports')
report['validation']['gait']=read('unity_gait_verification.json')
report['validation']['gait_exports']=read('gait_export_verification.json')
report['limits']=[x for x in report['limits'] if not x.startswith('Walking floor min')]
report['limits'].append('Leg IK fixes stance/ankle paths, but ground contact locking and zero foot sliding remain unverified. Local skin floor min spans about -14.5 to +13.8mm at source scale.')
for tag in ['raw','post','clean','clean_eps','local']:
name='Walking_'+tag+'_corrected.glb';report['artifacts_sha256'][name]=digest(folder/name)
assert native['passed'] and exports['passed'] and report['uv_preserved']
(folder/'summary.json').write_text(json.dumps(report,ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
print('PASS summary:',folder/'summary.json')
+2
View File
@@ -0,0 +1,2 @@
bin/
obj/
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration><packageSources><clear /></packageSources></configuration>
+53
View File
@@ -0,0 +1,53 @@
using System;
using System.Collections.Generic;
using HotUpdate.Net;
namespace UnityEngine { public static class Time { public static float realtimeSinceStartup; } }
namespace AOT.Boot
{
public static class WebSocketBridge
{
public static int LastId;
public static readonly Dictionary<int, Queue<string>> Events = new();
public static readonly List<string> Sent = new();
public static int AIGame_WsCreate(string url) { Events[++LastId] = new(); return LastId; }
public static string AIGame_WsPoll(int id) => Events[id].TryDequeue(out var item) ? item : null;
public static void AIGame_WsSend(int id, string text) => Sent.Add(text);
public static void AIGame_WsDispose(int id) => Events.Remove(id);
public static void Emit(string item) => Events[LastId].Enqueue(item);
}
}
public static class Program
{
static void Check(bool pass, string message) { if (!pass) throw new Exception(message); }
public static void Main()
{
var ws = new WsClient(); int opens = 0, closes = 0, retries = 0; string message = null;
ws.OnConnected += () => { opens++; ws.SendText("hello"); };
ws.OnDisconnected += _ => closes++;
ws.OnReconnecting += _ => retries++;
ws.OnMessage += text => message = text;
ws.Connect("wss://test");
Check(ws.State == WsState.Connecting, "connect state");
AOT.Boot.WebSocketBridge.Emit("O"); AOT.Boot.WebSocketBridge.Emit("M中文🙂"); ws.Tick();
Check(opens == 1 && message == "中文🙂" && AOT.Boot.WebSocketBridge.Sent[0] == "hello", "open, UTF-8 and queued send");
AOT.Boot.WebSocketBridge.Emit("Eoffline"); ws.Tick();
Check(closes == 1 && retries == 1 && ws.LastError == "offline", "disconnect and retry notification");
UnityEngine.Time.realtimeSinceStartup = 1; ws.Tick();
Check(AOT.Boot.WebSocketBridge.LastId == 1, "retry must back off");
UnityEngine.Time.realtimeSinceStartup = 2; ws.Tick();
Check(ws.State == WsState.Reconnecting && AOT.Boot.WebSocketBridge.LastId == 2, "retry starts at deadline");
UnityEngine.Time.realtimeSinceStartup = 18; ws.Tick();
Check(closes == 2 && retries == 2 && ws.LastError.Contains("timed out"), "connect timeout");
ws.ForceReconnect(); AOT.Boot.WebSocketBridge.Emit("O"); ws.Tick();
Check(ws.State == WsState.Connected && opens == 2 && ws.LastError == null, "manual reconnect resets error");
ws.Close(); UnityEngine.Time.realtimeSinceStartup = 100; ws.Tick();
Check(ws.State == WsState.Disconnected && AOT.Boot.WebSocketBridge.Events.Count == 0, "manual close cancels pending reconnect");
ws.Connect("wss://test"); ws.AutoReconnect = false;
AOT.Boot.WebSocketBridge.Emit("C1000 maintenance"); ws.Tick();
UnityEngine.Time.realtimeSinceStartup = 200; ws.Tick();
Check(ws.State == WsState.Disconnected && retries == 2, "maintenance must not reconnect");
ws.Dispose();
Console.WriteLine("PASS: actual UNITY_WEBGL WsClient open/send/receive/backoff/timeout/manual reconnect/close/maintenance.");
}
}
+13
View File
@@ -0,0 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<NuGetAudit>false</NuGetAudit>
<DefineConstants>UNITY_WEBGL</DefineConstants>
<EnableDefaultCompileItems>false</EnableDefaultCompileItems>
</PropertyGroup>
<ItemGroup>
<Compile Include="Program.cs" />
<Compile Include="../../../AIClient/Assets/HotUpdate/Scripts/Net/WsClient.cs" Link="WsClient.cs" />
</ItemGroup>
</Project>
+36
View File
@@ -0,0 +1,36 @@
"""Job-scoped instrumentation of installed TokenRig; no shared code modified.
Run inside the existing DGXA container. One prediction produces raw + voxel
postprocessed outputs, avoiding stochastic differences between the two trials.
"""
import hashlib
import os
from pathlib import Path
import runpy
import sys
job=Path(__file__).resolve().parent
source=Path('/workspace/SkinTokens/demo.py').read_text()
needle=' if use_postprocess:\n'
assert source.count(needle)==1
instrument=''' import numpy as np
import os
pair_folder = os.environ["TOKENRIG_PAIR_FOLDER"]
np.savez_compressed(pair_folder + "/raw_prediction.npz", vertices=asset.vertices, faces=asset.faces, joints=asset.joints, parents=asset.parents, skin=asset.skin)
post_bpy_payload("transfer", dict(source_asset=asset, target_path=asset.path, export_path=pair_folder + "/rigged_raw.glb", group_per_vertex=4))
print("PAIR_RAW_EXPORTED", flush=True)
'''
source=source.replace(needle,instrument+needle)
needle2=' asset.normalize_skin()\n'
assert source.count(needle2)==1
source=source.replace(needle2,needle2+''' np.savez_compressed(pair_folder + "/post_prediction.npz", vertices=asset.vertices, faces=asset.faces, joints=asset.joints, parents=asset.parents, skin=asset.skin)
print("PAIR_POSTPROCESSED", flush=True)
''')
source='import random\nimport numpy as np\nimport torch\nrandom.seed(20261023)\nnp.random.seed(20261023)\ntorch.manual_seed(20261023)\n'+source
script=job/'instrumented_demo.py'
script.write_text(source)
(job/'installed_demo.sha256').write_text(hashlib.sha256(Path('/workspace/SkinTokens/demo.py').read_bytes()).hexdigest())
os.environ['TOKENRIG_PAIR_FOLDER']=str(job)
sys.argv=[str(script),'--input',str(job/'source.glb'),'--output',str(job/'rigged_post.glb'),
'--use_skeleton','--use_transfer','--use_postprocess','--skeleton_template','original','--rig_cls','vroid']
runpy.run_path(str(script),run_name='__main__')
+49
View File
@@ -0,0 +1,49 @@
[CmdletBinding()]
param([Parameter(Mandatory=$true)][string]$ReleaseDirectory, [int]$Port = 18183, [switch]$CheckSamePortBind)
$ErrorActionPreference = 'Stop'
$release = (Resolve-Path -LiteralPath $ReleaseDirectory).Path
$repo = Split-Path -Parent $PSScriptRoot
$probe = Join-Path $repo ('Tools/output/server_release/probe-' + [Guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $probe -Force | Out-Null
foreach ($script in Get-ChildItem -LiteralPath $release -Filter '*.ps1') {
$syntaxErrors = $null; $tokens = $null
[System.Management.Automation.Language.Parser]::ParseFile($script.FullName,[ref]$tokens,[ref]$syntaxErrors) | Out-Null
if ($syntaxErrors.Count) { throw ($syntaxErrors | Out-String) }
}
$probeProcess = Start-Process -FilePath (Join-Path $release 'aiserver.exe') -ArgumentList @(
'--host','127.0.0.1','--port',"$Port",'--db',(Join-Path $probe 'aiserver.db'),'--daemon'
) -WorkingDirectory $release -WindowStyle Hidden -PassThru `
-RedirectStandardOutput (Join-Path $probe 'stdout.log') -RedirectStandardError (Join-Path $probe 'stderr.log')
try {
$ready = $false
for ($attempt=0; $attempt -lt 30; $attempt++) {
if ($probeProcess.HasExited) { throw 'Packaged server exited.' }
try {
$health = Invoke-RestMethod -Uri "http://127.0.0.1:$Port/health" -TimeoutSec 1
if ($health.service -eq 'aiserver') { $ready = $true; break }
} catch { }
Start-Sleep -Milliseconds 200
}
if (-not $ready) { throw 'Packaged server did not become ready.' }
& (Join-Path $release 'Test-Server.ps1') -BaseUrl "http://127.0.0.1:$Port" -ExerciseAccount
& (Join-Path $release 'Install-StartupTask.ps1') -Port $Port -WhatIf
if ($CheckSamePortBind) {
$nic = Get-NetIPAddress -AddressFamily IPv4 | Where-Object {
$_.AddressState -eq 'Preferred' -and $_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.254.*'
} | Select-Object -First 1
if (-not $nic) { throw 'No non-loopback IPv4 NIC available for bind verification.' }
& (Join-Path $release 'New-CaddySite.ps1') -ListenIp $nic.IPAddress -OutputPath (Join-Path $probe 'BuddyDefense.caddyfile')
$listener = New-Object Net.Sockets.TcpListener([Net.IPAddress]::Parse($nic.IPAddress),$Port)
$listener.Server.ExclusiveAddressUse = $true
try { $listener.Start(); Write-Output "PASS distinct loopback/NIC listeners share port $Port." }
finally { $listener.Stop() }
}
$result = @{passed=$true; scriptSyntax=$true; health=$true; unauthenticatedState401=$true;
registration=$true; login=$true; state=$true; websocket=$true; startupTaskDryRun=$true;
isolatedDatabase=$true; samePortBind=[bool]$CheckSamePortBind; productionModified=$false; release=$release}
[IO.File]::WriteAllText((Join-Path $repo 'Tools/output/server_release/smoke.json'),
($result | ConvertTo-Json), (New-Object Text.UTF8Encoding($false)))
Write-Output 'PASS packaged release smoke test; isolated database only.'
} finally {
if (-not $probeProcess.HasExited) { Stop-Process -Id $probeProcess.Id -Force }
}
+67
View File
@@ -0,0 +1,67 @@
// Execute the actual Emscripten library against SocketTask and browser test doubles.
const fs = require('fs');
const vm = require('vm');
const assert = require('assert/strict');
const source = fs.readFileSync('AIClient/Assets/Plugins/WebGL/GameWebSocket.jslib', 'utf8');
let checks = 0;
function environment(kind) {
let sockets = [], memory = {}, next = 1;
const make = () => {
const s = { callbacks: {}, sent: [], closes: 0,
onOpen(fn) { this.callbacks.open = fn; }, onMessage(fn) { this.callbacks.message = fn; },
onError(fn) { this.callbacks.error = fn; }, onClose(fn) { this.callbacks.close = fn; },
close() { this.closes++; },
send(arg) { if (this.throwSend) throw Error('send rejected'); this.sent.push(arg); },
emit(type, data = {}) { (this.callbacks[type] || this['on' + type])(data); } };
sockets.push(s); return s;
};
const context = { LibraryManager: { library: {} }, mergeInto: Object.assign,
UTF8ToString: x => typeof x === 'string' ? x : memory[x],
lengthBytesUTF8: x => Buffer.byteLength(x), _malloc: () => next++,
stringToUTF8: (x, p) => memory[p] = x };
const wx = { connectSocket(options) { const s = make(); s.options = options; return s; } };
if (kind === 'wechat') context.wx = wx;
else if (kind === 'global') context.GameGlobal = { wx };
else if (kind === 'browser') context.WebSocket = function (url) { const s = make(); s.url = url; return s; };
vm.createContext(context); vm.runInContext(source, context);
context.AIGameSockets = context.LibraryManager.library.$AIGameSockets;
const api = context.LibraryManager.library;
return { api, sockets, context, poll(id) { const p = api.AIGame_WsPoll(id); const text = memory[p]; delete memory[p]; return text; } };
}
for (const kind of ['wechat', 'global', 'browser']) {
const e = environment(kind), id = e.api.AIGame_WsCreate('wss://game.test/ws');
const s = e.sockets[0];
s.emit('open'); assert.equal(e.poll(id), 'O');
s.emit('message', { data: '中文消息🙂' }); assert.equal(e.poll(id), 'M中文消息🙂');
e.api.AIGame_WsSend(id, 'hello');
assert.equal(kind === 'browser' ? s.sent[0] : s.sent[0].data, 'hello');
s.emit('error', { errMsg: 'offline' }); s.emit('close', { code: 1006 });
assert.ok(e.poll(id).startsWith('E')); assert.equal(e.poll(id), undefined);
e.api.AIGame_WsDispose(id); assert.equal(s.closes, 1);
s.emit('message', { data: 'stale' }); assert.equal(e.poll(id), undefined);
assert.equal(Object.keys(e.context.AIGameSockets.sockets).length, 0);
checks++;
}
{
const e = environment('wechat'), id = e.api.AIGame_WsCreate('wss://test');
e.api.AIGame_WsDispose(id); e.sockets[0].emit('open');
assert.equal(e.sockets[0].closes, 2); assert.equal(e.poll(id), undefined); checks++;
}
for (const mode of ['throw', 'callback']) {
const e = environment('wechat'), id = e.api.AIGame_WsCreate('wss://test'), s = e.sockets[0];
s.emit('open'); e.poll(id);
if (mode === 'throw') s.throwSend = true;
e.api.AIGame_WsSend(id, 'hello');
if (mode === 'callback') s.sent[0].fail({ errMsg: 'send denied' });
assert.ok(e.poll(id).startsWith('E')); e.api.AIGame_WsDispose(id); checks++;
}
{
const e = environment('wechat'), id = e.api.AIGame_WsCreate('wss://test');
e.sockets[0].options.fail({ errMsg: 'domain rejected' });
assert.equal(e.poll(id), 'Edomain rejected'); e.api.AIGame_WsDispose(id); checks++;
}
{
const e = environment('missing'), id = e.api.AIGame_WsCreate('wss://test');
assert.ok(e.poll(id).startsWith('E')); e.api.AIGame_WsDispose(id); checks++;
}
console.log(`PASS: ${checks} WebSocket bridge scenarios (WeChat, GameGlobal, browser, UTF-8, error, send failure, disposal).`);
+32
View File
@@ -0,0 +1,32 @@
// Exercises the actual WebGL bridge with WeChat logical coordinates and API fallbacks.
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const assert = require('assert');
const root = path.resolve(__dirname, '..');
const source = fs.readFileSync(path.join(root, 'AIClient/Assets/Plugins/WebGL/UiSafeArea.jslib'), 'utf8');
const results = [];
function check(name, api, expected, status = 1, useGameGlobal = false) {
const context = { LibraryManager: { library: {} }, mergeInto: Object.assign, HEAPF32: new Float32Array(16) };
if (api) { if (useGameGlobal) context.GameGlobal = { wx: api }; else context.wx = api; }
vm.createContext(context); vm.runInContext(source, context);
assert.strictEqual(context.LibraryManager.library.AIGame_GetUiSafeInsets(0), status, name);
const actual = Array.from(context.HEAPF32.slice(0, 4));
actual.forEach((v, i) => assert(Math.abs(v - expected[i]) < 0.000001, name + ': ' + actual));
results.push({ name, status, actual });
}
const phone = { windowWidth: 390, windowHeight: 844, pixelRatio: 3,
safeArea: { left: 0, right: 390, top: 47, bottom: 810, width: 390, height: 763 } };
const capsule = { left: 290, right: 377, top: 51, bottom: 83, width: 87, height: 32 };
check('iphone notch, capsule and home indicator', { getWindowInfo: () => phone, getMenuButtonBoundingClientRect: () => capsule }, [0, 34 / 844, 0, 91 / 844]);
check('DPR does not change normalized layout', { getWindowInfo: () => ({ ...phone, pixelRatio: 1 }), getMenuButtonBoundingClientRect: () => capsule }, [0, 34 / 844, 0, 91 / 844]);
check('GameGlobal API access', { getWindowInfo: () => phone, getMenuButtonBoundingClientRect: () => capsule }, [0, 34 / 844, 0, 91 / 844], 1, true);
check('older client window API fallback', { getWindowInfo: () => { throw Error('unsupported'); }, getSystemInfoSync: () => phone, getMenuButtonBoundingClientRect: () => capsule }, [0, 34 / 844, 0, 91 / 844]);
check('menu API fallback', { getWindowInfo: () => phone }, [0, 34 / 844, 0, 87 / 844]);
check('android capsule without notch', { getWindowInfo: () => ({ windowWidth: 360, windowHeight: 800 }),
getMenuButtonBoundingClientRect: () => ({ top: 24, bottom: 56, width: 87, height: 32 }) }, [0, 0, 0, 64 / 800]);
check('invalid dimensions', { getWindowInfo: () => ({ windowWidth: 0, windowHeight: 0 }) }, [0, 0, 0, 0], 0);
check('browser has no wx', null, [0, 0, 0, 0], 0);
const output = path.join(root, 'Tools/output/home_mobile'); fs.mkdirSync(output, { recursive: true });
fs.writeFileSync(path.join(output, 'wechat_safe_area.json'), JSON.stringify({ passed: true, cases: results }, null, 2));
console.log('PASS: ' + results.length + ' WeChat safe-area bridge scenarios');