@bitmagic/cli 0.1.66-dev.6 → 0.1.66-dev.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. package/README.md +37 -1
  2. package/assets/blender-level/find_blender.py +3 -1
  3. package/assets/character-skin-authoring/SKILL.md +43 -0
  4. package/assets/character-skin-authoring/references/exporters.md +134 -0
  5. package/assets/character-skin-authoring/references/eyes.md +215 -0
  6. package/assets/character-skin-authoring/references/materials.md +115 -0
  7. package/assets/character-skin-authoring/references/playground.md +78 -0
  8. package/assets/character-skin-authoring/references/profiles.md +97 -0
  9. package/assets/character-skin-authoring/references/projection.md +190 -0
  10. package/assets/character-skin-authoring/references/regional-detail.md +72 -0
  11. package/assets/character-skin-authoring/scripts/assemble_character.py +205 -0
  12. package/assets/character-skin-authoring/scripts/authored_detail.py +47 -0
  13. package/assets/character-skin-authoring/scripts/authored_surface_patch.py +83 -0
  14. package/assets/character-skin-authoring/scripts/brow_groom.py +62 -0
  15. package/assets/character-skin-authoring/scripts/build_face_detail.py +107 -0
  16. package/assets/character-skin-authoring/scripts/build_surface_candidate.py +28 -0
  17. package/assets/character-skin-authoring/scripts/canthus_wall.py +57 -0
  18. package/assets/character-skin-authoring/scripts/character_build.py +54 -0
  19. package/assets/character-skin-authoring/scripts/eye_assets.py +333 -0
  20. package/assets/character-skin-authoring/scripts/eye_opening.py +114 -0
  21. package/assets/character-skin-authoring/scripts/eye_texture.py +35 -0
  22. package/assets/character-skin-authoring/scripts/eyelid_detail.py +34 -0
  23. package/assets/character-skin-authoring/scripts/eyelid_morph.py +171 -0
  24. package/assets/character-skin-authoring/scripts/eyelid_patch.py +173 -0
  25. package/assets/character-skin-authoring/scripts/face_atlas.py +51 -0
  26. package/assets/character-skin-authoring/scripts/geometry_repair.py +112 -0
  27. package/assets/character-skin-authoring/scripts/glb_arrays.py +13 -0
  28. package/assets/character-skin-authoring/scripts/glb_output.py +21 -0
  29. package/assets/character-skin-authoring/scripts/inspect_skin_point.py +66 -0
  30. package/assets/character-skin-authoring/scripts/normal_patch_height.py +38 -0
  31. package/assets/character-skin-authoring/scripts/prepare_face_reference.py +23 -0
  32. package/assets/character-skin-authoring/scripts/regional_atlas.py +51 -0
  33. package/assets/character-skin-authoring/scripts/select_preview.py +90 -0
  34. package/assets/character-skin-authoring/scripts/skin_views.py +267 -0
  35. package/assets/character-skin-authoring/scripts/surface_pbr_maps.py +46 -0
  36. package/assets/character-skin-authoring/scripts/texture_repair.py +98 -0
  37. package/assets/character-skin-authoring/scripts/vertex_detail_support.py +80 -0
  38. package/assets/character-skin-authoring/scripts/vision_blend_materials.py +74 -0
  39. package/assets/character-skin-authoring/scripts/vision_materials.py +276 -0
  40. package/assets/character-skin-authoring/scripts/workspace_paths.py +18 -0
  41. package/assets/metahuman/src/work/metahuman/MetaHumanActor.ts +580 -0
  42. package/assets/metahuman/src/work/metahuman/MetaHumanLabHooks.ts +122 -0
  43. package/assets/metahuman/src/work/metahuman/MetaHumanMaterials.ts +500 -0
  44. package/assets/metahuman/src/work/metahuman/MetaHumanSpeech.ts +242 -0
  45. package/assets/metahuman/src/work/metahuman/StudioEnvironment.ts +142 -0
  46. package/assets/metahuman/tools/lipsync/make_line.py +181 -0
  47. package/assets/metahuman/tools/metahuman/README.md +21 -0
  48. package/assets/metahuman/tools/metahuman/e2e/eval.mjs +34 -0
  49. package/assets/metahuman/tools/metahuman/e2e/probes/arm-pose-check.js +32 -0
  50. package/assets/metahuman/tools/metahuman/e2e/probes/bone-displacement.js +26 -0
  51. package/assets/metahuman/tools/metahuman/e2e/probes/clip-shoulder.js +41 -0
  52. package/assets/metahuman/tools/metahuman/e2e/probes/grafted-bones.js +11 -0
  53. package/assets/metahuman/tools/metahuman/e2e/probes/helper-follow-descent.js +38 -0
  54. package/assets/metahuman/tools/metahuman/e2e/probes/helper-follow-sweep.js +31 -0
  55. package/assets/metahuman/tools/metahuman/e2e/probes/helper-joints-inspect.js +10 -0
  56. package/assets/metahuman/tools/metahuman/e2e/probes/outfit-bad-vertices.js +26 -0
  57. package/assets/metahuman/tools/metahuman/e2e/probes/outfit-body-material.js +7 -0
  58. package/assets/metahuman/tools/metahuman/e2e/probes/outfit-own-skeleton.js +8 -0
  59. package/assets/metahuman/tools/metahuman/e2e/probes/shirt-bone-ownership.js +25 -0
  60. package/assets/metahuman/tools/metahuman/e2e/probes/shirt-edge-stretch.js +26 -0
  61. package/assets/metahuman/tools/metahuman/e2e/probes/shirt-long-edges.js +23 -0
  62. package/assets/metahuman/tools/metahuman/e2e/probes/walk-sampler.js +105 -0
  63. package/assets/metahuman/tools/metahuman/e2e/shot.mjs +125 -0
  64. package/assets/metahuman/tools/metahuman/e2e/walk-probe.mjs +43 -0
  65. package/assets/metahuman/tools/metahuman/merge-skeletons.mjs +69 -0
  66. package/assets/metahuman/tools/metahuman/package.json +13 -0
  67. package/assets/metahuman/tools/metahuman/prepare.sh +25 -0
  68. package/assets/metahuman/tools/metahuman/reorder-body-first.mjs +20 -0
  69. package/assets/metahuman/tools/metahuman/smooth-outfit-weights.mjs +48 -0
  70. package/assets/metahuman/tools/metahuman/strip-draco.mjs +32 -0
  71. package/assets/metahuman/tools/metahuman/transfer-skin-weights.mjs +90 -0
  72. package/dist/assets/voxelize-glb.d.ts +5 -1
  73. package/dist/assets/voxelize-glb.js +1 -0
  74. package/dist/assets/voxelize-glb.js.map +1 -1
  75. package/dist/assets/vxl-summary.js +2 -2
  76. package/dist/commands/generate.d.ts +62 -0
  77. package/dist/commands/generate.js +241 -2
  78. package/dist/commands/generate.js.map +1 -1
  79. package/dist/commands/init.js +2 -1
  80. package/dist/commands/init.js.map +1 -1
  81. package/dist/commands/judge.js +5 -0
  82. package/dist/commands/judge.js.map +1 -1
  83. package/dist/commands/verify.js +7 -0
  84. package/dist/commands/verify.js.map +1 -1
  85. package/dist/generate/audio-file.d.ts +6 -0
  86. package/dist/generate/audio-file.js +42 -0
  87. package/dist/generate/audio-file.js.map +1 -0
  88. package/dist/generate/model.js +3 -3
  89. package/dist/generate/model.js.map +1 -1
  90. package/dist/generate/music.d.ts +4 -3
  91. package/dist/generate/music.js +6 -23
  92. package/dist/generate/music.js.map +1 -1
  93. package/dist/generate/prop.d.ts +19 -1
  94. package/dist/generate/prop.js +34 -6
  95. package/dist/generate/prop.js.map +1 -1
  96. package/dist/generate/stream.d.ts +20 -0
  97. package/dist/generate/stream.js +15 -1
  98. package/dist/generate/stream.js.map +1 -1
  99. package/dist/scaffold/asset-pipeline-skill-files.js +6 -1
  100. package/dist/scaffold/asset-pipeline-skill-files.js.map +1 -1
  101. package/dist/scaffold/character-skin-skill-files.d.ts +2 -0
  102. package/dist/scaffold/character-skin-skill-files.js +49 -0
  103. package/dist/scaffold/character-skin-skill-files.js.map +1 -0
  104. package/dist/scaffold/metahuman-skill-files.d.ts +21 -0
  105. package/dist/scaffold/metahuman-skill-files.js +306 -0
  106. package/dist/scaffold/metahuman-skill-files.js.map +1 -0
  107. package/dist/scaffold/project-files.js +28 -19
  108. package/dist/scaffold/project-files.js.map +1 -1
  109. package/dist/scaffold/project.js +4 -0
  110. package/dist/scaffold/project.js.map +1 -1
  111. package/dist/scaffold/skill-files.js +4 -2
  112. package/dist/scaffold/skill-files.js.map +1 -1
  113. package/dist/telemetry/command-context.d.ts +23 -0
  114. package/dist/telemetry/command-context.js +11 -0
  115. package/dist/telemetry/command-context.js.map +1 -1
  116. package/dist/telemetry/report.d.ts +1 -0
  117. package/dist/telemetry/report.js +4 -1
  118. package/dist/telemetry/report.js.map +1 -1
  119. package/dist/tools/bundles.js +8 -0
  120. package/dist/tools/bundles.js.map +1 -1
  121. package/dist/verify/browser.js +25 -0
  122. package/dist/verify/browser.js.map +1 -1
  123. package/dist/verify/categories.d.ts +16 -0
  124. package/dist/verify/categories.js +51 -0
  125. package/dist/verify/categories.js.map +1 -0
  126. package/package.json +10 -7
