Replace TownSquare with HomeCamp art and land humanoid bots, LittleBear, and LmGirl review assets.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -21,6 +21,11 @@
|
||||
|
||||
## FBX 轴向约定
|
||||
|
||||
项目标准为 Y-up、底面中心原点、**局部 +Z 为正面**,配置见 `Config/Art/ModelConvention.json`。
|
||||
朝向直接使用 Unity `transform.forward` / `Quaternion.LookRotation(direction)`。
|
||||
`axis_forward="-Z"` 是 Blender FBX 坐标转换参数,继续保留;它不等于角色解剖正面。
|
||||
旧 -Z 资产、固定骨架和历史验收记录按自身约定兼容,不批量旋转已绑定网格或动画。
|
||||
|
||||
Studio 五步的 GLB 使用标准 glTF Y-up;Blender 默认导入后应在 Z-up 场景中站立。
|
||||
`prepare_game_export` 为历史调用保留 Y-up 顶点缓冲;最终必须由
|
||||
`export_fbx_source_yup` 在临时副本上恢复 Blender Z-up,再烘焙为 Y-up FBX。
|
||||
@@ -67,3 +72,18 @@ python Tools/install_character_bundles.py -a CaoCao --kind Human -b Tools/output
|
||||
| QR 引擎 | 未捆绑(无 xremesh 依赖) | 需本机 Quad Remesher 引擎 |
|
||||
|
||||
**结论**:AIC#Project 在「有机角色拓扑」上更优(Quad Remesher + 更深 UV 门);本仓库已并入其自适应射线、大岛门限与 XPbr 打包,并保留 GLB 输入。若本机已装 QR 引擎,可再把 `qr_bridge` / `--reducer quad` 完整迁入作为可选增强。
|
||||
|
||||
## 肩部黑影诊断(2026-10-27)
|
||||
|
||||
先用同机位拆分检查:底色无光照、关闭 Normal、关闭 AO、纯灰材质。`bl_audit_bake_quality.py --job <Studio任务目录> --output <绝对输出目录>` 在 Blender 中运行,可生成上述对比。`--quick --normal-strength 0.35` 可导出减弱法线的隔离预览;它会牺牲部分细节,不代表投射已修复。
|
||||
|
||||
已用 `lm_bear` 任务完成受控对比,记录在 `Tools/output/bake_quality_audit/findings.json`:黑块主要由法线贴图引入,EMIT底色和不挂AO的GLB仍可排除相应通道。仅取消烘焙后的向外法线偏置不足以消除问题;将cage从0.06014m缩到0.007156m改善肩部/腿间,但增加头发细节漏投射。因此不能仅凭包围盒固定比例或全局P99距离决定最终参数。
|
||||
|
||||
推荐的生产改进(本轮未修改共享后端):
|
||||
|
||||
- 烘焙前固定最终三角化、UV和角点法线,烘焙后保持一致;如第五步减面改变了网格,需在最终网格原UV上补烘法线。
|
||||
- 分区域cage、小范围到大范围重试,结合命中距离、法线方向和部位对应关系选择结果;肩/领巾/头发等复杂区域可用匹配分件或手调cage。
|
||||
- 生成独立UV覆盖/投射命中mask;不要把黑色底色、零金属度当作未命中。单位长度合格的法线也可能投射到了错误表面。
|
||||
- 可为卡通预览提供Normal Strength约0.3~0.5的可调回退,并保留原贴图。
|
||||
|
||||
投射试验入口 `bl_trial_studio_bake.py --job <任务目录> --output <绝对输出目录> --ray-mode legacy|adaptive`:只在独立目录执行当前共享烘焙器的受控副本,保留源网格与Studio UV。Blender烘焙设置说明:[Render Baking](https://docs.blender.org/manual/en/5.0/render/cycles/baking.html)。
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
"""Read-only shading audit for a Studio/bake job. Blender -- --job DIR --output DIR."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import bpy
|
||||
import numpy as np
|
||||
from mathutils import Vector
|
||||
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument('--job', required=True)
|
||||
p.add_argument('--output', required=True)
|
||||
p.add_argument('--low-model')
|
||||
p.add_argument('--quick', action='store_true')
|
||||
p.add_argument('--normal-strength', type=float)
|
||||
args = p.parse_args(sys.argv[sys.argv.index('--') + 1:])
|
||||
job, out = Path(args.job).resolve(), Path(args.output).resolve()
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
def load(path):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.ops.import_scene.gltf(filepath=str(path))
|
||||
meshes = [o for o in bpy.context.scene.objects if o.type == 'MESH']
|
||||
assert len(meshes) == 1
|
||||
obj = meshes[0]
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
obj.select_set(True)
|
||||
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||||
return obj
|
||||
|
||||
|
||||
def describe(obj):
|
||||
result = []
|
||||
for mat in obj.data.materials:
|
||||
bsdf = next((n for n in mat.node_tree.nodes if n.type == 'BSDF_PRINCIPLED'), None)
|
||||
channels = {}
|
||||
if bsdf:
|
||||
for name in ['Base Color', 'Metallic', 'Roughness', 'Normal']:
|
||||
socket = bsdf.inputs[name]
|
||||
channels[name] = dict(linked=socket.is_linked,
|
||||
source=socket.links[0].from_node.type if socket.is_linked else None)
|
||||
result.append(dict(name=mat.name, channels=channels,
|
||||
images=[dict(name=n.image.name, size=list(n.image.size), colorspace=n.image.colorspace_settings.name)
|
||||
for n in mat.node_tree.nodes if n.type == 'TEX_IMAGE' and n.image]))
|
||||
return result
|
||||
|
||||
|
||||
def setup(obj):
|
||||
pts = np.array([obj.matrix_world @ v.co for v in obj.data.vertices])
|
||||
lo, hi = pts.min(0), pts.max(0)
|
||||
center = Vector((lo + hi) / 2)
|
||||
h = hi[2] - lo[2]
|
||||
scene = bpy.context.scene
|
||||
scene.render.engine = 'CYCLES'
|
||||
scene.cycles.device = 'CPU'
|
||||
scene.cycles.samples = 16
|
||||
scene.cycles.use_denoising = True
|
||||
scene.render.resolution_x = 800
|
||||
scene.render.resolution_y = 800
|
||||
scene.render.resolution_percentage = 100
|
||||
scene.render.image_settings.file_format = 'PNG'
|
||||
scene.world = bpy.data.worlds.new('AuditWorld')
|
||||
scene.world.use_nodes = True
|
||||
scene.world.node_tree.nodes['Background'].inputs['Color'].default_value = (.4, .4, .4, 1)
|
||||
scene.world.node_tree.nodes['Background'].inputs['Strength'].default_value = .6
|
||||
for name, xyz, power, size in [('Key', (-2, 3, 3), 180, 2), ('Fill', (2, -2, 1.5), 100, 2)]:
|
||||
data = bpy.data.lights.new(name, 'AREA'); data.energy = power; data.shape = 'DISK'; data.size = size*h
|
||||
light = bpy.data.objects.new(name, data); scene.collection.objects.link(light)
|
||||
light.location = center + Vector(xyz)*h
|
||||
light.rotation_euler = (center-light.location).to_track_quat('-Z', 'Y').to_euler()
|
||||
camera = bpy.data.objects.new('Camera', bpy.data.cameras.new('Camera'))
|
||||
scene.collection.objects.link(camera); scene.camera = camera
|
||||
camera.data.type = 'ORTHO'; camera.data.ortho_scale = max(h*1.17, (hi[0]-lo[0])*1.17)
|
||||
camera.location = center + Vector((1.7, 3, 1))*h
|
||||
camera.rotation_euler = (center-camera.location).to_track_quat('-Z', 'Y').to_euler()
|
||||
scene.view_settings.view_transform = 'Standard'
|
||||
|
||||
|
||||
def capture(name):
|
||||
bpy.context.scene.render.filepath = str(out / (name+'.png'))
|
||||
bpy.ops.render.render(write_still=True)
|
||||
|
||||
|
||||
def normal_quality(obj):
|
||||
node = next((n for mat in obj.data.materials for n in mat.node_tree.nodes
|
||||
if n.type == 'TEX_IMAGE' and n.image and n.image.name.startswith('normal')), None)
|
||||
if node is None:
|
||||
return {}
|
||||
image = node.image
|
||||
w, h = image.size[:]
|
||||
pixels = np.empty(w*h*4, dtype=np.float32)
|
||||
image.pixels.foreach_get(pixels)
|
||||
pixels = pixels.reshape(h, w, 4)
|
||||
coverage = np.zeros((h, w), dtype=bool)
|
||||
uv_data = obj.data.uv_layers.active.data
|
||||
obj.data.calc_loop_triangles()
|
||||
corners = []
|
||||
for tri in obj.data.loop_triangles:
|
||||
uvs = np.array([uv_data[i].uv[:] for i in tri.loops])
|
||||
for vi, uv in zip(tri.vertices, uvs):
|
||||
corners.append(tuple(np.round(list(obj.data.vertices[vi].co)+list(uv), 6)))
|
||||
x, y = uvs[:, 0]*w-.5, uvs[:, 1]*h-.5
|
||||
x0, x1 = max(0, int(np.floor(x.min()))), min(w-1, int(np.ceil(x.max())))
|
||||
y0, y1 = max(0, int(np.floor(y.min()))), min(h-1, int(np.ceil(y.max())))
|
||||
den = (y[1]-y[2])*(x[0]-x[2])+(x[2]-x[1])*(y[0]-y[2])
|
||||
if abs(den)<1e-8 or x1<x0 or y1<y0:
|
||||
continue
|
||||
xx, yy = np.meshgrid(np.arange(x0,x1+1),np.arange(y0,y1+1))
|
||||
a=((y[1]-y[2])*(xx-x[2])+(x[2]-x[1])*(yy-y[2]))/den
|
||||
b=((y[2]-y[0])*(xx-x[2])+(x[0]-x[2])*(yy-y[2]))/den
|
||||
coverage[y0:y1+1,x0:x1+1] |= (a>=0)&(b>=0)&(a+b<=1)
|
||||
length = np.linalg.norm(pixels[:,:,:3]*2-1, axis=2)
|
||||
invalid = coverage & ((length < .85) | (length > 1.15))
|
||||
return dict(uvCoveredPixels=int(coverage.sum()),
|
||||
invalidNormalDirectionPixels=int(invalid.sum()),
|
||||
invalidNormalDirectionPercent=float(invalid.sum()/max(1,coverage.sum())*100),
|
||||
nearlyBlackNormalPixels=int((coverage & (pixels[:,:,:3].max(axis=2)<.03)).sum()),
|
||||
oppositeHemispherePixels=int((coverage & (pixels[:,:,2]<.5)).sum()),
|
||||
geometryUvFingerprint=hashlib.sha256(np.array(sorted(corners),dtype='<f4').tobytes()).hexdigest())
|
||||
|
||||
|
||||
report = {}
|
||||
items = [('low', Path(args.low_model).resolve() if args.low_model else job/'model_baked.glb')]
|
||||
if not args.quick:
|
||||
items.insert(0,('high',job/'model.glb'))
|
||||
for tag, filename in items:
|
||||
obj = load(filename)
|
||||
if args.normal_strength is not None and tag=='low':
|
||||
for mat in obj.data.materials:
|
||||
for node in mat.node_tree.nodes:
|
||||
if node.type=='NORMAL_MAP':
|
||||
node.inputs['Strength'].default_value=args.normal_strength
|
||||
report[tag] = dict(triangles=sum(len(poly.vertices)-2 for poly in obj.data.polygons), materials=describe(obj),
|
||||
normalQuality=normal_quality(obj) if tag=='low' else None)
|
||||
setup(obj)
|
||||
capture(tag+'_lit_full')
|
||||
if args.quick:
|
||||
if args.normal_strength is not None:
|
||||
bpy.ops.object.select_all(action='DESELECT');obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active=obj
|
||||
bpy.ops.export_scene.gltf(filepath=str(out/'normal_strength_preview.glb'),export_format='GLB',use_selection=True,export_animations=False)
|
||||
continue
|
||||
if tag == 'low':
|
||||
for mat in obj.data.materials:
|
||||
nt = mat.node_tree; bsdf = next(n for n in nt.nodes if n.type == 'BSDF_PRINCIPLED')
|
||||
for link in list(bsdf.inputs['Normal'].links): nt.links.remove(link)
|
||||
capture('low_without_normal')
|
||||
for mat in obj.data.materials:
|
||||
nt=mat.node_tree; bsdf=next(n for n in nt.nodes if n.type=='BSDF_PRINCIPLED')
|
||||
output=next(n for n in nt.nodes if n.type=='OUTPUT_MATERIAL')
|
||||
emit=nt.nodes.new('ShaderNodeEmission')
|
||||
base=bsdf.inputs['Base Color']
|
||||
if base.is_linked:nt.links.new(base.links[0].from_socket,emit.inputs['Color'])
|
||||
else:emit.inputs['Color'].default_value=base.default_value
|
||||
for link in list(output.inputs['Surface'].links):nt.links.remove(link)
|
||||
nt.links.new(emit.outputs['Emission'],output.inputs['Surface'])
|
||||
capture('low_albedo_unlit')
|
||||
# Plain material isolates the exported geometric/corner normals.
|
||||
mat=bpy.data.materials.new('Clay');mat.use_nodes=True
|
||||
bsdf=mat.node_tree.nodes.get('Principled BSDF');bsdf.inputs['Base Color'].default_value=(.5,.5,.5,1);bsdf.inputs['Roughness'].default_value=.8
|
||||
obj.data.materials.clear();obj.data.materials.append(mat)
|
||||
capture('low_clay_exported_normals')
|
||||
try:bpy.ops.mesh.customdata_custom_splitnormals_clear()
|
||||
except Exception:pass
|
||||
for poly in obj.data.polygons:poly.use_smooth=True
|
||||
capture('low_clay_recomputed_normals')
|
||||
(out/'audit.json').write_text(json.dumps(report,indent=2),encoding='utf-8')
|
||||
print('AUDIT_DONE',out)
|
||||
@@ -8,6 +8,8 @@ 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'
|
||||
LEGACY=OUT/'legacy/LmGirlTpose.fbx'
|
||||
if not LEGACY.is_file():LEGACY=FOLDER/'static/LmGirlTpose.fbx'
|
||||
|
||||
def inspect(stage,path):
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
@@ -34,10 +36,11 @@ def inspect(stage,path):
|
||||
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'),
|
||||
('05 final GLB',FOLDER/'model_ex.glb'),('Original static FBX',LEGACY),
|
||||
('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')]
|
||||
('Fixed production XPbr FBX',FOLDER/'axis_pipeline_audit/production/xpbr/LmGirlTpose.fbx'),
|
||||
('Current static FBX',FOLDER/'static/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')
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Export unretargeted Mixamo FBX rest/animated joint positions for Unity comparison."""
|
||||
import sys,json,hashlib
|
||||
from pathlib import Path
|
||||
import bpy
|
||||
import numpy as np
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[2]
|
||||
OUT=ROOT/'Tools/output/campguide_studio/playback_audit';OUT.mkdir(parents=True,exist_ok=True)
|
||||
paths=[ROOT/'AIClient/Assets/Bundles/Characters/Human/test/Boy_mixamo/CampGuideStudio.fbx',ROOT/'AIClient/Assets/Bundles/Characters/Human/Common/Walking.fbx']
|
||||
report=[]
|
||||
for path in paths:
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True);bpy.ops.import_scene.fbx(filepath=str(path),use_anim=True)
|
||||
arm=next(o for o in bpy.context.scene.objects if o.type=='ARMATURE')
|
||||
action=arm.animation_data.action if arm.animation_data else None
|
||||
start,end=action.frame_range if action else (1,1)
|
||||
rest={b.name:dict(parent=b.parent.name if b.parent else None,world=list(arm.matrix_world@b.head_local),matrix=[list(r) for r in arm.matrix_world@b.matrix_local]) for b in arm.data.bones}
|
||||
samples=[]
|
||||
for frame in np.linspace(start,end,32) if action else [1]:
|
||||
whole=int(frame);bpy.context.scene.frame_set(whole,subframe=float(frame-whole));bpy.context.view_layer.update()
|
||||
samples.append(dict(frame=float(frame),world={b.name:list(arm.matrix_world@b.head) for b in arm.pose.bones}))
|
||||
entry=dict(path=str(path.relative_to(ROOT)),sha256=hashlib.sha256(path.read_bytes()).hexdigest(),bones=len(rest),rest=rest,frame_start=float(start),frame_end=float(end),fps=bpy.context.scene.render.fps,samples=samples)
|
||||
report.append(entry);print('FBX_REFERENCE',path.name,len(rest),start,end)
|
||||
(OUT/'fbx_reference.json').write_text(json.dumps(report,indent=2),encoding='utf-8')
|
||||
@@ -71,7 +71,8 @@ def prepare_game_export(obj, restore_axis=None):
|
||||
|
||||
restore_axis: True 强制 -90°X;False 跳过;None 则导入后物体仍带约 +90°X 才还原。
|
||||
烘焙结束时顶点已是 Z-up、旋转已 apply,必须传 True。
|
||||
导出后:Y-up、原点底面中点、局部 -Z 为正面(与混元/glTF 一致)。
|
||||
导出后保证 Y-up、原点底面中点;项目目标为解剖正面 +Z,须独立验收。
|
||||
解剖正面沿用输入。axis_forward='-Z' 是坐标转换设置,不代表人物脸部朝 -Z。
|
||||
"""
|
||||
import math
|
||||
import bpy
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Export the selected TokenRig GLB as a textured, skinned Unity FBX.
|
||||
|
||||
Run in Blender. Does not change the source rig, weights, or prior review assets.
|
||||
"""
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import bpy
|
||||
import numpy as np
|
||||
from mathutils import Matrix, Vector
|
||||
from mathutils.kdtree import KDTree
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
SOURCE = ROOT / 'Tools/output/lmgirl_tpose_studio/rig_mixamo41/lmgirl_local.glb'
|
||||
STATIC = ROOT / 'Tools/output/lmgirl_tpose_studio/static/LmGirlTpose.fbx'
|
||||
OUT = SOURCE.parent / 'fbx'
|
||||
FBX = OUT / 'LmGirlTokenRig.fbx'
|
||||
|
||||
|
||||
def sha(path):
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def points(mesh):
|
||||
return np.array([mesh.matrix_world @ v.co for v in mesh.data.vertices])
|
||||
|
||||
|
||||
def nearest_error(a, b):
|
||||
tree = KDTree(len(b))
|
||||
for i, p in enumerate(b):
|
||||
tree.insert(Vector(p), i)
|
||||
tree.balance()
|
||||
return max(tree.find(Vector(p))[2] for p in a)
|
||||
|
||||
|
||||
def snapshot(arm, mesh):
|
||||
mesh.data.calc_loop_triangles()
|
||||
return {
|
||||
'points': points(mesh),
|
||||
'uv': np.array([list(d.uv) for d in mesh.data.uv_layers.active.data]),
|
||||
'triangles': len(mesh.data.loop_triangles),
|
||||
'bones': {b.name: {'parent': b.parent.name if b.parent else None,
|
||||
'matrix': np.array(arm.matrix_world @ b.matrix_local)}
|
||||
for b in arm.data.bones},
|
||||
'weights': [{mesh.vertex_groups[g.group].name: g.weight for g in v.groups if g.weight > 1e-8}
|
||||
for v in mesh.data.vertices],
|
||||
}
|
||||
|
||||
|
||||
def make_material(mesh):
|
||||
mat = bpy.data.materials.new('LmGirlTokenRig_Body')
|
||||
mat.use_nodes = True
|
||||
bsdf = mat.node_tree.nodes.get('Principled BSDF')
|
||||
images = {}
|
||||
for suffix, socket in [('color', 'Base Color'), ('roughness', 'Roughness'), ('metallic', 'Metallic'), ('normal', None)]:
|
||||
path = STATIC.parent / ('LmGirlTpose_' + suffix + '.png')
|
||||
image = bpy.data.images.load(str(path), check_existing=True)
|
||||
if suffix != 'color':
|
||||
image.colorspace_settings.name = 'Non-Color'
|
||||
node = mat.node_tree.nodes.new('ShaderNodeTexImage')
|
||||
node.image = image
|
||||
if suffix == 'normal':
|
||||
normal = mat.node_tree.nodes.new('ShaderNodeNormalMap')
|
||||
mat.node_tree.links.new(node.outputs['Color'], normal.inputs['Color'])
|
||||
mat.node_tree.links.new(normal.outputs['Normal'], bsdf.inputs['Normal'])
|
||||
else:
|
||||
mat.node_tree.links.new(node.outputs['Color'], bsdf.inputs[socket])
|
||||
images[suffix] = str(path.relative_to(ROOT))
|
||||
mesh.data.materials.clear()
|
||||
mesh.data.materials.append(mat)
|
||||
return images
|
||||
|
||||
|
||||
def main():
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.ops.import_scene.gltf(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')
|
||||
arm.animation_data_clear()
|
||||
assert len(arm.data.bones) == 41
|
||||
original = snapshot(arm, mesh)
|
||||
print('SOURCE', mesh.name, len(original['points']), original['triangles'],
|
||||
original['points'].min(0), original['points'].max(0), 'ARM_MATRIX', arm.matrix_world)
|
||||
|
||||
# The current static FBX has independently reviewed anatomical +Z front.
|
||||
# Match its complete imported geometry rather than infer direction from metadata.
|
||||
existing = set(bpy.context.scene.objects)
|
||||
bpy.ops.import_scene.fbx(filepath=str(STATIC))
|
||||
reference = next(o for o in bpy.context.scene.objects if o not in existing and o.type == 'MESH')
|
||||
ref = points(reference)
|
||||
candidates = []
|
||||
for degrees in [0, 180]:
|
||||
rotate = Matrix.Rotation(math.radians(degrees), 4, 'Z')
|
||||
candidate = np.array([rotate @ Vector(p) for p in original['points']])
|
||||
candidates.append((nearest_error(candidate, ref), degrees, rotate))
|
||||
error, degrees, conversion = min(candidates, key=lambda item: item[0])
|
||||
if error > 1e-5:
|
||||
raise RuntimeError('Cannot match reviewed static geometry: ' + str([(e, d) for e, d, _ in candidates]))
|
||||
for obj in list(bpy.context.scene.objects):
|
||||
if obj not in existing:
|
||||
bpy.data.objects.remove(obj, do_unlink=True)
|
||||
converted = np.array([conversion @ Vector(p) for p in original['points']])
|
||||
lo, hi = converted.min(0), converted.max(0)
|
||||
shift = Vector((-(lo[0]+hi[0])/2, -(lo[1]+hi[1])/2, -lo[2]))
|
||||
conversion = Matrix.Translation(shift) @ conversion
|
||||
matrices = {o: conversion @ o.matrix_world for o in [arm, mesh]}
|
||||
mesh.parent = None
|
||||
for obj, matrix in matrices.items():
|
||||
obj.matrix_world = matrix
|
||||
for obj in [arm, mesh]:
|
||||
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)
|
||||
arm.name = 'LmGirlTokenRig'
|
||||
mesh.name = 'LmGirlTokenRigMesh'
|
||||
texture_paths = make_material(mesh)
|
||||
bpy.context.view_layer.update()
|
||||
expected = snapshot(arm, mesh)
|
||||
assert max(abs(sum(row.values())-1) for row in expected['weights']) < 1e-5
|
||||
assert max(len(row) for row in expected['weights']) <= 4
|
||||
bpy.ops.object.select_all(action='DESELECT')
|
||||
for obj in [arm, mesh]:
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = arm
|
||||
bpy.ops.export_scene.fbx(
|
||||
filepath=str(FBX), use_selection=True, object_types={'ARMATURE', 'MESH'},
|
||||
axis_forward='-Z', axis_up='Y', global_scale=1,
|
||||
apply_unit_scale=True, apply_scale_options='FBX_SCALE_ALL',
|
||||
bake_space_transform=False, add_leaf_bones=False, use_armature_deform_only=False,
|
||||
armature_nodetype='NULL', bake_anim=False, mesh_smooth_type='OFF',
|
||||
use_mesh_modifiers=False, path_mode='COPY', embed_textures=True)
|
||||
|
||||
# Reimport through Blender's FBX importer, independently of the GLB importer.
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.ops.import_scene.fbx(filepath=str(FBX))
|
||||
actual_arm = next(o for o in bpy.context.scene.objects if o.type == 'ARMATURE')
|
||||
actual_mesh = next(o for o in bpy.context.scene.objects if o.type == 'MESH')
|
||||
actual = snapshot(actual_arm, actual_mesh)
|
||||
assert actual['triangles'] == original['triangles'] == 4999
|
||||
assert set(actual['bones']) == set(expected['bones'])
|
||||
assert all(actual['bones'][n]['parent'] == expected['bones'][n]['parent'] for n in actual['bones'])
|
||||
# FBX may split vertices at corners. Match geometry+UV+weights using nearest positions.
|
||||
tree = KDTree(len(expected['points']))
|
||||
for i, p in enumerate(expected['points']):
|
||||
tree.insert(Vector(p), i)
|
||||
tree.balance()
|
||||
max_geometry_error = 0.
|
||||
max_weight_error = 0.
|
||||
mapping = []
|
||||
for p, row in zip(actual['points'], actual['weights']):
|
||||
_, index, distance = tree.find(Vector(p))
|
||||
mapping.append(index)
|
||||
max_geometry_error = max(max_geometry_error, distance)
|
||||
source_row = expected['weights'][index]
|
||||
max_weight_error = max(max_weight_error, max(abs(row.get(n, 0)-source_row.get(n, 0)) for n in set(row)|set(source_row)))
|
||||
max_bone_head_error = max(float(np.linalg.norm(actual['bones'][n]['matrix'][:3,3]-expected['bones'][n]['matrix'][:3,3])) for n in actual['bones'])
|
||||
# UV distributions are independent of corner ordering. Quantization allows FBX float rounding.
|
||||
uv_reference = sorted(map(tuple, np.round(expected['uv'], 5)))
|
||||
uv_roundtrip = sorted(map(tuple, np.round(actual['uv'], 5)))
|
||||
uv_preserved = uv_reference == uv_roundtrip
|
||||
assert max_geometry_error < 1e-5, max_geometry_error
|
||||
assert max_weight_error < 1e-5, max_weight_error
|
||||
assert max_bone_head_error < 1e-5, max_bone_head_error
|
||||
assert uv_preserved
|
||||
lo, hi = actual['points'].min(0), actual['points'].max(0)
|
||||
report = {
|
||||
'source': str(SOURCE.relative_to(ROOT)), 'source_sha256': sha(SOURCE),
|
||||
'fbx': str(FBX.relative_to(ROOT)), 'fbx_sha256': sha(FBX),
|
||||
'variant': 'TokenRig local constraints, 41 bones, top4; no website weights',
|
||||
'sceneUp': '+Y', 'anatomicalFront': '+Z', 'unit': 'meter',
|
||||
'conversionAboutBlenderZDegrees': degrees, 'originShiftBlender': list(shift),
|
||||
'geometryMatchToReviewedStaticM': error, 'textures': texture_paths,
|
||||
'sourceVertices': len(original['points']), 'roundtripVertices': len(actual['points']),
|
||||
'triangles': actual['triangles'], 'bones': len(actual['bones']),
|
||||
'maxInfluences': max(len(row) for row in actual['weights']),
|
||||
'roundtripGeometryErrorM': max_geometry_error, 'roundtripWeightError': max_weight_error,
|
||||
'roundtripBoneHeadErrorM': max_bone_head_error, 'uvPreserved': uv_preserved,
|
||||
'roundtripBoundsBlender': [lo.tolist(), hi.tolist()],
|
||||
'animationIncluded': False,
|
||||
'notes': 'Blender workspace is Z-up. Standard skinned FBX axis conversion is retained on the armature node; verify full imported transforms, not raw mesh coordinates alone.'}
|
||||
(OUT/'export_verification.json').write_text(json.dumps(report, indent=2), encoding='utf-8')
|
||||
print('EXPORTED', json.dumps(report))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -11,6 +11,8 @@ from io_scene_fbx import parse_fbx
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[2]
|
||||
SOURCE=ROOT/'Tools/output/lmgirl_tpose_studio/static/LmGirlTpose.fbx'
|
||||
LEGACY=ROOT/'Tools/output/lmgirl_tpose_studio/axis_pipeline_audit/legacy/LmGirlTpose.fbx'
|
||||
if LEGACY.is_file():SOURCE=LEGACY
|
||||
OUT=ROOT/'Tools/output/lmgirl_tpose_studio/mixamo_upload'
|
||||
TARGET=OUT/'LmGirlTpose_Mixamo.fbx'
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Read-only comparison of static and Mixamo FBX UVs/materials/textures."""
|
||||
import json,hashlib
|
||||
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/material_audit'
|
||||
OUT.mkdir(parents=True,exist_ok=True)
|
||||
def inspect(path):
|
||||
tree,_=parse_fbx.parse(str(path))
|
||||
objects=next(e for e in tree.elems if e.id==b'Objects')
|
||||
raw=[]
|
||||
for e in objects.elems:
|
||||
if e.id not in (b'Material',b'Texture',b'Video',b'Geometry'):continue
|
||||
item=dict(type=e.id.decode(),name=str(e.props[1]))
|
||||
if e.id==b'Geometry':item['uv_layers']=[dict(name=str(x.id),children=[str(v.id) for v in x.elems]) for x in e.elems if x.id==b'LayerElementUV']
|
||||
if e.id in (b'Texture',b'Video'):item['files']=[str(x.props) for x in e.elems if x.id in (b'FileName',b'RelativeFilename',b'Filename')];item['embedded_bytes']=sum(len(x.props[0]) for x in e.elems if x.id==b'Content' and x.props and isinstance(x.props[0],bytes))
|
||||
raw.append(item)
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.ops.import_scene.fbx(filepath=str(path),use_anim=False)
|
||||
meshes=[];uv_values=[]
|
||||
for obj in bpy.context.scene.objects:
|
||||
if obj.type!='MESH':continue
|
||||
layers=[]
|
||||
for layer in obj.data.uv_layers:
|
||||
values=np.array([tuple(v.uv) for v in layer.data],dtype='<f4')
|
||||
if layer==obj.data.uv_layers.active:uv_values.extend(values.tolist())
|
||||
layers.append(dict(name=layer.name,corners=len(values),min=values.min(0).tolist(),max=values.max(0).tolist(),unique=len(np.unique(values,axis=0)),sha256=hashlib.sha256(values.tobytes()).hexdigest()))
|
||||
meshes.append(dict(name=obj.name,vertices=len(obj.data.vertices),triangles=sum(len(p.vertices)-2 for p in obj.data.polygons),uv=layers,
|
||||
material_slots=[dict(name=s.material.name if s.material else None,images=[dict(name=n.image.name,path=n.image.filepath) for n in s.material.node_tree.nodes if n.type=='TEX_IMAGE' and n.image] if s.material and s.material.use_nodes else []) for s in obj.material_slots]))
|
||||
return dict(path=str(path.relative_to(ROOT)),sha256=hashlib.sha256(path.read_bytes()).hexdigest(),raw=raw,meshes=meshes),np.array(sorted(tuple(np.round(v,6)) for v in uv_values))
|
||||
|
||||
source,source_uv=inspect(ROOT/'Tools/output/lmgirl_tpose_studio/static/LmGirlTpose.fbx')
|
||||
mixamo,mixamo_uv=inspect(ROOT/'AIClient/Assets/Bundles/Characters/Human/test/girl/LmGirlTpose.fbx')
|
||||
report=dict(source=source,mixamo=mixamo,uv_corner_multiset_equal=source_uv.shape==mixamo_uv.shape and bool(np.array_equal(source_uv,mixamo_uv)))
|
||||
(OUT/'fbx_materials.json').write_text(json.dumps(report,indent=2),encoding='utf-8')
|
||||
print(json.dumps(report,indent=2))
|
||||
@@ -0,0 +1,373 @@
|
||||
"""LittleBear fixed Mixamo-41 fitting, TokenRig weight restoration and FBX export.
|
||||
|
||||
Blender: --python this_file -- inspect|prepare|restore|render
|
||||
The fitted skeleton is frozen before inference. No reference weights are used.
|
||||
"""
|
||||
import hashlib
|
||||
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
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
BASE = ROOT / "Tools/output/littlebear_studio"
|
||||
OUT = BASE / "rig_mixamo41"
|
||||
STATIC = BASE / "static/LittleBear.fbx"
|
||||
REFERENCE = ROOT / "AIClient/Assets/Bundles/Characters/Human/test/Boy_mixamo/CampGuideStudio.fbx"
|
||||
HEIGHT = 1.2
|
||||
|
||||
|
||||
def write(name, value):
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
(OUT / name).write_text(json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def points(mesh):
|
||||
return np.array([mesh.matrix_world @ v.co for v in mesh.data.vertices])
|
||||
|
||||
|
||||
def activate(obj):
|
||||
bpy.ops.object.select_all(action="DESELECT")
|
||||
obj.select_set(True)
|
||||
bpy.context.view_layer.objects.active = obj
|
||||
|
||||
|
||||
def load_static():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.ops.import_scene.fbx(filepath=str(STATIC), use_anim=False)
|
||||
mesh = next(o for o in bpy.context.scene.objects if o.type == "MESH")
|
||||
activate(mesh)
|
||||
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||||
p = points(mesh)
|
||||
if int(np.argmax(np.ptp(p, axis=0))) != 2:
|
||||
raise ValueError("Static FBX must reimport upright in Blender Z-up")
|
||||
mesh.scale = (HEIGHT / np.ptp(p[:, 2]),) * 3
|
||||
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||||
p = points(mesh)
|
||||
mesh.location -= Vector(((p[:, 0].min() + p[:, 0].max()) / 2,
|
||||
(p[:, 1].min() + p[:, 1].max()) / 2, p[:, 2].min()))
|
||||
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||||
return mesh
|
||||
|
||||
|
||||
def material(mesh):
|
||||
mat = bpy.data.materials.new("LittleBear_Body")
|
||||
mat.use_nodes = True
|
||||
bsdf = mat.node_tree.nodes.get("Principled BSDF")
|
||||
for suffix, socket in [("color", "Base Color"), ("roughness", "Roughness"), ("metallic", "Metallic"), ("normal", None)]:
|
||||
image = bpy.data.images.load(str(BASE / "static" / ("LittleBear_" + suffix + ".png")), check_existing=True)
|
||||
if suffix != "color":
|
||||
image.colorspace_settings.name = "Non-Color"
|
||||
node = mat.node_tree.nodes.new("ShaderNodeTexImage")
|
||||
node.image = image
|
||||
if socket:
|
||||
mat.node_tree.links.new(node.outputs["Color"], bsdf.inputs[socket])
|
||||
else:
|
||||
normal = mat.node_tree.nodes.new("ShaderNodeNormalMap")
|
||||
mat.node_tree.links.new(node.outputs["Color"], normal.inputs["Color"])
|
||||
mat.node_tree.links.new(normal.outputs["Normal"], bsdf.inputs["Normal"])
|
||||
if suffix == "color":
|
||||
mat.node_tree.nodes.active = node
|
||||
mesh.data.materials.clear()
|
||||
mesh.data.materials.append(mat)
|
||||
|
||||
|
||||
def render(mesh, tag, assign_material=True):
|
||||
if assign_material:
|
||||
material(mesh)
|
||||
p = points(mesh)
|
||||
center = Vector(((p[:, 0].min() + p[:, 0].max()) / 2,
|
||||
(p[:, 1].min() + p[:, 1].max()) / 2,
|
||||
(p[:, 2].min() + p[:, 2].max()) / 2))
|
||||
scene = bpy.context.scene
|
||||
scene.render.engine = "BLENDER_WORKBENCH"
|
||||
scene.render.resolution_x = 900
|
||||
scene.render.resolution_y = 1000
|
||||
scene.render.resolution_percentage = 100
|
||||
scene.render.image_settings.file_format = "PNG"
|
||||
shading = scene.display.shading
|
||||
shading.light = "STUDIO"
|
||||
shading.color_type = "TEXTURE"
|
||||
shading.show_shadows = True
|
||||
shading.show_cavity = True
|
||||
shading.background_type = "WORLD"
|
||||
scene.world = bpy.data.worlds.new("BearReviewWorld")
|
||||
scene.world.color = (.78, .78, .78)
|
||||
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 = max(HEIGHT * 1.15, np.ptp(p[:, 0]) * 1000 / 900 * 1.1)
|
||||
for label, offset in [("front", (0, -3, 0)), ("back", (0, 3, 0)), ("three_quarter", (2, -3, .4))]:
|
||||
camera.location = center + Vector(offset)
|
||||
camera.rotation_euler = (center - camera.location).to_track_quat("-Z", "Y").to_euler()
|
||||
scene.render.filepath = str(OUT / (tag + "_" + label + ".png"))
|
||||
bpy.ops.render.render(write_still=True)
|
||||
|
||||
|
||||
def inspect():
|
||||
mesh = load_static()
|
||||
p = points(mesh)
|
||||
bands = []
|
||||
for z in np.linspace(.02, .98, 49):
|
||||
a = p[abs(p[:, 2] / HEIGHT - z) < .012]
|
||||
if len(a):
|
||||
bands.append(dict(z=float(z), count=len(a), min=a.min(0).tolist(), max=a.max(0).tolist()))
|
||||
write("mesh_inspection.json", dict(bounds=[p.min(0).tolist(), p.max(0).tolist()], bands=bands))
|
||||
render(mesh, "static")
|
||||
|
||||
|
||||
def inspect_high():
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.ops.import_scene.gltf(filepath=str(ROOT / "Tools/output/littlebear_3d/littlebear.glb"))
|
||||
mesh = next(o for o in bpy.context.scene.objects if o.type == "MESH")
|
||||
activate(mesh)
|
||||
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||||
p = points(mesh)
|
||||
mesh.scale = (HEIGHT / np.ptp(p[:, 2]),) * 3
|
||||
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||||
for slot in mesh.material_slots:
|
||||
mat = slot.material
|
||||
if mat and mat.use_nodes:
|
||||
for node in mat.node_tree.nodes:
|
||||
if node.type == "TEX_IMAGE" and any(link.to_socket.name == "Base Color" for link in node.outputs["Color"].links):
|
||||
mat.node_tree.nodes.active = node
|
||||
break
|
||||
render(mesh, "high", assign_material=False)
|
||||
|
||||
|
||||
def prepare():
|
||||
mesh = load_static()
|
||||
p = points(mesh)
|
||||
before = set(bpy.data.objects)
|
||||
bpy.ops.import_scene.fbx(filepath=str(REFERENCE), use_anim=False)
|
||||
arm = next(o for o in bpy.context.scene.objects if o not in before and o.type == "ARMATURE")
|
||||
for o in list(bpy.data.objects):
|
||||
if o not in (mesh, arm):
|
||||
bpy.data.objects.remove(o, do_unlink=True)
|
||||
arm.animation_data_clear()
|
||||
d = arm.matrix_world @ arm.data.bones["mixamorig:Head"].head_local - arm.matrix_world @ arm.data.bones["mixamorig:Hips"].head_local
|
||||
if abs(d.y) > abs(d.z):
|
||||
arm.matrix_world = Matrix.Rotation(math.pi / 2 if d.y > 0 else -math.pi / 2, 4, "X") @ arm.matrix_world
|
||||
activate(arm)
|
||||
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||||
toe = arm.data.bones["mixamorig:LeftToeBase"].head_local - arm.data.bones["mixamorig:LeftFoot"].head_local
|
||||
if toe.y > 0:
|
||||
arm.matrix_world = Matrix.Rotation(math.pi, 4, "Z") @ arm.matrix_world
|
||||
bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
|
||||
assert len(arm.data.bones) == 41
|
||||
# Fractions reflect this bear's inspected T-pose silhouette, not human landmarks.
|
||||
positions = {"Hips": (0, 0, .385), "Spine": (0, 0, .440), "Spine1": (0, 0, .510),
|
||||
"Spine2": (0, 0, .595), "Neck": (0, 0, .677), "Head": (0, 0, .723),
|
||||
"HeadTop_End": (0, 0, .952)}
|
||||
reach = max(abs(p[:, 0].min()), abs(p[:, 0].max())) / HEIGHT
|
||||
arm_z = float(np.median(p[(abs(p[:, 0]) > HEIGHT * .28) & (abs(p[:, 0]) < HEIGHT * .36), 2])) / HEIGHT
|
||||
if not .54 < arm_z < .76:
|
||||
raise ValueError("Inspect bear shoulder height before fitting: " + str(arm_z))
|
||||
for side, sign in [("Left", 1), ("Right", -1)]:
|
||||
local = {"Shoulder": (.064, 0, arm_z), "Arm": (.146, 0, arm_z),
|
||||
"ForeArm": (reach * .57, 0, arm_z), "Hand": (reach * .82, 0, arm_z),
|
||||
"UpLeg": (.079, 0, .383), "Leg": (.095, -.006, .213),
|
||||
"Foot": (.099, .004, .065), "ToeBase": (.099, -.070, .024),
|
||||
"Toe_End": (.099, -.105, .024)}
|
||||
for name, v in local.items():
|
||||
positions[side + name] = (v[0] * sign, v[1], v[2])
|
||||
old = {b.name: b.matrix_local.copy() for b in arm.data.bones}
|
||||
template_height = old["mixamorig:HeadTop_End"].translation.z - old["mixamorig:LeftToe_End"].translation.z
|
||||
for side in ["Left", "Right"]:
|
||||
hand = old["mixamorig:" + side + "Hand"].translation
|
||||
for name, m in old.items():
|
||||
short = name.split(":")[-1]
|
||||
if short.startswith(side + "Hand") and short != side + "Hand":
|
||||
positions[short] = (Vector(positions[side + "Hand"]) + (m.translation - hand) * (.75 / template_height))[:]
|
||||
bpy.ops.object.mode_set(mode="EDIT")
|
||||
for b in arm.data.edit_bones:
|
||||
b.use_connect = False
|
||||
for b in arm.data.edit_bones:
|
||||
delta, roll = b.tail - b.head, b.roll
|
||||
b.head = Vector(positions[b.name.split(":")[-1]]) * HEIGHT
|
||||
b.tail = b.head + delta
|
||||
b.roll = roll
|
||||
bpy.ops.object.mode_set(mode="OBJECT")
|
||||
bpy.context.view_layer.update()
|
||||
drift = max(old[b.name].to_quaternion().rotation_difference(b.matrix_local.to_quaternion()).angle for b in arm.data.bones)
|
||||
assert drift < .001, drift
|
||||
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(p))), 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")
|
||||
mesh.parent = arm
|
||||
mod = mesh.modifiers.new("FixedMixamoInput", "ARMATURE")
|
||||
mod.object = arm
|
||||
arm.data.pose_position = "REST"
|
||||
mesh.data.materials.clear()
|
||||
manifest = dict(character="LittleBear", height=HEIGHT, vertices=len(p),
|
||||
skeleton_source=str(REFERENCE.relative_to(ROOT)),
|
||||
input_skin="Non-anatomical keepalive placeholders only; all reference weights discarded",
|
||||
landmarks={n: list(v) for n, v in positions.items()},
|
||||
max_axis_rotation_drift_rad=drift,
|
||||
bones={b.name: dict(parent=b.parent.name if b.parent else None,
|
||||
head=list(arm.matrix_world @ b.head_local),
|
||||
world_matrix=[list(r) for r in arm.matrix_world @ b.matrix_local]) for b in arm.data.bones})
|
||||
write("input_manifest.json", manifest)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(OUT / "prepared.blend"), compress=True)
|
||||
activate(mesh)
|
||||
arm.select_set(True)
|
||||
bpy.ops.export_scene.gltf(filepath=str(OUT / "source.glb"), export_format="GLB", export_yup=True,
|
||||
use_selection=True, export_skins=True, export_animations=False, export_def_bones=False)
|
||||
print("PREPARED", len(arm.data.bones), len(p), "shoulder fraction", arm_z)
|
||||
|
||||
|
||||
def restore():
|
||||
bpy.ops.wm.open_mainfile(filepath=str(OUT / "prepared.blend"))
|
||||
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")
|
||||
arm.data.pose_position = "POSE"
|
||||
names = [b.name for b in arm.data.bones]
|
||||
p = points(mesh)
|
||||
decoded = json.loads((OUT / "decoded_weights.json").read_text())
|
||||
tree = KDTree(len(decoded))
|
||||
for i, row in enumerate(decoded):
|
||||
x, y, z = row["position_y_up"]
|
||||
tree.insert(Vector((x, -z, y)), i)
|
||||
tree.balance()
|
||||
weights = np.zeros((len(p), len(names)))
|
||||
distances = []
|
||||
for i, pos in enumerate(p):
|
||||
_, j, dist = tree.find(Vector(pos))
|
||||
distances.append(dist)
|
||||
for n, w in decoded[j]["weights"].items():
|
||||
weights[i, names.index(n)] += w
|
||||
assert max(distances) < 1e-5, max(distances)
|
||||
weights /= weights.sum(1, keepdims=True)
|
||||
raw = weights.copy()
|
||||
fit = json.loads((OUT / "input_manifest.json").read_text())
|
||||
marks = fit["landmarks"]
|
||||
wrist_x = abs(marks["LeftHand"][0]) * HEIGHT
|
||||
head_z = marks["Head"][2] * HEIGHT
|
||||
for i, pos in enumerate(p):
|
||||
side = "Left" if pos[0] > 0 else "Right"
|
||||
if pos[2] > head_z:
|
||||
allowed = ["mixamorig:Head"]
|
||||
elif abs(pos[0]) > wrist_x:
|
||||
allowed = ["mixamorig:" + side + "Hand"]
|
||||
elif abs(pos[0]) > .16 * HEIGHT and abs(pos[2] / HEIGHT - marks["LeftArm"][2]) < .085:
|
||||
allowed = ["mixamorig:" + side + part for part in ["Arm", "ForeArm", "Hand"]]
|
||||
elif pos[2] < .35 * HEIGHT:
|
||||
allowed = ["mixamorig:Hips"] + ["mixamorig:" + side + part for part in ["UpLeg", "Leg", "Foot", "ToeBase", "Toe_End"]]
|
||||
if pos[2] > .24 * HEIGHT and abs(pos[0]) < .06 * HEIGHT:
|
||||
allowed += ["mixamorig:LeftUpLeg", "mixamorig:RightUpLeg"]
|
||||
else:
|
||||
allowed = ["mixamorig:" + part for part in ["Hips", "Spine", "Spine1", "Spine2", "Neck", "Head"]]
|
||||
allowed += ["mixamorig:" + s + part for s in ["Left", "Right"] for part in ["Shoulder", "Arm", "UpLeg"]]
|
||||
keep = np.array([n in allowed for n in names])
|
||||
# Preserve mitten hand contribution at the wrist instead of tiny finger joints.
|
||||
if "mixamorig:" + side + "Hand" in allowed:
|
||||
hand = names.index("mixamorig:" + side + "Hand")
|
||||
weights[i, hand] = sum(weights[i, j] for j, n in enumerate(names) if n.startswith("mixamorig:" + side + "Hand"))
|
||||
weights[i, ~keep] = 0
|
||||
if weights[i].sum() < 1e-8:
|
||||
if len(allowed) == 1:
|
||||
weights[i, names.index(allowed[0])] = 1
|
||||
else:
|
||||
near = min(allowed, key=lambda n: (arm.data.bones[n].head_local - Vector(pos)).length)
|
||||
weights[i, names.index(near)] = 1
|
||||
weights[i] /= weights[i].sum()
|
||||
# The connected crotch must blend continuously across the left/right
|
||||
# boundary. Pin its center softly to Hips rather than switching legs.
|
||||
if .24 * HEIGHT < pos[2] < .40 * HEIGHT:
|
||||
t = min(abs(pos[0]) / (.055 * HEIGHT), 1.)
|
||||
vertical = min(max((pos[2] / HEIGHT - .24) / .09, 0.), 1.)
|
||||
center = (1 - t * t * (3 - 2 * t)) * vertical * vertical * (3 - 2 * vertical)
|
||||
weights[i] *= 1 - center
|
||||
weights[i, names.index("mixamorig:Hips")] += center
|
||||
ids = np.argsort(-weights[i])[4:]
|
||||
weights[i, ids] = 0
|
||||
weights[i, weights[i] < .005] = 0
|
||||
weights[i] /= weights[i].sum()
|
||||
mesh.vertex_groups.clear()
|
||||
for n in names:
|
||||
mesh.vertex_groups.new(name=n)
|
||||
for i, row in enumerate(weights):
|
||||
for j in np.flatnonzero(row):
|
||||
mesh.vertex_groups[names[j]].add([i], float(row[j]), "REPLACE")
|
||||
for b in arm.data.bones:
|
||||
assert (b.parent.name if b.parent else None) == fit["bones"][b.name]["parent"]
|
||||
expected = np.array(fit["bones"][b.name]["world_matrix"])
|
||||
assert np.max(abs(np.array(arm.matrix_world @ b.matrix_local) - expected)) < 1e-6
|
||||
material(mesh)
|
||||
bpy.ops.wm.save_as_mainfile(filepath=str(OUT / "LittleBear_Rigged.blend"), compress=True)
|
||||
activate(mesh)
|
||||
arm.select_set(True)
|
||||
bpy.ops.export_scene.gltf(filepath=str(OUT / "LittleBear_Rigged.glb"), export_format="GLB", export_yup=True,
|
||||
use_selection=True, export_skins=True, export_animations=False, export_def_bones=False)
|
||||
target = OUT / "LittleBear_Rigged.fbx"
|
||||
bpy.ops.export_scene.fbx(filepath=str(target), use_selection=True, object_types={"ARMATURE", "MESH"},
|
||||
axis_forward="-Z", axis_up="Y", global_scale=1, apply_unit_scale=True,
|
||||
apply_scale_options="FBX_SCALE_ALL", bake_space_transform=False, add_leaf_bones=False,
|
||||
use_armature_deform_only=False, armature_nodetype="NULL", bake_anim=False,
|
||||
mesh_smooth_type="OFF", use_mesh_modifiers=False, path_mode="COPY", embed_textures=True)
|
||||
write("skin_receipt.json", dict(bones=len(names), vertices=len(p),
|
||||
triangles=sum(len(poly.vertices) - 2 for poly in mesh.data.polygons), height=HEIGHT,
|
||||
maxInfluences=int(np.count_nonzero(weights, axis=1).max()),
|
||||
localCleanupChangedVertices=int(np.any(abs(weights - raw) > .001, axis=1).sum()),
|
||||
weightsSource="TokenRig prediction, supplied fixed Mixamo skeleton; no website weights",
|
||||
maxPredictionMappingDistanceM=max(distances),
|
||||
fbxSha256=hashlib.sha256(target.read_bytes()).hexdigest()))
|
||||
render(mesh, "rigged_rest")
|
||||
print("RIGGED_EXPORT", target)
|
||||
|
||||
|
||||
def verify_export():
|
||||
def snapshot():
|
||||
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")
|
||||
return dict(points=points(mesh),
|
||||
bones={b.name: dict(parent=b.parent.name if b.parent else None,
|
||||
head=list(arm.matrix_world @ b.head_local)) for b in arm.data.bones},
|
||||
weights=[{mesh.vertex_groups[g.group].name: g.weight for g in v.groups if g.weight > 1e-8} for v in mesh.data.vertices],
|
||||
uv=sorted(map(tuple, np.round(np.array([tuple(v.uv) for v in mesh.data.uv_layers.active.data]), 5))),
|
||||
triangles=sum(len(p.vertices) - 2 for p in mesh.data.polygons))
|
||||
bpy.ops.wm.open_mainfile(filepath=str(OUT / "LittleBear_Rigged.blend"))
|
||||
expected = snapshot()
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
bpy.ops.import_scene.fbx(filepath=str(OUT / "LittleBear_Rigged.fbx"), use_anim=False)
|
||||
actual = snapshot()
|
||||
tree = KDTree(len(expected["points"]))
|
||||
for i, p in enumerate(expected["points"]):
|
||||
tree.insert(Vector(p), i)
|
||||
tree.balance()
|
||||
position_error = weight_error = 0.
|
||||
for p, row in zip(actual["points"], actual["weights"]):
|
||||
_, i, distance = tree.find(Vector(p))
|
||||
position_error = max(position_error, distance)
|
||||
ref = expected["weights"][i]
|
||||
weight_error = max(weight_error, max(abs(row.get(n, 0) - ref.get(n, 0)) for n in set(row) | set(ref)))
|
||||
assert set(actual["bones"]) == set(expected["bones"])
|
||||
assert all(actual["bones"][n]["parent"] == expected["bones"][n]["parent"] for n in actual["bones"])
|
||||
bone_error = max(float(np.linalg.norm(np.array(actual["bones"][n]["head"]) - expected["bones"][n]["head"])) for n in actual["bones"])
|
||||
assert position_error < 1e-5 and weight_error < 1e-5 and bone_error < 1e-5
|
||||
assert actual["uv"] == expected["uv"]
|
||||
assert actual["triangles"] == expected["triangles"] == 3000
|
||||
assert max(len(row) for row in actual["weights"]) <= 4
|
||||
report = dict(bones=len(actual["bones"]), triangles=actual["triangles"],
|
||||
maxPositionErrorM=position_error, maxWeightError=weight_error, maxBoneHeadErrorM=bone_error,
|
||||
uvPreserved=True, parentHierarchyPreserved=True, sourceVertices=len(expected["points"]),
|
||||
roundtripVertices=len(actual["points"]), valid=True)
|
||||
write("export_verification.json", report)
|
||||
print("ROUNDTRIP_VERIFIED", json.dumps(report))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
action = sys.argv[sys.argv.index("--") + 1]
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
{"inspect": inspect, "inspect_high": inspect_high, "prepare": prepare, "restore": restore, "verify_export": verify_export}[action]()
|
||||
@@ -0,0 +1,42 @@
|
||||
"""Render both signs of the actual FBX Z axis to identify anatomical front."""
|
||||
import sys,json,math
|
||||
from pathlib import Path
|
||||
import bpy
|
||||
from mathutils import Vector
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[2]
|
||||
OUT=ROOT/'Tools/output/character_front_audit';OUT.mkdir(parents=True,exist_ok=True)
|
||||
for folder,name,kind in [('campguide_studio','CampGuideStudio','static'),('lmgirl_tpose_studio','LmGirlTpose','static'),
|
||||
('campguide_studio','CampGuideStudio','high'),('lmgirl_tpose_studio','LmGirlTpose','high')]:
|
||||
bpy.ops.wm.read_factory_settings(use_empty=True)
|
||||
base=ROOT/'Tools/output'/folder/'static'
|
||||
if kind=='static':bpy.ops.import_scene.fbx(filepath=str(base/(name+'.fbx')),use_anim=False)
|
||||
else:bpy.ops.import_scene.gltf(filepath=str(base.parent/'model.glb'))
|
||||
obj=next(o for o in bpy.context.scene.objects if o.type=='MESH')
|
||||
if kind=='static':
|
||||
material=bpy.data.materials.new('AuditTexture');material.use_nodes=True
|
||||
node=material.node_tree.nodes.new('ShaderNodeTexImage');node.image=bpy.data.images.load(str(base/(name+'_color.png')))
|
||||
material.node_tree.nodes.active=node
|
||||
material.node_tree.links.new(node.outputs['Color'],material.node_tree.nodes.get('Principled BSDF').inputs['Base Color'])
|
||||
obj.data.materials.clear();obj.data.materials.append(material)
|
||||
else:
|
||||
for slot in obj.material_slots:
|
||||
if slot.material and slot.material.use_nodes:
|
||||
for node in slot.material.node_tree.nodes:
|
||||
if node.type=='TEX_IMAGE' and any(link.to_socket.name=='Base Color' for link in node.outputs['Color'].links):
|
||||
slot.material.node_tree.nodes.active=node;break
|
||||
pts=[obj.matrix_world@v.co for v in obj.data.vertices];lo=Vector([min(v[i] for v in pts) for i in range(3)]);hi=Vector([max(v[i] for v in pts) for i in range(3)])
|
||||
h=hi.z-lo.z;center=(lo+hi)/2
|
||||
scene=bpy.context.scene;scene.render.engine='BLENDER_WORKBENCH'
|
||||
scene.render.resolution_x=512;scene.render.resolution_y=640;scene.render.resolution_percentage=100
|
||||
scene.render.image_settings.file_format='PNG';scene.world=bpy.data.worlds.new('AuditWorld');scene.world.color=(.7,.7,.7)
|
||||
shading=scene.display.shading;shading.light='STUDIO';shading.color_type='TEXTURE';shading.show_shadows=True;shading.show_cavity=True;shading.background_type='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=h*1.2
|
||||
# A Y-up FBX imported into Blender is rotated +90 X: +Z becomes -Y.
|
||||
for sign,axis in [('plus_z',Vector((0,-1,0))),('minus_z',Vector((0,1,0)))]:
|
||||
camera.location=center+axis*h*3
|
||||
camera.rotation_euler=(center-camera.location).to_track_quat('-Z','Y').to_euler()
|
||||
scene.render.filepath=str(OUT/(name+('_high' if kind=='high' else '')+'_from_'+sign+'.png'))
|
||||
bpy.ops.render.render(write_still=True)
|
||||
print('FRONT_VIEW',scene.render.filepath)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Isolated A/B trial of the installed Studio baker; never edits the shared backend.
|
||||
|
||||
Blender -- --job DIR --output DIR --ray-mode legacy|adaptive
|
||||
Uses the same source geometry and Studio UVs, keeping baked/exported normals consistent.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument('--job', required=True)
|
||||
parser.add_argument('--output', required=True)
|
||||
parser.add_argument('--ray-mode', choices=['legacy', 'adaptive'], default='adaptive')
|
||||
args = parser.parse_args(sys.argv[sys.argv.index('--') + 1:])
|
||||
job, out = Path(args.job).resolve(), Path(args.output).resolve()
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
backend = Path('D:/UD/AI/XModel/LLM3DGraphics/backend/blender_lowpoly_bake.py')
|
||||
source = backend.read_text(encoding='utf-8')
|
||||
baseline_hash = hashlib.sha256(backend.read_bytes()).hexdigest()
|
||||
needle = 'outward_faces = bias_normals_outward(low)'
|
||||
assert source.count(needle) == 1
|
||||
source = source.replace(needle, 'outward_faces = 0 # Preserve the same tangent basis used by the normal bake')
|
||||
if args.ray_mode == 'adaptive':
|
||||
needle = 'ray = estimate_ray_distance(low)'
|
||||
assert source.count(needle) == 1
|
||||
source = source.replace(needle, '''
|
||||
# Trial: surface separation rather than a fixed 4% of the full bounding box.
|
||||
_bvh = BVHTree.FromObject(high, bpy.context.evaluated_depsgraph_get())
|
||||
_distances = []
|
||||
for _poly in low.data.polygons:
|
||||
_hit = _bvh.find_nearest(low.matrix_world @ _poly.center)
|
||||
if _hit[0] is not None:
|
||||
_distances.append(float(_hit[3]))
|
||||
_diag = float(low.dimensions.length)
|
||||
_p99 = float(np.percentile(_distances, 99))
|
||||
ray = min(max(_p99 * 1.5, _diag * .0005), _diag * .01)
|
||||
print('TRIAL_PROJECTION ' + json.dumps(dict(p99=_p99, bbox_diagonal=_diag,
|
||||
cage_extrusion=ray, legacy_cage_extrusion=_diag*.04)))
|
||||
''')
|
||||
sys.argv = [str(backend), '--', str(job/'model.glb'), str(out/'model_baked.glb'),
|
||||
'1', '1024', str(job/'model_lowpoly_uv.glb')]
|
||||
(out/'baker_snapshot.py').write_text(source, encoding='utf-8')
|
||||
(out/'trial_settings.json').write_text(json.dumps(dict(
|
||||
source_job=str(job), shared_backend_sha256=baseline_hash,
|
||||
ray_mode=args.ray_mode, post_bake_normal_bias=False,
|
||||
preserve_studio_uv=True, source_unchanged=True), indent=2), encoding='utf-8')
|
||||
exec(compile(source, str(out/'baker_snapshot.py'), 'exec'),
|
||||
{'__file__': str(backend), '__name__': '__main__'})
|
||||
@@ -0,0 +1,57 @@
|
||||
"""Verify a final static FBX against its Studio GLB, using full scene transforms."""
|
||||
import sys,json,math,hashlib
|
||||
from pathlib import Path
|
||||
import bpy
|
||||
import numpy as np
|
||||
from mathutils import Matrix
|
||||
|
||||
sys.path.insert(0,str(Path(__file__).resolve().parent))
|
||||
from bl_decimate import prepare_game_export,validate_fbx_yup_scene
|
||||
|
||||
folder=Path(sys.argv[sys.argv.index('--')+1]).resolve()
|
||||
name=sys.argv[sys.argv.index('--')+2]
|
||||
source=folder/'model_ex.glb';target=folder/'static'/(name+'.fbx')
|
||||
out=folder/'axis_pipeline_audit';out.mkdir(parents=True,exist_ok=True)
|
||||
|
||||
def load(path):
|
||||
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)
|
||||
meshes=[o for o in bpy.context.scene.objects if o.type=='MESH']
|
||||
assert len(meshes)==1
|
||||
return meshes[0]
|
||||
|
||||
def uv(obj):return np.array([tuple(v.uv) for v in obj.data.uv_layers.active.data],dtype='<f4')
|
||||
def triangles(obj):return sum(len(p.vertices)-2 for p in obj.data.polygons)
|
||||
|
||||
obj=load(source);prepare_game_export(obj,restore_axis=True)
|
||||
expected=np.array([Matrix.Rotation(math.pi/2,4,'X')@obj.matrix_world@v.co for v in obj.data.vertices])
|
||||
source_uv=uv(obj);source_triangles=triangles(obj)
|
||||
axes=validate_fbx_yup_scene(str(target))
|
||||
obj=load(target);actual=np.array([obj.matrix_world@v.co for v in obj.data.vertices]);target_uv=uv(obj)
|
||||
assert actual.shape==expected.shape and triangles(obj)==source_triangles
|
||||
error=float(np.max(np.abs(actual-expected)));assert error<2e-6
|
||||
assert np.array_equal(target_uv,source_uv)
|
||||
lo=actual.min(0);hi=actual.max(0)
|
||||
assert np.argmax(hi-lo)==2,'The complete scene is horizontal in Blender Z-up'
|
||||
origin_error=max(abs(lo[2]),abs((lo[0]+hi[0])/2),abs((lo[1]+hi[1])/2));assert origin_error<2e-6
|
||||
legacy=out/'legacy'/(name+'.fbx')
|
||||
legacy_rejected=None
|
||||
if legacy.is_file():
|
||||
try:validate_fbx_yup_scene(str(legacy));legacy_rejected=False
|
||||
except ValueError:legacy_rejected=True
|
||||
report=dict(source=str(source),fbx=str(target),sha256=hashlib.sha256(target.read_bytes()).hexdigest(),
|
||||
axes=axes,vertices=len(actual),triangles=source_triangles,uv_preserved=True,
|
||||
uv_sha256=hashlib.sha256(target_uv.tobytes()).hexdigest(),upright=True,
|
||||
height_m=float(hi[2]-lo[2]),bottom_center_error_m=float(origin_error),
|
||||
max_position_error_m=error,legacy_rejected_by_axis_guard=legacy_rejected,
|
||||
blender_world_bounds_min=lo.tolist(),blender_world_bounds_max=hi.tolist())
|
||||
convention=json.loads((Path(__file__).resolve().parents[2]/'Config/Art/ModelConvention.json').read_text(encoding='utf-8'))
|
||||
receipt_path=folder/'mesh_receipt.json'
|
||||
receipt=json.loads(receipt_path.read_text(encoding='utf-8')) if receipt_path.is_file() else {}
|
||||
front=receipt.get('fbxAxes',{}).get('anatomicalFront') if receipt.get('fbxSha256')==report['sha256'] else None
|
||||
report.update(project_forward=convention['forwardAxis'],anatomical_front=front,
|
||||
project_front_conforms=(front==convention['forwardAxis']) if front else None,
|
||||
front_visual_check_required=front is None)
|
||||
(out/'verification.json').write_text(json.dumps(report,indent=2),encoding='utf-8')
|
||||
print('STATIC_VERIFICATION '+json.dumps(report))
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Compare Unity evaluation against raw FBX joint trajectories, pelvis-relative."""
|
||||
import json,math
|
||||
from pathlib import Path
|
||||
|
||||
ROOT=Path(__file__).resolve().parents[2]
|
||||
OUT=ROOT/'Tools/output/campguide_studio/playback_audit'
|
||||
reference=json.loads((OUT/'fbx_reference.json').read_text(encoding='utf-8'))[1]
|
||||
comparison=json.loads((OUT/'comparison.json').read_text(encoding='utf-8'))
|
||||
def sub(a,b):return [a[i]-b[i] for i in range(3)]
|
||||
def length(a):return math.sqrt(sum(v*v for v in a))
|
||||
def unity(p):return [-p[0],p[2],-p[1]]
|
||||
def angle(a,b):return math.degrees(math.acos(max(-1,min(1,sum(a[i]*b[i] for i in range(3))/(length(a)*length(b))))))
|
||||
report=[]
|
||||
for entry in comparison['samples']:
|
||||
maximum=0;feet_max=0;angles=[];gaps=[]
|
||||
for frame,pose in enumerate(entry['frames']):
|
||||
raw={name:unity(p) for name,p in reference['samples'][frame]['world'].items()}
|
||||
for name,p in pose.items():
|
||||
delta=sub(sub(p,pose['mixamorig:Hips']),sub(raw[name],raw['mixamorig:Hips']))
|
||||
error=length(delta);maximum=max(maximum,error)
|
||||
if name.endswith('Foot'):feet_max=max(feet_max,error)
|
||||
for side in ('Left','Right'):
|
||||
ankle='mixamorig:'+side+'Foot';toe='mixamorig:'+side+'ToeBase'
|
||||
angles.append(angle(sub(pose[toe],pose[ankle]),sub(raw[toe],raw[ankle])))
|
||||
gaps.append(pose['mixamorig:RightFoot'][0]-pose['mixamorig:LeftFoot'][0])
|
||||
report.append(dict(variant=entry['tag'],max_joint_error_m=maximum,max_ankle_error_m=feet_max,
|
||||
max_foot_direction_error_degrees=max(angles),min_signed_ankle_gap_m=min(gaps),crossed_frames=sum(g<0 for g in gaps)))
|
||||
print(json.dumps(report,indent=2))
|
||||
assert report[0]['max_joint_error_m']<.001,'Generic reference does not match the original FBX'
|
||||
(OUT/'summary.json').write_text(json.dumps(report,indent=2),encoding='utf-8')
|
||||
@@ -12,6 +12,8 @@ from bl_decimate import prepare_game_export,export_fbx_source_yup,validate_fbx_y
|
||||
|
||||
OUT=ROOT/'Tools/output/lmgirl_tpose_studio/axis_pipeline_audit/regression'
|
||||
OUT.mkdir(parents=True,exist_ok=True)
|
||||
LEGACY=OUT.parent/'legacy/LmGirlTpose.fbx'
|
||||
if not LEGACY.is_file():LEGACY=ROOT/'Tools/output/lmgirl_tpose_studio/static/LmGirlTpose.fbx'
|
||||
|
||||
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])
|
||||
@@ -66,7 +68,7 @@ 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)
|
||||
_,r=run('girl_from_legacy',LEGACY,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)
|
||||
@@ -75,7 +77,7 @@ 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'))
|
||||
try:validate_fbx_yup_scene(str(LEGACY))
|
||||
except ValueError:pass
|
||||
else:raise AssertionError('Legacy rotated FBX passed the export guard')
|
||||
results.append(dict(case='reject_legacy_rotation',passed=True))
|
||||
|
||||
Reference in New Issue
Block a user