Add rank, friends, fishing, and camp treasure with T2 studio models and skill presentation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
udx
2026-10-21 23:01:31 +08:00
co-authored by Cursor
parent 89e0fa246f
commit 079313fb06
677 changed files with 395371 additions and 9692 deletions
+19
View File
@@ -0,0 +1,19 @@
"""Align Studio's centred/normalised mesh to its exact source high, retaining UVs."""
import bpy,sys,json,hashlib
import numpy as np
from pathlib import Path
from mathutils import Matrix,Vector
folder=Path(sys.argv[sys.argv.index('--')+1]).resolve()
bpy.ops.wm.read_factory_settings(use_empty=True)
def load(path):
before=set(bpy.data.objects);bpy.ops.import_scene.gltf(filepath=str(path),merge_vertices=False);objects=[o for o in bpy.data.objects if o not in before and o.type=='MESH'];assert len(objects)==1;return objects[0]
def bounds(obj):
points=[obj.matrix_world@v.co for v in obj.data.vertices];lo=Vector([min(p[i] for p in points) for i in range(3)]);hi=Vector([max(p[i] for p in points) for i in range(3)]);return lo,hi
def uvhash(obj):return hashlib.sha256(np.array([tuple(v.uv) for v in obj.data.uv_layers.active.data],dtype='<f4').tobytes()).hexdigest()
high=load(folder/'model.glb');low=load(folder/'model_lowpoly_uv.glb');hlo,hhi=bounds(high);llo,lhi=bounds(low);scale=(hhi.z-hlo.z)/(lhi.z-llo.z);hc=Vector(((hlo.x+hhi.x)/2,(hlo.y+hhi.y)/2,hlo.z));lc=Vector(((llo.x+lhi.x)/2,(llo.y+lhi.y)/2,llo.z))
before=uvhash(low);low.matrix_world=Matrix.Translation(hc)@Matrix.Scale(scale,4)@Matrix.Translation(-lc)@low.matrix_world
bpy.ops.object.select_all(action='DESELECT');low.select_set(True);bpy.context.view_layer.objects.active=low;bpy.ops.object.transform_apply(location=True,rotation=True,scale=True);assert uvhash(low)==before
bpy.ops.export_scene.gltf(filepath=str(folder/'model_lowpoly_uv_aligned.glb'),export_format='GLB',use_selection=True,export_materials='NONE')
report=dict(scale=scale,highMin=list(hlo),highMax=list(hhi),studioMin=list(llo),studioMax=list(lhi),matrix=[list(row) for row in Matrix.Translation(hc)@Matrix.Scale(scale,4)@Matrix.Translation(-lc)],uvHashBefore=before,uvHashAfter=uvhash(low),uvUnchanged=True)
(folder/'bake_alignment.json').write_text(json.dumps(report,indent=2),encoding='utf-8');print('MT_SUMMARY '+json.dumps(report))
@@ -0,0 +1,23 @@
"""Read-only stage geometry/UV audit and native Blender render."""
import bpy,sys,json
from pathlib import Path
from mathutils import Vector
from mathutils.bvhtree import BVHTree
folder=Path(sys.argv[sys.argv.index('--')+1]).resolve()
reports=[]
for name in ('model.glb','model_lowpoly_uv.glb','model_baked.glb','model_ex.glb'):
bpy.ops.wm.read_factory_settings(use_empty=True);bpy.ops.import_scene.gltf(filepath=str(folder/name))
objects=[o for o in bpy.context.scene.objects if o.type=='MESH']
vertices=[o.matrix_world@v.co for o in objects for v in o.data.vertices]
minimum=Vector([min(v[i] for v in vertices) for i in range(3)]);maximum=Vector([max(v[i] for v in vertices) for i in range(3)])
reports.append(dict(name=name,minimum=list(minimum),maximum=list(maximum),uvLayers=[[(u.name,len(u.data)) for u in o.data.uv_layers] for o in objects],materials=[s.material.name for o in objects for s in o.material_slots if s.material]))
if name not in ('model.glb','model_ex.glb'):continue
scene=bpy.context.scene;scene.render.engine='CYCLES';scene.cycles.samples=16;scene.render.resolution_x=640;scene.render.resolution_y=640;scene.render.resolution_percentage=100
world=bpy.data.worlds.new('AuditWorld');world.use_nodes=True;world.node_tree.nodes['Background'].inputs[0].default_value=(.45,.45,.45,1);world.node_tree.nodes['Background'].inputs[1].default_value=.7;scene.world=world
center=(minimum+maximum)/2;extent=maximum.z-minimum.z
camera=bpy.data.objects.new('AuditCamera',bpy.data.cameras.new('AuditCamera'));scene.collection.objects.link(camera);camera.location=center+Vector((.2,-3,1))*extent;camera.rotation_euler=(center-camera.location).to_track_quat('-Z','Y').to_euler();camera.data.type='ORTHO';camera.data.ortho_scale=extent*1.5;scene.camera=camera
for location,power in [((2,-3,4),500),((-3,-1,2),300)]:
light=bpy.data.objects.new('AuditLight',bpy.data.lights.new('AuditLight','AREA'));scene.collection.objects.link(light);light.location=center+Vector(location)*extent;light.rotation_euler=(center-light.location).to_track_quat('-Z','Y').to_euler();light.data.energy=power;light.data.shape='DISK';light.data.size=extent*3
scene.render.filepath=str(folder/('audit_'+Path(name).stem+'.png'));bpy.ops.render.render(write_still=True)
(folder/'stage_geometry.json').write_text(json.dumps(reports,indent=2),encoding='utf-8')
+43
View File
@@ -0,0 +1,43 @@
"""Fit only offending new sole vertices by 1mm in game space; leave rig, UV and clips unchanged."""
import bpy,sys,json,hashlib
from pathlib import Path
from mathutils import Vector
from mathutils.kdtree import KDTree
import numpy as np
argv=sys.argv[sys.argv.index('--')+1:]
folder=Path(argv[0]).resolve()
name=argv[1] if len(argv)>1 else 'GreatHornStudio'
# GreatHorn's reviewed fit touched exactly two soles; other actors fit whatever their own constraints flag.
expected=int(argv[2]) if len(argv)>2 else (2 if name=='GreatHornStudio' else -1)
rigged=folder/(name+'_Rigged.fbx')
bpy.ops.wm.read_factory_settings(use_empty=True);bpy.ops.import_scene.fbx(filepath=str(rigged))
arm=next(o for o in bpy.context.scene.objects if o.type=='ARMATURE');low=next(o for o in bpy.context.scene.objects if o.type=='MESH');arm.animation_data_clear();arm.data.pose_position='REST';bpy.context.view_layer.update()
world=[low.matrix_world@v.co for v in low.data.vertices];lo=Vector([min(v[i] for v in world) for i in range(3)]);hi=Vector([max(v[i] for v in world) for i in range(3)]);center=Vector(((lo.x+hi.x)/2,(lo.y+hi.y)/2,lo.z));height=hi.z-lo.z
data=json.loads((folder/'foot_constraints.json').read_text(encoding='utf-8'));frames=np.array(data['frames']);targets=[]
for j,v in enumerate(data['vertices']):
weights=np.array(v['weights'])
if weights.sum()>.95 and (frames[:,j,:]@weights).min()<-.003:targets.append(v)
assert expected<0 or len(targets)==expected,'Re-inspect a changed sole fit instead of applying stale edits'
normal=[(p-center)/height for p in world];candidates=[]
for sx in (-1,1):
for sz in (-1,1):
tree=KDTree(len(normal))
for i,p in enumerate(normal):tree.insert(Vector((sx*p.x,p.z,sz*p.y)),i)
tree.balance();errors=[tree.find(Vector(v['normalizedBind']))[2] for v in data['vertices']];candidates.append((max(errors),sx,sz,tree))
error,sx,sz,tree=min(candidates,key=lambda x:x[0]);assert error<.0001,('Unity/Blender bind coordinate mismatch',error)
uv=lambda:hashlib.sha256(np.array([tuple(v.uv) for v in low.data.uv_layers.active.data],dtype='<f4').tobytes()).hexdigest()
uv_before=uv();geometry_before=[v.co.copy() for v in low.data.vertices];bone_before={b.name:list(b.matrix_local) for b in arm.data.bones}
lift_game=.001;model_scale=data.get('modelScale',.758561)
delta=low.matrix_world.inverted().to_3x3()@Vector((0,0,lift_game/model_scale));edited=set()
for target in targets:
matches=tree.find_range(Vector(target['normalizedBind']),.00001);assert matches
for _,index,_ in matches:low.data.vertices[index].co+=delta;edited.add(index)
low.data.update();assert uv()==uv_before
for bone in arm.data.bones:assert all((a-b).length<1e-8 for a,b in zip(bone.matrix_local,bone_before[bone.name]))
arm.data.pose_position='POSE'
for bone in arm.pose.bones:bone.matrix_basis.identity()
bpy.ops.object.select_all(action='DESELECT');arm.select_set(True);low.select_set(True);bpy.context.view_layer.objects.active=arm
bpy.ops.export_scene.fbx(filepath=str(rigged),use_selection=True,object_types={'ARMATURE','MESH'},add_leaf_bones=False,bake_anim=False,armature_nodetype='NULL',axis_forward='-Z',axis_up='Y',apply_scale_options='FBX_SCALE_ALL',path_mode='COPY',embed_textures=True,mesh_smooth_type='FACE')
report=dict(method='local new sole fit to immutable original clips',gameSpaceLift=lift_game,blenderVertices=sorted(edited),unityVertices=[v['index'] for v in targets],coordinateMatchMax=error,uvUnchanged=uv()==uv_before,uvSha256=uv_before,bonesUnchanged=True,originalAnimationsUnchanged=True)
(folder/'sole_fit.json').write_text(json.dumps(report,indent=2),encoding='utf-8');print('MT_SUMMARY '+json.dumps(report))
@@ -0,0 +1,37 @@
"""Convert the completed Studio/bake mesh to project Y-up FBX without touching its UVs."""
import sys,json,shutil,hashlib
from pathlib import Path
import bpy
import numpy as np
sys.path.insert(0,str(Path(__file__).resolve().parent))
from bl_decimate import prepare_game_export,export_fbx_source_yup
argv=sys.argv[sys.argv.index('--')+1:]
folder=Path(argv[0]).resolve()
# Optional output actor name; GreatHornStudio keeps the original trial invocation working.
name=argv[1] if len(argv)>1 else 'GreatHornStudio'
out=folder/'static';out.mkdir(parents=True,exist_ok=True)
bpy.ops.wm.read_factory_settings(use_empty=True)
bpy.ops.import_scene.gltf(filepath=str(folder/'model_ex.glb'))
meshes=[o for o in bpy.context.scene.objects if o.type=='MESH']
assert len(meshes)==1,'Expected one final Studio mesh'
low=meshes[0]
def uv_hash():return hashlib.sha256(np.array([tuple(v.uv) for v in low.data.uv_layers.active.data],dtype='<f4').tobytes()).hexdigest()
before=uv_hash()
low.name=name
# Pure mesh plus conventionally named maps lets the project XPbr converter
# consume the existing five baked maps without generating another UV layout.
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')))
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))
(folder/'static_export.json').write_text(json.dumps(report,indent=2),encoding='utf-8')
print('MT_SUMMARY '+json.dumps(report))
+3
View File
@@ -218,8 +218,11 @@ def main():
for i in part:
geodesic[i]=min(geodesic[j]+(points[i]-points[j]).length for j in main)
target=low.vertex_groups[rule['bone']];affected=[]
# Optional normalized box: on a re-topologised mesh the same falloff can leak across the body.
within=rule.get('within')
for i,distance in enumerate(geodesic):
if distance>=fade:continue
if within and any(not within[0][c]<=points[i][c]<=within[1][c] for c in range(3)):continue
weight=1 if distance<=full else (fade-distance)/(fade-full)
weight=weight*weight*(3-2*weight)
previous={g.group:g.weight for g in low.data.vertices[i].groups}