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:
@@ -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."
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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'))
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
@@ -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"}))
|
||||
@@ -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))
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -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'
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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')
|
||||
@@ -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()
|
||||
@@ -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')
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
|
||||
@@ -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))
|
||||
@@ -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.
@@ -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.
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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')
|
||||
@@ -0,0 +1,2 @@
|
||||
bin/
|
||||
obj/
|
||||
@@ -0,0 +1,2 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration><packageSources><clear /></packageSources></configuration>
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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__')
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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).`);
|
||||
@@ -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');
|
||||
Reference in New Issue
Block a user