@@ -0,0 +1,205 @@
1
+ """Attach the shared body rig without re-exporting authored materials or eye layers.
2
+
3
+ Original surface vertices match the dense bound mesh. Newly clipped eye vertices
4
+ use a unanimous rigid-head field or exact bound-triangle interpolation. Optical
5
+ rest fitting retains its pre-fit correspondence; off-surface evidence is rejected.
6
+ """
7
+ import argparse,copy,hashlib,json,shutil
8
+ from pathlib import Path
9
+ import numpy as np
10
+ from scipy.spatial import cKDTree
11
+ from scipy.spatial.transform import Rotation
12
+ from glb_arrays import read,accessor
13
+ from glb_output import append_accessor
14
+ from vision_materials import source_world_points
15
+ from eye_assets import write_glb
16
+
17
+ from workspace_paths import workspace_root
18
+ ROOT=workspace_root(__file__)
19
+
20
+ def node_matrix(node):
21
+ if 'matrix' in node:return np.asarray(node['matrix']).reshape(4,4).T
22
+ matrix=np.eye(4);matrix[:3,:3]=Rotation.from_quat(node.get('rotation',[0,0,0,1])).as_matrix()@np.diag(node.get('scale',[1,1,1]));matrix[:3,3]=node.get('translation',[0,0,0]);return matrix
23
+
24
+ def world_matrices(doc):
25
+ parents={child:i for i,n in enumerate(doc['nodes']) for child in n.get('children',[])};cache={}
26
+ def world(i):
27
+ if i not in cache:cache[i]=(world(parents[i]) if i in parents else np.eye(4))@node_matrix(doc['nodes'][i])
28
+ return cache[i]
29
+ return [world(i) for i in range(len(doc['nodes']))]
30
+
31
+ def set_transform(node,matrix):
32
+ scale=np.linalg.norm(matrix[:3,:3],axis=0);rotation=matrix[:3,:3]/scale
33
+ if not np.allclose(rotation.T@rotation,np.eye(3),atol=1e-5) or np.linalg.det(rotation)<0:raise ValueError('Eye attachment requires a non-sheared positive transform')
34
+ node.pop('matrix',None);node.update(translation=matrix[:3,3].tolist(),rotation=Rotation.from_matrix(rotation).as_quat().tolist(),scale=scale.tolist())
35
+
36
+ def skin_points(points,joints,weights,matrices):
37
+ result=np.zeros_like(points,dtype=float);p=np.c_[points,np.ones(len(points))]
38
+ for slot in range(4):result+=np.einsum('nij,nj->ni',matrices[joints[:,slot]],p)[:,:3]*weights[:,slot,None]
39
+ return result
40
+
41
+ def surface_weight_transfer(query,points,faces,joints,weights,tolerance):
42
+ """Exact closest-triangle interpolation; reject points off the bound surface."""
43
+ triangles=points[faces];centers=triangles.mean(1)
44
+ radius=np.linalg.norm(triangles-centers[:,None,:],axis=2).max()
45
+ tree=cKDTree(centers);vertices=cKDTree(points[np.unique(faces)])
46
+ result_j=[];result_w=[]
47
+ for point in query:
48
+ upper=vertices.query(point)[0]
49
+ ids=tree.query_ball_point(point,float(upper+radius+tolerance))
50
+ tri=triangles[ids];a=tri[:,0];u=tri[:,1]-a;v=tri[:,2]-a;p=point-a
51
+ uu=(u*u).sum(1);uv=(u*v).sum(1);vv=(v*v).sum(1);pu=(p*u).sum(1);pv=(p*v).sum(1)
52
+ determinant=uu*vv-uv*uv;safe=np.where(determinant>1e-24,determinant,1)
53
+ b=(vv*pu-uv*pv)/safe;c=(uu*pv-uv*pu)/safe
54
+ bary=np.stack([1-b-c,b,c],1);inside=(bary>=0).all(1)&(determinant>1e-24)
55
+ distance=np.linalg.norm(np.einsum('ni,nij->nj',bary,tri)-point,axis=1)
56
+ distance[~inside]=np.inf
57
+ for first,last in [(0,1),(1,2),(2,0)]:
58
+ edge=tri[:,last]-tri[:,first];length=(edge*edge).sum(1)
59
+ t=np.clip(((point-tri[:,first])*edge).sum(1)/np.maximum(length,1e-24),0,1)
60
+ candidate=np.linalg.norm(tri[:,first]+edge*t[:,None]-point,axis=1);better=candidate<distance
61
+ distance[better]=candidate[better];bary[better]=0;bary[better,first]=1-t[better];bary[better,last]=t[better]
62
+ closest=int(np.argmin(distance))
63
+ if distance[closest]>tolerance:raise ValueError('Binding provenance is not on the validated source surface')
64
+ corners=faces[ids[closest]];combined={}
65
+ for vertex,factor in zip(corners,bary[closest]):
66
+ for joint,weight in zip(joints[vertex],weights[vertex]):combined[int(joint)]=combined.get(int(joint),0)+float(factor*weight)
67
+ strongest=sorted(combined.items(),key=lambda item:item[1],reverse=True)[:4]
68
+ strongest += [(0,0)]*(4-len(strongest));total=sum(w for _,w in strongest)
69
+ if total<=0:raise ValueError('Bound triangle has no skin weights')
70
+ result_j.append([j for j,_ in strongest]);result_w.append([w/total for _,w in strongest])
71
+ return np.asarray(result_j),np.asarray(result_w)
72
+
73
+ def assemble(sid,relative_input,variant='eyes'):
74
+ base=ROOT/'characters'/sid/'high/v1';input_path=(base/relative_input).resolve()
75
+ if base.resolve() not in input_path.parents:raise ValueError('Input must be a revision inside this character')
76
+ doc,data=read(input_path);rig_path=base/'output/character.glb';rig,rig_data=read(rig_path);rig_report=json.loads((base/'rig-report.json').read_text())
77
+ if hashlib.sha256(rig_path.read_bytes()).hexdigest()!=rig_report['output_sha256']:raise ValueError('Body rig changed after validation')
78
+ if rig_report['geometry_displacement_max_m']>1e-6:raise ValueError('Reshaped sources require an explicit surface deformation transfer')
79
+ if len(rig.get('skins',[]))!=1 or doc.get('skins'):raise ValueError('This assembly adapter adds exactly one new body skin')
80
+ body_node=next(i for i,n in enumerate(doc['nodes']) if n.get('mesh')==0)
81
+ rig_body=next(i for i,n in enumerate(rig['nodes']) if n.get('skin')==0)
82
+ if any('mesh' in n for i,n in enumerate(rig['nodes']) if i!=rig_body):raise ValueError('Unexpected extra body geometry')
83
+ worlds=world_matrices(doc);rig_world=world_matrices(rig)
84
+ if not np.allclose(rig_world[rig_body],np.eye(4),atol=1e-6):raise ValueError('Bound mesh needs an explicit nonidentity bind-space adapter')
85
+ primitives=doc['meshes'][0]['primitives'];positions=accessor(doc,data,primitives[0]['attributes']['POSITION'])
86
+ if any(p['attributes']['POSITION']!=primitives[0]['attributes']['POSITION'] for p in primitives):raise ValueError('Expected a shared surface vertex pool')
87
+ rp=rig['meshes'][rig['nodes'][rig_body]['mesh']]['primitives'][0]
88
+ bound_positions=accessor(rig,rig_data,rp['attributes']['POSITION']);bound_joints=accessor(rig,rig_data,rp['attributes']['JOINTS_0']).astype(np.int32);bound_weights=accessor(rig,rig_data,rp['attributes']['WEIGHTS_0'])
89
+ if not np.issubdtype(bound_weights.dtype,np.floating):raise ValueError('Decode normalized integer weight attributes explicitly')
90
+ scale=rig_report['normalization']['scale'];center=np.asarray(rig_report['normalization']['center']);normalization=np.eye(4);normalization[:3,:3]*=scale;normalization[:3,3]=-center[[0,2,1]]*np.array([1,1,-1])*scale
91
+ transform=normalization@worlds[body_node];expected=positions@transform[:3,:3].T+transform[:3,3]
92
+ binding_index=primitives[0]['attributes'].get('_BIND_SOURCE_POSITION')
93
+ if any(p['attributes'].get('_BIND_SOURCE_POSITION')!=binding_index for p in primitives):raise ValueError('Expected shared source binding positions across material partitions')
94
+ binding_positions=positions if binding_index is None else accessor(doc,data,binding_index)
95
+ if binding_positions.shape!=positions.shape or not np.isfinite(binding_positions).all():raise ValueError('Invalid source binding positions')
96
+ binding_expected=binding_positions@transform[:3,:3].T+transform[:3,3]
97
+ distance,index=cKDTree(bound_positions).query(binding_expected);tolerance=max(np.ptp(bound_positions,axis=0))*2e-6
98
+ joints=bound_joints[index].copy();weights=bound_weights[index].copy();unmatched=distance>tolerance
99
+ source_skin=rig['skins'][0];head_indices=[k for k,i in enumerate(source_skin['joints']) if rig['nodes'][i].get('name') in ['Head','mixamorigHead','mixamorig:Head']]
100
+ if len(head_indices)!=1:raise ValueError('The shared rig must identify one Head joint')
101
+ head=head_indices[0]
102
+ barycentric_count=0
103
+ if unmatched.any():
104
+ # A clipped ocular vertex is safe only within the explicit rigid-head field.
105
+ d,near=cKDTree(bound_positions).query(binding_expected[unmatched],k=4)
106
+ head_weight=np.sum(bound_weights[near]*(bound_joints[near]==head),axis=2)
107
+ rigid=(d<=.003).all(1)&(head_weight>=.9999).all(1)
108
+ missing_ids=np.flatnonzero(unmatched);joints[missing_ids[rigid]]=head;weights[missing_ids[rigid]]=[1,0,0,0]
109
+ if not rigid.all():
110
+ selected=missing_ids[~rigid]
111
+ faces=np.concatenate([accessor(rig,rig_data,p['indices']).reshape(-1,3) for p in rig['meshes'][rig['nodes'][rig_body]['mesh']]['primitives']])
112
+ joints[selected],weights[selected]=surface_weight_transfer(binding_expected[selected],bound_positions,faces,bound_joints,bound_weights,tolerance)
113
+ barycentric_count=len(selected)
114
+ if not np.isfinite(weights).all() or np.max(abs(weights.sum(1)-1))>1e-5:raise ValueError('Invalid transferred weights')
115
+ original_length=len(data);mapping={}
116
+ for i,n in enumerate(rig['nodes']):
117
+ if i!=rig_body:mapping[i]=len(doc['nodes']);doc['nodes'].append(copy.deepcopy(n))
118
+ for old,new in mapping.items():
119
+ if 'children' in doc['nodes'][new]:doc['nodes'][new]['children']=[mapping[c] for c in rig['nodes'][old]['children'] if c!=rig_body]
120
+ roots=rig['scenes'][rig.get('scene',0)]['nodes'];doc['scenes'][doc.get('scene',0)]['nodes'] += [mapping[i] for i in roots if i!=rig_body]
121
+ inverse_bind=accessor(rig,rig_data,source_skin['inverseBindMatrices']).reshape(-1,4,4).transpose(0,2,1)
122
+ # Keep the rig's canonical inverse binds: Skeleton.pose() reconstructs its
123
+ # rest world matrices from them. Convert mesh coordinates instead of hiding
124
+ # a mesh-space transform inside the inverse binds.
125
+ coordinate_scale=np.linalg.norm(transform[:3,:3],axis=0)
126
+ if not np.allclose(coordinate_scale,coordinate_scale[0],rtol=1e-5):raise ValueError('Nonuniform bind-space conversion needs explicit normal handling')
127
+ rotation=transform[:3,:3]/coordinate_scale[0]
128
+ if not np.allclose(rotation.T@rotation,np.eye(3),atol=1e-5):raise ValueError('Sheared bind-space conversion is unsupported')
129
+ converted={}
130
+ def convert_attribute(index,kind,delta=False):
131
+ key=(index,kind,delta)
132
+ if key not in converted:
133
+ values=accessor(doc,data,index)
134
+ if kind=='POSITION':values=values@transform[:3,:3].T+(0 if delta else transform[:3,3])
135
+ elif kind=='TANGENT':values[:,:3]=values[:,:3]@rotation.T
136
+ else:values=values@rotation.T
137
+ converted[key]=append_accessor(doc,data,values.astype('<f4'),'VEC4' if kind=='TANGENT' else 'VEC3',5126)
138
+ if kind=='POSITION':doc['accessors'][converted[key]].update(min=values.min(0).tolist(),max=values.max(0).tolist())
139
+ return converted[key]
140
+ for p in primitives:
141
+ # Authoring correspondence is consumed here; it is not a renderer input.
142
+ p['attributes'].pop('_BIND_SOURCE_POSITION',None)
143
+ for kind in ['POSITION','NORMAL','TANGENT']:
144
+ if kind in p['attributes']:p['attributes'][kind]=convert_attribute(p['attributes'][kind],kind)
145
+ for target in p.get('targets',[]):
146
+ for kind in ['POSITION','NORMAL']:
147
+ if kind in target:target[kind]=convert_attribute(target[kind],kind,True)
148
+ adjusted=inverse_bind
149
+ pose_error=float(np.max(abs(np.linalg.inv(adjusted)-np.asarray([rig_world[i] for i in source_skin['joints']]))))
150
+ if pose_error>2e-5:raise ValueError('Inverse binds are incompatible with the engine skeleton pose reset')
151
+ ibm_index=append_accessor(doc,data,adjusted.transpose(0,2,1).reshape(-1,16).astype('<f4'),'MAT4',5126)
152
+ doc['skins']=[{'name':source_skin.get('name','SharedBodyRig'),'joints':[mapping[i] for i in source_skin['joints']],'inverseBindMatrices':ibm_index}];doc['nodes'][body_node]['skin']=0
153
+ ji=append_accessor(doc,data,joints.astype('<u2'),'VEC4',5123);wi=append_accessor(doc,data,weights.astype('<f4'),'VEC4',5126)
154
+ for p in primitives:p['attributes'].update(JOINTS_0=ji,WEIGHTS_0=wi)
155
+ # Optical roots and head-fixed tissue now follow the animated Head joint.
156
+ head_node=mapping[source_skin['joints'][head]];head_world=rig_world[source_skin['joints'][head]];rotation_changes={};attachments=list(doc['nodes'][body_node].get('children',[]))
157
+ for child in attachments:
158
+ node=doc['nodes'][child]
159
+ optical=node.get('extras',{}).get('characterEye') or ('mesh' in node and all(doc['materials'][p['material']].get('extras',{}).get('eyeLayer') for p in doc['meshes'][node['mesh']]['primitives']))
160
+ if not optical:raise ValueError('Nonocular body attachments require explicit parenting')
161
+ old_rotation=Rotation.from_quat(node.get('rotation',[0,0,0,1]));set_transform(node,np.linalg.inv(head_world)@normalization@worlds[child]);rotation_changes[child]=Rotation.from_quat(node['rotation'])*old_rotation.inv()
162
+ doc['nodes'][head_node].setdefault('children',[]).append(child)
163
+ doc['nodes'][body_node]['children']=[]
164
+ for i,node in enumerate(doc['nodes']):
165
+ if i!=body_node and body_node in node.get('children',[]):node['children'].remove(body_node)
166
+ set_transform(doc['nodes'][body_node],np.eye(4))
167
+ scene_nodes=doc['scenes'][doc.get('scene',0)]['nodes']
168
+ if body_node not in scene_nodes:scene_nodes.append(body_node)
169
+ for animation in doc.get('animations',[]):
170
+ for channel in animation['channels']:
171
+ target=channel['target']
172
+ if target.get('path')=='rotation' and target['node'] in rotation_changes:
173
+ sampler=animation['samplers'][channel['sampler']]
174
+ if sampler.get('interpolation','LINEAR')!='LINEAR':raise ValueError('Eye rotation retarget requires linear quaternion tracks')
175
+ q=accessor(doc,data,sampler['output']);q=(rotation_changes[target['node']]*Rotation.from_quat(q)).as_quat();sampler['output']=append_accessor(doc,data,q.astype('<f4'),'VEC4',5126)
176
+ copied={}
177
+ def copy_accessor(index):
178
+ if index not in copied:
179
+ original=rig['accessors'][index];copied[index]=append_accessor(doc,data,accessor(rig,rig_data,index),original['type'],original['componentType'])
180
+ for key in ['min','max','normalized']:
181
+ if key in original:doc['accessors'][copied[index]][key]=original[key]
182
+ return copied[index]
183
+ for animation in rig.get('animations',[]):
184
+ a=copy.deepcopy(animation)
185
+ for sampler in a['samplers']:sampler['input']=copy_accessor(sampler['input']);sampler['output']=copy_accessor(sampler['output'])
186
+ for channel in a['channels']:channel['target']['node']=mapping[channel['target']['node']]
187
+ doc.setdefault('animations',[]).append(a)
188
+ matrices=np.asarray([rig_world[i] for i in source_skin['joints']])@adjusted
189
+ maximum=0.
190
+ for batch in np.array_split(np.arange(len(positions)),max(1,len(positions)//100000)):
191
+ error=np.linalg.norm(skin_points(expected[batch],joints[batch],weights[batch],matrices)-expected[batch],axis=1);maximum=max(maximum,float(error.max()))
192
+ if maximum>2e-5:raise ValueError(f'Assembled bind pose does not preserve the source: {maximum}m')
193
+ out=base/'runtime/v1';out.mkdir(parents=True,exist_ok=True)
194
+ report={'id':sid,'status':'motion_review_required','viewerReady':False,'surfaceInput':relative_input,'surfaceSha256':hashlib.sha256(input_path.read_bytes()).hexdigest(),'rigSha256':rig_report['output_sha256'],'restPositionErrorM':maximum,'rigidHeadInsertedVertices':int(unmatched.sum()),'bodyBones':len(source_skin['joints']),'headAttachments':len(attachments),'clips':[a['name'] for a in doc.get('animations',[])],'sourceBufferBytesPreserved':bytes(data[:original_length])==bytes(read(input_path)[1]),'geometryChange':'Rigid uniform bind-space coordinate conversion of positions, normals and morph deltas; no surface reshaping','limitations':['Shared body rig currently has no independent finger bones','Motion and eye contact require visual acceptance']}
195
+ report['variant']=variant
196
+ report['barycentricWeightVertices']=barycentric_count
197
+ report['rigidHeadInsertedVertices']=int(unmatched.sum())-barycentric_count
198
+ report['poseResetMatrixError']=pose_error
199
+ report['outputSha256']=write_glb(doc,data,out/f'{variant}.glb');(out/f'{variant}-report.json').write_text(json.dumps(report,indent=2))
200
+ for name in ['ATTRIBUTION.txt','MakeHuman-LICENSE.md']:
201
+ if (input_path.parent/name).exists():shutil.copyfile(input_path.parent/name,out/name)
202
+ print(json.dumps(report))
203
+
204
+ if __name__=='__main__':
205
+ p=argparse.ArgumentParser();p.add_argument('--id',required=True);p.add_argument('--input',required=True);p.add_argument('--variant',choices=['eyes','surfaces'],default='eyes');a=p.parse_args();assemble(a.id,a.input,a.variant)
@@ -0,0 +1,47 @@
1
+ """Bake agent-authored regional crease curves; never infer anatomy or landmarks."""
2
+ import hashlib,json
3
+ import numpy as np
4
+
5
+ LABELS={'skin':1,'palm':2,'sole':3,'dorsal_hand':9}
6
+
7
+ def curve_distance(points,curve):
8
+ distance=np.full(len(points),np.inf)
9
+ for a,b in zip(curve[:-1],curve[1:]):
10
+ edge=b-a;length=np.dot(edge,edge)
11
+ if length<=0:raise ValueError('Crease curve has a repeated point')
12
+ t=np.clip((points-a)@edge/length,0,1)
13
+ distance=np.minimum(distance,np.linalg.norm(points-a-t[:,None]*edge,axis=1))
14
+ return distance
15
+
16
+ class AuthoredDetail:
17
+ def __init__(self,path,manifest,physical_height):
18
+ self.curves=[];self.report=None
19
+ if not path.exists():return
20
+ raw=path.read_bytes();profile=json.loads(raw)
21
+ if profile['sourceSha256']!=manifest['sourceSha256']:raise ValueError('Body detail profile belongs to another source')
22
+ for record in profile['curves']:
23
+ index=next(i for i,v in enumerate(manifest['views']) if v['name']==record['view'])
24
+ image=record['view']+'.png'
25
+ if profile['evidenceHashes'].get(image)!=manifest['evidenceHashes'].get(image):raise ValueError('Body detail coordinates belong to different view evidence')
26
+ view=manifest['views'][index];direction=np.asarray(view['direction'],float);direction/=np.linalg.norm(direction)
27
+ right=np.cross(direction,view.get('up',[0,0,1]));right/=np.linalg.norm(right);up=np.cross(right,direction)
28
+ curve=np.asarray(record['pointsPx'],float)
29
+ if curve.ndim!=2 or curve.shape[1]!=2 or len(curve)<2 or not np.isfinite(curve).all():raise ValueError('Invalid authored crease polyline')
30
+ width=record['widthM'];depth=record['depthM']
31
+ if not 0<width<=.003 or not 0<depth<=.00025:raise ValueError('Crease dimensions exceed the supported skin-detail range')
32
+ self.curves.append((index,np.asarray(view.get('targetOffset',[0,0,0]))*physical_height,right,up,direction,view['span']*physical_height,manifest['size'],curve,width,depth,LABELS[record['region']]))
33
+ self.report={'profileSha256':hashlib.sha256(raw).hexdigest(),'curves':len(self.curves),'provenance':profile['provenance'],'method':'agent-authored regional curves projected only onto visible independently labelled skin; physical width/depth'}
34
+
35
+ def evaluate(self,points,normals,labels,visibility):
36
+ result=np.zeros(len(points),np.float32)
37
+ for index,target,right,up,direction,span,size,curve,width,depth,region in self.curves:
38
+ selected=(labels==region)&((visibility&(1<<index))!=0)
39
+ if not selected.any():continue
40
+ local=points[selected]-target
41
+ xy=np.stack([(local@right/span+.5)*size,(.5-local@up/span)*size],1)
42
+ distance=curve_distance(xy,curve)*span/size
43
+ facing=np.clip((abs(normals[selected]@direction)-.2)/.3,0,1)
44
+ # A compact smooth groove avoids hard tube ends and adds no pigment.
45
+ relief=-depth*np.exp(-.5*(distance/width)**2)*facing*facing*(3-2*facing)
46
+ result[selected]=np.minimum(result[selected],relief)
47
+ return result
@@ -0,0 +1,83 @@
1
+ """Vision-registered roughness/height exemplars, limited by labels and visibility.
2
+
3
+ No inferred anatomy and no conversion of normal RGB into displacement.
4
+ """
5
+ import hashlib,json
6
+ from pathlib import Path
7
+ import numpy as np
8
+ from PIL import Image,ImageDraw
9
+ from scipy.interpolate import RBFInterpolator
10
+ from scipy.ndimage import distance_transform_edt,map_coordinates,gaussian_filter
11
+ from authored_detail import LABELS
12
+
13
+ class AuthoredSurfacePatch:
14
+ def __init__(self,path,manifest,physical_height,root):
15
+ self.patches=[];self.independent=[];self.report=None;self.attributions={}
16
+ if not path.exists():return
17
+ raw=path.read_bytes();profile=json.loads(raw)
18
+ if profile['sourceSha256']!=manifest['sourceSha256']:raise ValueError('Surface patch belongs to another source')
19
+ receipts=[]
20
+ for record in profile['patches']:
21
+ index=next(i for i,v in enumerate(manifest['views'])if v['name']==record['view']);view=manifest['views'][index];image=record['view']+'.png'
22
+ image_path=Path(manifest['output'])/image
23
+ if profile['evidenceHashes'].get(image)!=manifest['evidenceHashes'].get(image) or hashlib.sha256(image_path.read_bytes()).hexdigest()!=profile['evidenceHashes'][image]:raise ValueError('Surface patch evidence changed')
24
+ texture_path=root/record['roughnessTexture'];data=texture_path.read_bytes()
25
+ if hashlib.sha256(data).hexdigest()!=record['textureSha256']:raise ValueError('Surface patch exemplar changed')
26
+ provenance=record['provenance'];license_bytes=(root/provenance['licenseFile']).read_bytes()
27
+ if hashlib.sha256(license_bytes).hexdigest()!=provenance['licenseSha256']:raise ValueError('Surface patch license receipt changed')
28
+ self.attributions[provenance['licenseSha256']]=provenance['title']+'\n'+provenance['url']+'\nAdapted by vision registration and roughness remapping.\n'+license_bytes.decode()
29
+ donor=np.asarray(Image.open(texture_path).convert('L'),float)/255
30
+ source=np.asarray(record['sourcePixels'],float);target=np.asarray(record['targetPixels'],float)
31
+ if source.shape!=target.shape or source.ndim!=2 or source.shape[1]!=2 or len(source)<3 or not np.isfinite(source).all() or not np.isfinite(target).all():raise ValueError('Invalid surface patch correspondences')
32
+ size=manifest['size'];donor_size=record['sourceAnnotationImageSize'];resolution=record.get('sampleResolution',size)
33
+ if not isinstance(resolution,int) or not 16<=resolution<=4096:raise ValueError('Invalid patch sampling resolution')
34
+ if (target<0).any() or (target>=size).any() or (source<0).any() or (source>=donor_size).any():raise ValueError('Surface patch pixel outside evidence')
35
+ if not 0<record['featherM']<=.02:raise ValueError('Invalid surface patch feather')
36
+ low,high=record['roughnessRange']
37
+ if not 0<=low<high<=1:raise ValueError('Invalid surface patch roughness range')
38
+ target_mask=Image.new('L',(resolution,resolution));ImageDraw.Draw(target_mask).polygon([tuple(p*resolution/size)for p in target],fill=255);inside=np.asarray(target_mask)>0
39
+ yy,xx=np.nonzero(inside);mapped=RBFInterpolator(target/size,source/donor_size,kernel='thin_plate_spline',smoothing=0)(np.column_stack([xx,yy])/resolution)
40
+ sampled=map_coordinates(gaussian_filter(donor,1),[mapped[:,1]*(donor.shape[0]-1),mapped[:,0]*(donor.shape[1]-1)],order=1,mode='nearest')
41
+ # Preserve exemplar variation inside a declared art-directed physical response range.
42
+ rough=np.full((resolution,resolution),.5*(low+high));rough[yy,xx]=low+(high-low)*sampled
43
+ height=None
44
+ if record.get('height'):
45
+ h=record['height'];height_path=root/h['path']
46
+ if hashlib.sha256(height_path.read_bytes()).hexdigest()!=h['sha256']:raise ValueError('Surface height exemplar changed')
47
+ field=np.load(height_path,allow_pickle=False);bounds=np.asarray(h['boundsPx'],float)
48
+ if field.ndim!=2 or not np.isfinite(field).all() or np.max(abs(field))>.001 or bounds.shape!=(4,) or not np.isfinite(bounds).all() or np.any(bounds[2:]<=bounds[:2]) or not 0<h['scale']<=4 or not h.get('provenance'):
49
+ raise ValueError('Invalid physical surface height field or provenance')
50
+ coordinates=(mapped*donor_size-bounds[:2])/(bounds[2:]-bounds[:2])
51
+ height=np.zeros((resolution,resolution));height[yy,xx]=map_coordinates(field,[coordinates[:,1]*(field.shape[0]-1),coordinates[:,0]*(field.shape[1]-1)],order=1,mode='nearest')*h['scale']
52
+ self.attributions[provenance['licenseSha256']]+='\nHeight adaptation: '+json.dumps(h['provenance'])+'\n'
53
+ feather=np.clip(distance_transform_edt(inside)*view['span']*physical_height/resolution/record['featherM'],0,1);feather=feather*feather*(3-2*feather)
54
+ direction=np.asarray(view['direction'],float);direction/=np.linalg.norm(direction);right=np.cross(direction,view.get('up',[0,0,1]));right/=np.linalg.norm(right);up=np.cross(right,direction)
55
+ self.patches.append((index,np.asarray(view.get('targetOffset',[0,0,0]))*physical_height,right,up,direction,view['span']*physical_height,resolution,LABELS[record['region']],rough,feather,height))
56
+ self.independent.append(record.get('independentDetail',False))
57
+ receipts.append({'view':record['view'],'region':record['region'],'roughnessTexture':record['roughnessTexture'],'textureSha256':record['textureSha256'],'roughnessRange':record['roughnessRange'],'height':record.get('height'),'provenance':record['provenance'],'independentDetail':self.independent[-1],'sampleResolution':resolution})
58
+ self.report={'profileSha256':hashlib.sha256(raw).hexdigest(),'patches':receipts,'method':'independent vision correspondences; optional physical height; semantic labels and geometric visibility constrain application'}
59
+
60
+ def evaluate(self,points,normals,labels,visibility,base,include_independent=True):
61
+ return self._evaluate(points,normals,labels,visibility,base,False,include_independent)
62
+
63
+ def relief(self,points,normals,labels,visibility,include_independent=True):
64
+ return self._evaluate(points,normals,labels,visibility,np.zeros(len(points)),True,include_independent)
65
+
66
+ def _evaluate(self,points,normals,labels,visibility,base,use_height,include_independent):
67
+ total=np.zeros(len(points));value=np.zeros(len(points))
68
+ for independent,patch in zip(self.independent,self.patches):
69
+ if independent and not include_independent:continue
70
+ index,target,right,up,direction,span,size,region,rough,feather,height=patch
71
+ field=height if use_height else rough
72
+ if field is None:continue
73
+ selected=(labels==region)&((visibility&(1<<index))!=0)
74
+ if not selected.any():continue
75
+ local=points[selected]-target;xy=np.stack([(local@right/span+.5)*size,(.5-local@up/span)*size],1)
76
+ weight=map_coordinates(feather,[xy[:,1],xy[:,0]],order=1,mode='constant',cval=0)
77
+ facing=np.clip((abs(normals[selected]@direction)-.2)/.3,0,1);weight*=facing*facing*(3-2*facing)
78
+ value[selected]+=map_coordinates(field,[xy[:,1],xy[:,0]],order=1,mode='nearest')*weight;total[selected]+=weight
79
+ return base*(1-np.minimum(total,1))+value/np.maximum(total,1e-12)*np.minimum(total,1)
80
+
81
+ def independent_planes(self):
82
+ names=['index','target','right','up','direction','span','size','region','roughness','feather','height']
83
+ return [dict(zip(names,patch)) for independent,patch in zip(self.independent,self.patches) if independent]
@@ -0,0 +1,62 @@
1
+ """Short strand groom from explicit image-space brow polygons and comb guides.
2
+
3
+ Sampling places designed fibers inside authored regions; it does not recognize hair.
4
+ """
5
+ import numpy as np
6
+
7
+
8
+ def inside_polygon(points, polygon):
9
+ polygon=np.asarray(polygon,float);inside=np.zeros(len(points),bool)
10
+ for a,b in zip(polygon,np.roll(polygon,-1,axis=0)):
11
+ if abs(b[1]-a[1])<1e-12:continue
12
+ inside^=((a[1]>points[:,1])!=(b[1]>points[:,1]))&(points[:,0]<(b[0]-a[0])*(points[:,1]-a[1])/(b[1]-a[1])+a[0])
13
+ return inside
14
+
15
+
16
+ def build_brow(profile,points,triangles,normals,target,right,up,direction,span,size,unit,inverse,origin):
17
+ polygon=np.asarray(profile['polygonPx'],float);guides=np.asarray(profile['combGuidesPx'],float)
18
+ if polygon.ndim!=2 or polygon.shape[1]!=2 or len(polygon)<3 or not np.isfinite(polygon).all():raise ValueError('Brow needs an explicit finite polygon')
19
+ if guides.ndim!=3 or guides.shape[1:]!=(2,2) or len(guides)<2 or not np.isfinite(guides).all():raise ValueError('Brow needs at least two explicit comb guides')
20
+ vectors=guides[:,1]-guides[:,0];lengths=np.linalg.norm(vectors,axis=1)
21
+ if np.any(lengths<1e-6):raise ValueError('Brow comb guides must have a direction')
22
+ vectors/=lengths[:,None]
23
+ pigment=np.asarray(profile['colorLinear'],float)
24
+ if pigment.shape!=(3,) or not np.isfinite(pigment).all() or np.any(pigment<0) or np.any(pigment>1):raise ValueError('Brow pigment must be finite linear RGB')
25
+ count=int(profile['strandCount']);radii=float(profile['radiusM'])*unit
26
+ low,high=np.asarray(profile['lengthRangeM'],float)*unit
27
+ if not 1<=count<=1000 or not 0<radii<.0001*unit or not 0<low<=high<.015*unit:raise ValueError('Invalid authored brow fiber dimensions')
28
+ rng=np.random.default_rng(profile['seed']);roots=[]
29
+ for batch in range(100):
30
+ candidates=rng.uniform(polygon.min(0),polygon.max(0),size=(count*4,2))
31
+ roots.extend(candidates[inside_polygon(candidates,polygon)])
32
+ if len(roots)>=count:break
33
+ if len(roots)<count:raise ValueError('Brow polygon has insufficient area')
34
+ roots=np.asarray(roots[:count])
35
+ projected=np.stack([((points-target)@right/span+.5)*size,(.5-(points-target)@up/span)*size],1)
36
+ bounds=projected[triangles];selected=np.all(bounds.max(1)>=polygon.min(0)-1,axis=1)&np.all(bounds.min(1)<=polygon.max(0)+1,axis=1)
37
+ triangles=triangles[selected];a=points[triangles[:,0]];e1=points[triangles[:,1]]-a;e2=points[triangles[:,2]]-a
38
+ h=np.cross(direction,e2);det=(e1*h).sum(1);safe=np.where(abs(det)>1e-12,det,1)
39
+ vertices=[];vertex_normals=[];faces=[];color=[]
40
+ for pixel in roots:
41
+ ray=target+right*((pixel[0]+.5)/size-.5)*span+up*(.5-(pixel[1]+.5)/size)*span-direction*span*4
42
+ s=ray-a;u=(s*h).sum(1)/safe;q=np.cross(s,e1);v=(q*direction).sum(1)/safe;t=(e2*q).sum(1)/safe
43
+ valid=(abs(det)>1e-12)&(u>=0)&(v>=0)&(u+v<=1)&(t>0)
44
+ if not valid.any():raise ValueError('Authored brow root misses visible source geometry')
45
+ face=int(np.argmin(np.where(valid,t,np.inf)));root=ray+direction*t[face]
46
+ normal=normals[triangles[face]].T@np.array([1-u[face]-v[face],u[face],v[face]]);normal/=np.linalg.norm(normal)
47
+ weights=1/np.maximum(np.sum((guides[:,0]-pixel)**2,axis=1),9);comb=weights@vectors/weights.sum()
48
+ tangent=right*comb[0]-up*comb[1];tangent-=normal*np.dot(normal,tangent);tangent/=np.linalg.norm(tangent)
49
+ length=rng.uniform(low,high);curve=[]
50
+ for fraction in np.linspace(0,1,8):
51
+ curve.append(root+tangent*length*fraction+normal*unit*(.00004+.00018*np.sin(np.pi*fraction)+.00008*fraction))
52
+ curve=np.asarray(curve);along=np.gradient(curve,axis=0);along/=np.linalg.norm(along,axis=1,keepdims=True)
53
+ side=np.cross(along,normal);side/=np.linalg.norm(side,axis=1,keepdims=True);other=np.cross(along,side)
54
+ angle=np.arange(5)*2*np.pi/5;tube_normals=side[:,None,:]*np.cos(angle)[None,:,None]+other[:,None,:]*np.sin(angle)[None,:,None]
55
+ tube=curve[:,None,:]+tube_normals*radii*np.linspace(1,.05,8)[:,None,None];start=len(vertices)
56
+ vertices.extend((tube.reshape(-1,3)-origin)@inverse.T)
57
+ transformed=tube_normals.reshape(-1,3)@np.linalg.inv(inverse);transformed/=np.linalg.norm(transformed,axis=1,keepdims=True);vertex_normals.extend(transformed)
58
+ shade=np.clip(pigment*rng.uniform(.8,1.2),0,1);color.extend(np.broadcast_to(shade,(40,3)))
59
+ for row in range(7):
60
+ for k in range(5):
61
+ i=start+row*5+k;j=start+row*5+(k+1)%5;faces.extend([[i,j,i+5],[j,j+5,i+5]])
62
+ return np.asarray(vertices),np.asarray(vertex_normals),np.asarray(color),np.asarray(faces)
@@ -0,0 +1,107 @@
1
+ """Register an attributed scan detail atlas to a generated face, preserving identity.
2
+
3
+ This transfers band-limited displacement, not the scan's face shape. Height scale
4
+ is an authoring calibration (not recovered physical capture metadata). No MERL or
5
+ MetaHuman textures are used. All derivatives carry source/license/hash provenance.
6
+ """
7
+ from pathlib import Path
8
+ import hashlib,json
9
+ import numpy as np
10
+ from PIL import Image,ImageDraw
11
+ from scipy.ndimage import gaussian_filter, distance_transform_edt
12
+ from face_atlas import rasterize,sample,register
13
+ from glb_arrays import read,accessor
14
+ from workspace_paths import workspace_root
15
+ ROOT=workspace_root(__file__)
16
+ REFERENCE=ROOT/'highquality/references/lee-perry-smith'
17
+
18
+ def pixels_to_coords(points,bounds,n=900):
19
+ p=np.asarray(points,dtype=float)/n;p[:,1]=1-p[:,1]
20
+ return np.asarray(bounds[:2])+p*(np.asarray(bounds[2:])-bounds[:2])
21
+
22
+ def region_image(profile,size=2048):
23
+ im=Image.new('L',(size,size),0);draw=ImageDraw.Draw(im)
24
+ for value,kind in enumerate(['eyes','brows','lips'],1):
25
+ for polygon in profile['regions'][kind]:
26
+ draw.polygon([(x/profile['annotationImageSize']*(size-1),(1-y/profile['annotationImageSize'])*(size-1)) for x,y in polygon],fill=value)
27
+ return np.array(im)
28
+
29
+ def verify_landmark_evidence(profile,root,source):
30
+ if profile.get('version',1)<2:return 'legacy_unbound_landmarks'
31
+ if hashlib.sha256(source.read_bytes()).hexdigest()!=profile['sourceSha256']:
32
+ raise ValueError('Face landmarks belong to a different source mesh')
33
+ image=root/profile['evidenceImage']
34
+ if hashlib.sha256(image.read_bytes()).hexdigest()!=profile['evidenceImageSha256']:
35
+ raise ValueError('Face landmark evidence image changed')
36
+ with Image.open(image) as pixels:
37
+ if pixels.size!=(profile['annotationImageSize'],profile['annotationImageSize']):
38
+ raise ValueError('Face landmark evidence dimensions differ')
39
+ return 'source_and_image_hash_verified'
40
+
41
+ def build(sid='b3662'):
42
+ profile_path=ROOT/'highquality/materials/faces'/f'{sid}.json';profile=json.loads(profile_path.read_text())
43
+ evidence_status=verify_landmark_evidence(profile,ROOT,ROOT/'characters'/sid/'high/v1/source/hy31pro.glb')
44
+ folder=ROOT/'characters'/sid/'high/v1/materials/v2';folder.mkdir(parents=True,exist_ok=True)
45
+ source=REFERENCE/'LeePerrySmith.glb';j,b=read(source);pr=j['meshes'][0]['primitives'][0]
46
+ p=accessor(j,b,pr['attributes']['POSITION']);uv=accessor(j,b,pr['attributes']['TEXCOORD_0']);uv[:,1]=1-uv[:,1]
47
+ ids=accessor(j,b,pr['indices']).reshape(-1,3)
48
+ size=profile.get('atlasSize',2048)
49
+ if size not in (2048,4096):raise ValueError('Facial atlasSize must be 2048 or 4096')
50
+ pixel_scale=size/2048
51
+ scan_uv,scan_valid=rasterize(p,ids,uv,profile['sourceBounds'],size)
52
+ displacement=np.asarray(Image.open(REFERENCE/'Infinite-Level_02_Disp_NoSmoothUV-4096.jpg').convert('L'),dtype=float)/255
53
+ scan_height=sample(displacement,scan_uv)
54
+ # Inpaint uncovered pixels before filtering to avoid silhouette impulses.
55
+ nearest=distance_transform_edt(~scan_valid,return_distances=False,return_indices=True)
56
+ scan_height=scan_height[tuple(nearest)]
57
+ # Remove identity-scale form; retain scan-correlated intermediate and fine detail.
58
+ band=gaussian_filter(scan_height,.65*pixel_scale)-gaussian_filter(scan_height,24*pixel_scale)
59
+ central=band[size//3:3*size//4,size//3:2*size//3]
60
+ amplitude=np.percentile(np.abs(central),95)
61
+ band*=profile['detailAmplitudeM']/max(amplitude,1.e-6)
62
+ np.save(folder/'scan-band-height-m.npy',band.astype('float32'))
63
+ yy,xx=np.mgrid[0:size,0:size];coords=np.stack([xx,yy],-1)/(size-1)
64
+ bounds=np.array(profile['bounds']);target=coords*(bounds[2:]-bounds[:2])+bounds[:2]
65
+ mapped,valid=register(target.reshape(-1,2),pixels_to_coords(profile['targetPixels'],profile['bounds'],profile['annotationImageSize']),pixels_to_coords(profile['sourcePixels'],profile['sourceBounds'],profile.get('sourceAnnotationImageSize',900)))
66
+ sb=np.array(profile['sourceBounds']);lookup=((mapped-sb[:2])/(sb[2:]-sb[:2])).reshape(size,size,2)
67
+ height=sample(band,lookup)
68
+ regions=region_image(profile,size)
69
+ coverage=valid.reshape(size,size)&(regions==0)
70
+ if not coverage.any():raise ValueError('Registration has no uncovered skin; review landmark hull and exclusion polygons')
71
+ feather=np.clip(distance_transform_edt(coverage)/(48*pixel_scale),0,1)
72
+ # Roughness follows the transferred relief's local variation, with coarse oil regions.
73
+ dy,dx=np.gradient(height);variance=gaussian_filter(dx*dx+dy*dy,3*pixel_scale)
74
+ relief=np.clip(np.sqrt(variance)/max(np.percentile(np.sqrt(variance[coverage]),95),1e-9),0,1)
75
+ # Transfer only gentle chromatic variation from clean upper-face skin;
76
+ # reject beard/jaw pigment so the donor's stubble does not enter the queen.
77
+ scan_color=sample(np.asarray(Image.open(REFERENCE/'Map-COL.jpg'),dtype=float)/255,scan_uv)
78
+ chroma=np.log(np.maximum(scan_color[:,:,0],.02)/np.maximum(scan_color[:,:,1],.02))
79
+ chroma=gaussian_filter(chroma,4*pixel_scale)-gaussian_filter(chroma,50*pixel_scale)
80
+ pigment=sample(chroma,lookup)*np.clip((lookup[:,:,1]-.53)/.09,0,1)
81
+ pigment=np.clip(pigment*4,-.3,.3)
82
+ packed=np.stack([np.clip(.5+height/profile['detailHeightRangeM'],0,1),.45+.20*relief,.5+pigment,feather],axis=-1)
83
+ Image.fromarray(np.round(packed*255).astype('uint8'),'RGBA').save(folder/'face-detail.png')
84
+ # Authored appearance deltas, NOT captured expression scans. Modulate the
85
+ # registered relief within facial regions; the source HY geometry stays intact.
86
+ x=coords[:,:,0];z=coords[:,:,1]
87
+ forehead=np.exp(-((x-.5)/.22)**2-((z-.69)/.11)**2)
88
+ smile=np.exp(-((abs(x-.5)-.15)/.10)**2-((z-.23)/.13)**2)
89
+ squint=np.exp(-((abs(x-.5)-.19)/.10)**2-((z-.46)/.09)**2)
90
+ delta=np.stack([height*forehead*.8,height*smile*.6,height*squint*.7],-1)
91
+ dynamic=np.concatenate([np.clip(.5+delta/profile['detailHeightRangeM'],0,1),np.clip((smile+forehead+squint)*feather,0,1)[...,None]],axis=-1)
92
+ Image.fromarray(np.round(dynamic*255).astype('uint8'),'RGBA').save(folder/'face-expression.png')
93
+ np.save(folder/'face-height-m.npy' ,height.astype('float32'))
94
+ Image.fromarray(regions.astype('uint8')).save(folder/'face-regions.png')
95
+ # Useful independent normal preview in the registered face plane.
96
+ physical_height=json.loads((ROOT/'characters'/sid/'high/v1/output/viewer-profile.json').read_text())['height']
97
+ spacing=(bounds[2:]-bounds[:2])*physical_height/(size-1)
98
+ dy,dx=np.gradient(height,spacing[1],spacing[0]);n=np.stack([-dx,-dy,np.ones_like(height)],-1);n/=np.linalg.norm(n,axis=-1,keepdims=True)
99
+ Image.fromarray(np.round((n*.5+.5)*255).astype('uint8')).save(folder/'face-normal-preview.png')
100
+ report={'id':sid,'landmarkEvidence':evidence_status,'method':'landmark-registered, band-limited scan displacement','author':'Lee Perry-Smith / Infinite Realities','license':'CC BY 3.0','licenseUrl':'https://creativecommons.org/licenses/by/3.0/','sourceUrl':'https://github.com/mrdoob/three.js/tree/r186/examples/models/gltf/LeePerrySmith','changes':'Projected into a facial atlas, band-pass filtered, amplitude calibrated, landmark-warped and masked. Only upper-face chromatic residual is transferred; beard pigment and face shape are not copied.','expressionMaps':'Authored modulation of scan relief; not captured expressions or a biological color model','scale':'Authoring estimate, not measured displacement units','heightRangeM':profile['detailHeightRangeM'],'profile_sha256':hashlib.sha256(profile_path.read_bytes()).hexdigest(),'inputs':{f.name:hashlib.sha256(f.read_bytes()).hexdigest() for f in REFERENCE.iterdir() if f.is_file()},'source_sha256':hashlib.sha256((ROOT/'characters'/sid/'high/v1/source/hy31pro.glb').read_bytes()).hexdigest()}
101
+ report['atlasSize']=size;report['texelSpacingM']=spacing.tolist();report['filterScaleRelativeTo2048']=pixel_scale
102
+ (folder/'face-detail-provenance.json').write_text(json.dumps(report,indent=2)+'\n')
103
+ (folder/'ATTRIBUTION.txt').write_text('Facial detail adapted from Infinite, 3D Head Scan by Lee Perry-Smith / Infinite Realities.\nCC BY 3.0: https://creativecommons.org/licenses/by/3.0/\nSource: three.js r186 examples/models/gltf/LeePerrySmith.\nModified by landmark registration, filtering, masking and amplitude calibration.\n')
104
+ print(json.dumps({'id':sid,'atlasSize':size,'heightP95Mm':float(np.percentile(abs(height[coverage]),95)*1000)}))
105
+ if __name__=='__main__':
106
+ import argparse
107
+ parser=argparse.ArgumentParser();parser.add_argument('--id',default='b3662');build(parser.parse_args().id)
@@ -0,0 +1,28 @@
1
+ """Build reviewed evidence into a separate surface/eye/runtime candidate, fail-fast.
2
+
3
+ Authoring and visual acceptance remain agent tasks. This command never selects anatomy,
4
+ promotes viewer readiness, modifies the original model, or runs paid generation.
5
+ """
6
+ import argparse,json,subprocess,sys
7
+ from pathlib import Path
8
+
9
+ from workspace_paths import workspace_root
10
+ ROOT=workspace_root(__file__)
11
+
12
+ def build(character,evidence,masks,eyes=None,assemble=False):
13
+ base=ROOT/'characters'/character/'high/v1'
14
+ for file in [evidence/'manifest.json',masks/'report.json',masks/'material-0-surface.npz',base/'materials/v2/face-detail.png',base/'materials/v2/face-expression.png',base/'materials/v2/scan-band-height-m.npy']:
15
+ if not file.is_file():raise ValueError(f'Missing authored input: {file}')
16
+ if eyes and json.loads(eyes.read_text())['id']!=character:raise ValueError('Eye profile character mismatch')
17
+ def run(script,*args):
18
+ subprocess.run([sys.executable,str(Path(__file__).resolve().parent/script),*map(str,args)],cwd=ROOT,check=True)
19
+ run('vision_materials.py','--id',character,'--evidence',evidence,'--masks',masks)
20
+ run('vision_blend_materials.py','--id',character,'--all-surfaces')
21
+ if eyes:run('eye_assets.py','--profile',eyes)
22
+ if assemble:
23
+ run('assemble_character.py','--id',character,'--input','materials/v4/character.glb','--variant','surfaces')
24
+ if eyes:run('assemble_character.py','--id',character,'--input','eyes/v1/character.glb','--variant','eyes')
25
+
26
+ if __name__=='__main__':
27
+ p=argparse.ArgumentParser(description=__doc__);p.add_argument('--id',required=True);p.add_argument('--evidence',required=True,type=Path);p.add_argument('--masks',required=True,type=Path);p.add_argument('--eyes-profile',type=Path);p.add_argument('--assemble',action='store_true');a=p.parse_args()
28
+ build(a.id,a.evidence.resolve(),a.masks.resolve(),a.eyes_profile.resolve() if a.eyes_profile else None,a.assemble)
@@ -0,0 +1,57 @@
1
+ """Designed lining between an explicitly authored canthus and the globe."""
2
+ import numpy as np
3
+ from eyelid_morph import conform_open_lid,surface_normals,surface_normal_groups
4
+ from eyelid_patch import lid_contact_depth,corner_contact_depth
5
+
6
+ def lining_depth_at_radius(radial,corner_radial,source_depth,radius,iris,unit,reconstructed=True):
7
+ """Depth on the authored corner lining, for tissue supported by that lining."""
8
+ swept=iris*np.cos(.35)+np.sqrt(radius*radius-iris*iris)*np.sin(.35)
9
+ inner=max(radius*.88,swept+.00035*unit)
10
+ if inner>=radius or not inner<=radial<=corner_radial or corner_radial<=inner:
11
+ raise ValueError('Authored corner tissue lies outside its lining support')
12
+ globe=np.sqrt(max(0,radius*radius-corner_radial*corner_radial))
13
+ if reconstructed:boundary=float(lid_contact_depth(corner_radial,radius,globe,source_depth))+.00025*unit
14
+ else:boundary=max(source_depth,globe+.00025*unit) if corner_radial<radius and source_depth>0 else source_depth
15
+ inner_depth=np.sqrt(radius*radius-inner*inner)-.00035*unit
16
+ t=(radial-inner)/(corner_radial-inner)
17
+ return inner_depth*(1-t)+(boundary-.0003*unit)*t
18
+
19
+
20
+ def build_canthus_wall(profile,opening,sample_surface,inverse,origin):
21
+ contour=np.asarray(opening['contour']);indices=profile['contourIndices']
22
+ if len(indices)<3:raise ValueError('A corner wall needs upper, corner and lower landmarks')
23
+ path=contour[indices];pixels=np.concatenate([a[None,:]*(1-np.arange(12)[:,None]/12)+b[None,:]*(np.arange(12)[:,None]/12) for a,b in zip(path[:-1],path[1:])]+[path[-1:]])
24
+ right=opening['right'];up=opening['up'];forward=-opening['direction'];center=opening['center'];radius=opening['radius'];unit=opening['unit']
25
+ plane=opening['target']+right[None,:]*((pixels[:,0]/opening['size']-.5)*opening['span'])[:,None]+up[None,:]*((.5-pixels[:,1]/opening['size'])*opening['span'])[:,None]
26
+ xy=np.stack([(plane-center)@right,(plane-center)@up],1);rad=np.linalg.norm(xy,axis=1)
27
+ swept_iris=opening['iris']*np.cos(.35)+np.sqrt(radius*radius-opening['iris']**2)*np.sin(.35)
28
+ inner_radius=max(radius*.88,swept_iris+.00035*unit)
29
+ if inner_radius>=radius:raise ValueError('Authored optical dimensions leave no lining clearance for the reviewed gaze range')
30
+ active=np.flatnonzero(rad>inner_radius)
31
+ if len(active)<3 or np.any(np.diff(active)!=1):raise ValueError('Reviewed corner must form one contiguous lining outside the globe center')
32
+ plane=plane[active];xy=xy[active];rad=rad[active]
33
+ boundary=np.asarray([sample_surface(p) for p in plane])
34
+ if profile.get('contactMethod') == 'reconstructed-lid-v1':
35
+ # Use precisely the reconstructed aperture's depth, rather than the
36
+ # old source eye surface, which is removed by the lid-band exporter.
37
+ source_depth=(boundary-center)@forward
38
+ globe_depth=np.sqrt(np.maximum(0,radius*radius-rad*rad))
39
+ depth=np.array([corner_contact_depth(r,g,s,p,opening) for r,g,s,p in zip(rad,globe_depth,source_depth,plane)])+.00025*unit
40
+ boundary=center+xy[:,0,None]*right+xy[:,1,None]*up+depth[:,None]*forward
41
+ elif profile.get('contactMethod') is not None:
42
+ raise ValueError('Unsupported canthus contact method')
43
+ boundary,_=conform_open_lid(boundary,opening)
44
+ inner_xy=xy*(inner_radius/rad)[:,None];inner=center+inner_xy[:,0,None]*right+inner_xy[:,1,None]*up+forward*(np.sqrt(radius*radius-inner_radius*inner_radius)-.00035*unit)
45
+ rows=7;vertices=[];roots=[];weights=[];faces=[];count=len(boundary)
46
+ for row,t in enumerate(np.linspace(0,1,rows)):
47
+ # The near-lid edge shares its support point with the source lid, but is
48
+ # slightly recessed. The far edge remains behind the globe during blink.
49
+ vertices.extend(inner*(1-t)+(boundary-forward*.0003*unit)*t)
50
+ roots.extend(boundary);weights.extend(np.full(count,t))
51
+ if row:
52
+ for j in range(count-1):
53
+ a=(row-1)*count+j;b=a+1;faces.extend([[a,b,a+count],[b,b+count,a+count]])
54
+ vertices=(np.asarray(vertices)-origin)@inverse.T;faces=np.asarray(faces)
55
+ normals=surface_normals(vertices,faces,surface_normal_groups(vertices))
56
+ colors=np.broadcast_to(profile['colorLinear'],vertices.shape).copy()
57
+ return (vertices,normals,colors,faces),(np.asarray(roots)-origin)@inverse.T,np.asarray(weights)