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

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 (99) hide show
  1. package/README.md +36 -1
  2. package/assets/character-skin-authoring/SKILL.md +43 -0
  3. package/assets/character-skin-authoring/references/exporters.md +134 -0
  4. package/assets/character-skin-authoring/references/eyes.md +215 -0
  5. package/assets/character-skin-authoring/references/materials.md +115 -0
  6. package/assets/character-skin-authoring/references/playground.md +78 -0
  7. package/assets/character-skin-authoring/references/profiles.md +97 -0
  8. package/assets/character-skin-authoring/references/projection.md +190 -0
  9. package/assets/character-skin-authoring/references/regional-detail.md +72 -0
  10. package/assets/character-skin-authoring/scripts/assemble_character.py +205 -0
  11. package/assets/character-skin-authoring/scripts/authored_detail.py +47 -0
  12. package/assets/character-skin-authoring/scripts/authored_surface_patch.py +83 -0
  13. package/assets/character-skin-authoring/scripts/brow_groom.py +62 -0
  14. package/assets/character-skin-authoring/scripts/build_face_detail.py +107 -0
  15. package/assets/character-skin-authoring/scripts/build_surface_candidate.py +28 -0
  16. package/assets/character-skin-authoring/scripts/canthus_wall.py +57 -0
  17. package/assets/character-skin-authoring/scripts/character_build.py +54 -0
  18. package/assets/character-skin-authoring/scripts/eye_assets.py +333 -0
  19. package/assets/character-skin-authoring/scripts/eye_opening.py +114 -0
  20. package/assets/character-skin-authoring/scripts/eye_texture.py +35 -0
  21. package/assets/character-skin-authoring/scripts/eyelid_detail.py +34 -0
  22. package/assets/character-skin-authoring/scripts/eyelid_morph.py +171 -0
  23. package/assets/character-skin-authoring/scripts/eyelid_patch.py +173 -0
  24. package/assets/character-skin-authoring/scripts/face_atlas.py +51 -0
  25. package/assets/character-skin-authoring/scripts/geometry_repair.py +112 -0
  26. package/assets/character-skin-authoring/scripts/glb_arrays.py +13 -0
  27. package/assets/character-skin-authoring/scripts/glb_output.py +21 -0
  28. package/assets/character-skin-authoring/scripts/inspect_skin_point.py +66 -0
  29. package/assets/character-skin-authoring/scripts/normal_patch_height.py +38 -0
  30. package/assets/character-skin-authoring/scripts/prepare_face_reference.py +23 -0
  31. package/assets/character-skin-authoring/scripts/regional_atlas.py +51 -0
  32. package/assets/character-skin-authoring/scripts/select_preview.py +90 -0
  33. package/assets/character-skin-authoring/scripts/skin_views.py +267 -0
  34. package/assets/character-skin-authoring/scripts/surface_pbr_maps.py +46 -0
  35. package/assets/character-skin-authoring/scripts/texture_repair.py +98 -0
  36. package/assets/character-skin-authoring/scripts/vertex_detail_support.py +80 -0
  37. package/assets/character-skin-authoring/scripts/vision_blend_materials.py +74 -0
  38. package/assets/character-skin-authoring/scripts/vision_materials.py +276 -0
  39. package/assets/character-skin-authoring/scripts/workspace_paths.py +18 -0
  40. package/assets/metahuman/src/work/metahuman/MetaHumanActor.ts +580 -0
  41. package/assets/metahuman/src/work/metahuman/MetaHumanLabHooks.ts +122 -0
  42. package/assets/metahuman/src/work/metahuman/MetaHumanMaterials.ts +500 -0
  43. package/assets/metahuman/src/work/metahuman/MetaHumanSpeech.ts +242 -0
  44. package/assets/metahuman/src/work/metahuman/StudioEnvironment.ts +142 -0
  45. package/assets/metahuman/tools/lipsync/make_line.py +181 -0
  46. package/assets/metahuman/tools/metahuman/README.md +21 -0
  47. package/assets/metahuman/tools/metahuman/e2e/eval.mjs +34 -0
  48. package/assets/metahuman/tools/metahuman/e2e/probes/arm-pose-check.js +32 -0
  49. package/assets/metahuman/tools/metahuman/e2e/probes/bone-displacement.js +26 -0
  50. package/assets/metahuman/tools/metahuman/e2e/probes/clip-shoulder.js +41 -0
  51. package/assets/metahuman/tools/metahuman/e2e/probes/grafted-bones.js +11 -0
  52. package/assets/metahuman/tools/metahuman/e2e/probes/helper-follow-descent.js +38 -0
  53. package/assets/metahuman/tools/metahuman/e2e/probes/helper-follow-sweep.js +31 -0
  54. package/assets/metahuman/tools/metahuman/e2e/probes/helper-joints-inspect.js +10 -0
  55. package/assets/metahuman/tools/metahuman/e2e/probes/outfit-bad-vertices.js +26 -0
  56. package/assets/metahuman/tools/metahuman/e2e/probes/outfit-body-material.js +7 -0
  57. package/assets/metahuman/tools/metahuman/e2e/probes/outfit-own-skeleton.js +8 -0
  58. package/assets/metahuman/tools/metahuman/e2e/probes/shirt-bone-ownership.js +25 -0
  59. package/assets/metahuman/tools/metahuman/e2e/probes/shirt-edge-stretch.js +26 -0
  60. package/assets/metahuman/tools/metahuman/e2e/probes/shirt-long-edges.js +23 -0
  61. package/assets/metahuman/tools/metahuman/e2e/probes/walk-sampler.js +105 -0
  62. package/assets/metahuman/tools/metahuman/e2e/shot.mjs +125 -0
  63. package/assets/metahuman/tools/metahuman/e2e/walk-probe.mjs +43 -0
  64. package/assets/metahuman/tools/metahuman/merge-skeletons.mjs +69 -0
  65. package/assets/metahuman/tools/metahuman/package.json +13 -0
  66. package/assets/metahuman/tools/metahuman/prepare.sh +25 -0
  67. package/assets/metahuman/tools/metahuman/reorder-body-first.mjs +20 -0
  68. package/assets/metahuman/tools/metahuman/smooth-outfit-weights.mjs +48 -0
  69. package/assets/metahuman/tools/metahuman/strip-draco.mjs +32 -0
  70. package/assets/metahuman/tools/metahuman/transfer-skin-weights.mjs +90 -0
  71. package/dist/commands/generate.d.ts +49 -0
  72. package/dist/commands/generate.js +168 -1
  73. package/dist/commands/generate.js.map +1 -1
  74. package/dist/generate/audio-file.d.ts +6 -0
  75. package/dist/generate/audio-file.js +42 -0
  76. package/dist/generate/audio-file.js.map +1 -0
  77. package/dist/generate/music.d.ts +4 -3
  78. package/dist/generate/music.js +6 -23
  79. package/dist/generate/music.js.map +1 -1
  80. package/dist/generate/stream.d.ts +13 -0
  81. package/dist/generate/stream.js +14 -1
  82. package/dist/generate/stream.js.map +1 -1
  83. package/dist/scaffold/asset-pipeline-skill-files.js +2 -0
  84. package/dist/scaffold/asset-pipeline-skill-files.js.map +1 -1
  85. package/dist/scaffold/character-skin-skill-files.d.ts +2 -0
  86. package/dist/scaffold/character-skin-skill-files.js +49 -0
  87. package/dist/scaffold/character-skin-skill-files.js.map +1 -0
  88. package/dist/scaffold/metahuman-skill-files.d.ts +21 -0
  89. package/dist/scaffold/metahuman-skill-files.js +306 -0
  90. package/dist/scaffold/metahuman-skill-files.js.map +1 -0
  91. package/dist/scaffold/project-files.js +4 -4
  92. package/dist/scaffold/project-files.js.map +1 -1
  93. package/dist/scaffold/project.js +4 -0
  94. package/dist/scaffold/project.js.map +1 -1
  95. package/dist/scaffold/skill-files.js +3 -1
  96. package/dist/scaffold/skill-files.js.map +1 -1
  97. package/dist/tools/bundles.js +8 -0
  98. package/dist/tools/bundles.js.map +1 -1
  99. package/package.json +4 -4
@@ -0,0 +1,171 @@
1
+ """Rest-space eyelid closure from authored ocular contours; eyeballs never scale."""
2
+ import numpy as np
3
+ from scipy.spatial.transform import Rotation
4
+ from glb_arrays import accessor
5
+ from glb_output import append_accessor
6
+ from vision_materials import source_world_points
7
+
8
+ def surface_normal_groups(rest):
9
+ """Weld geometric duplicates for deformation normals, retaining every UV vertex."""
10
+ tolerance=max(float(np.ptp(rest,axis=0).max())*1e-8,1e-12)
11
+ return np.unique(np.round(rest/tolerance).astype(np.int64),axis=0,return_inverse=True)[1]
12
+
13
+ def surface_normals(vertices,faces,groups):
14
+ face=np.cross(vertices[faces[:,1]]-vertices[faces[:,0]],vertices[faces[:,2]]-vertices[faces[:,0]])
15
+ summed=np.zeros((int(groups.max())+1,3),dtype=np.float64)
16
+ for k in range(3):np.add.at(summed,groups[faces[:,k]],face)
17
+ summed/=np.maximum(np.linalg.norm(summed,axis=1,keepdims=True),1e-12)
18
+ return summed[groups]
19
+
20
+ def transport_normals(original,rest,deformed):
21
+ """Rotate authored normals with the geometric frame instead of adding vectors."""
22
+ result=np.asarray(original,dtype=float).copy()
23
+ valid=(np.linalg.norm(rest,axis=1)>1e-8)&(np.linalg.norm(deformed,axis=1)>1e-8)
24
+ a=rest[valid]/np.linalg.norm(rest[valid],axis=1,keepdims=True)
25
+ b=deformed[valid]/np.linalg.norm(deformed[valid],axis=1,keepdims=True)
26
+ n=result[valid];cosine=np.clip((a*b).sum(1),-1,1);axis=np.cross(a,b)
27
+ regular=cosine>-1+1e-8
28
+ v=axis[regular];x=n[regular]
29
+ n[regular]=x+np.cross(v,x)+np.cross(v,np.cross(v,x))/(1+cosine[regular,None])
30
+ if (~regular).any():
31
+ # The shortest rotation is ambiguous at 180 degrees; select a stable
32
+ # perpendicular axis, retaining unit length and the geometric endpoint.
33
+ old=a[~regular];basis=np.eye(3)[np.argmin(abs(old),axis=1)]
34
+ v=np.cross(old,basis);v/=np.linalg.norm(v,axis=1,keepdims=True)
35
+ x=n[~regular];n[~regular]=2*v*(v*x).sum(1)[:,None]-x
36
+ n/=np.maximum(np.linalg.norm(n,axis=1,keepdims=True),1e-12)
37
+ result[valid]=n
38
+ return result
39
+
40
+ def closed_lid(points,o,band_pixels=36):
41
+ target=o['target'];right=o['right'];up=o['up'];forward=-o['direction'];span=o['span'];size=o['size'];contour=np.asarray(o['contour'])
42
+ xy=np.stack([((points-target)@right/span+.5)*size,(.5-(points-target)@up/span)*size],1);x=xy[:,0];y=xy[:,1]
43
+ upper=np.full(len(points),np.inf);lower=np.full(len(points),-np.inf)
44
+ for a,b in zip(contour,np.roll(contour,-1,axis=0)):
45
+ if abs(b[0]-a[0])<1e-9:continue
46
+ valid=(x>=min(a[0],b[0]))&(x<=max(a[0],b[0]));yy=a[1]+(x-a[0])/(b[0]-a[0])*(b[1]-a[1]);upper[valid]=np.minimum(upper[valid],yy[valid]);lower[valid]=np.maximum(lower[valid],yy[valid])
47
+ valid=np.isfinite(upper)&np.isfinite(lower)&(np.linalg.norm(points-o['center'],axis=1)<o['radius']*2)&((points-o['center'])@forward>0)
48
+ # Invalid columns are neutral; avoid infinities entering the deformation arithmetic.
49
+ upper[~valid]=y[~valid];lower[~valid]=y[~valid]
50
+ middle=upper*.3+lower*.7;top=y<=middle;edge=np.where(top,upper,lower);distance=abs(y-edge)
51
+ weight=np.clip(1-distance/band_pixels,0,1);weight=weight*weight*(3-2*weight);weight*=valid
52
+ movement=(middle-edge)*weight;out=points-up[None,:]*(movement*span/size)[:,None]
53
+ lateral=np.stack([(out-o['center'])@right,(out-o['center'])@up],1);rad=np.linalg.norm(lateral,axis=1)
54
+ depth=np.sqrt(np.maximum(0,o['radius']**2-rad**2));limbus=np.sqrt(o['radius']**2-o['iris']**2)
55
+ depth=np.where(rad<o['iris'],limbus+o['bulge']*np.maximum(0,1-(rad/o['iris'])**2),depth)+o['unit']*.00025
56
+ current=(out-o['center'])@forward
57
+ correction=depth-current
58
+ # Follow the globe where closure requires it, without dragging the whole
59
+ # orbital support band inward. Fade inward fitting at the canthi, where
60
+ # the authored upper and lower contours already meet and do not travel.
61
+ canthus=np.clip((lower-upper)/8,0,1)
62
+ inward=weight**8*canthus
63
+ correction=np.where(correction>0,correction*weight,correction*inward)
64
+ out+=forward[None,:]*correction[:,None]
65
+ return out,weight
66
+
67
+ def conform_open_lid(points,o,band_pixels=36):
68
+ """Push intersecting lid tissue outside the authored globe, never pull skin in.
69
+
70
+ Selection is the explicitly fitted opening and its local support band. This
71
+ is a geometric fit operation, not an anatomical segmentation algorithm.
72
+ """
73
+ forward=-o['direction'];rel=points-o['center']
74
+ xy=np.stack([((points-o['target'])@o['right']/o['span']+.5)*o['size'],(.5-(points-o['target'])@o['up']/o['span'])*o['size']],1)
75
+ distance=np.full(len(points),np.inf)
76
+ contour=np.asarray(o['contour'],float)
77
+ for a,b in zip(contour,np.roll(contour,-1,axis=0)):
78
+ edge=b-a;t=np.clip(((xy-a)*edge).sum(1)/np.dot(edge,edge),0,1)
79
+ distance=np.minimum(distance,np.linalg.norm(xy-a-t[:,None]*edge,axis=1))
80
+ lateral=np.stack([rel@o['right'],rel@o['up']],1);rad=np.linalg.norm(lateral,axis=1);current=rel@forward
81
+ support=(distance<band_pixels)&(current>0)&(rad<o['radius'])
82
+ depth=np.sqrt(np.maximum(0,o['radius']**2-rad**2))+o['unit']*.00025
83
+ limbus=np.sqrt(o['radius']**2-o['iris']**2)
84
+ depth=np.where(rad<o['iris'],limbus+o['bulge']*np.maximum(0,1-(rad/o['iris'])**2)+o['unit']*.00025,depth)
85
+ delta=np.where(support,np.maximum(0,depth-current),0)
86
+ return points+delta[:,None]*forward,delta>0
87
+
88
+ def follow_lid(points,o,direction):
89
+ _,weight=closed_lid(points,o)
90
+ xy=np.stack([((points-o['target'])@o['right']/o['span']+.5)*o['size'],(.5-(points-o['target'])@o['up']/o['span'])*o['size']],1)
91
+ contour=np.asarray(o['contour']);lo,hi=contour[:,0].min(),contour[:,0].max()
92
+ across=np.clip((xy[:,0]-lo)/(hi-lo),0,1)
93
+ canthus=np.sin(np.pi*across)**2
94
+ gains=o.get('lidFollowGains',[.55,.2])
95
+ gain=np.where(xy[:,1]<contour[:,1].mean(),gains[0],gains[1])
96
+ offset=direction*.35*o['radius']*gain*weight*canthus
97
+ moved=points+offset[:,None]*o['up']
98
+ # Refit only vertices affected by this target; a neutral eye's unrelated
99
+ # support vertices must not move when the gaze controller changes.
100
+ fitted,_=conform_open_lid(moved,o);active=abs(offset)>1e-12
101
+ moved[active]=fitted[active]
102
+ return moved,active
103
+
104
+ def add_blinks(doc,data,source,openings,eye_nodes):
105
+ mesh=doc['meshes'][0]
106
+ if any(p.get('targets') for p in mesh['primitives']):raise ValueError('Compose existing facial morphs explicitly before adding eyelid targets')
107
+ prim=mesh['primitives'][0];local=accessor(doc,data,prim['attributes']['POSITION']);world=source_world_points(source,local)
108
+ origin=source_world_points(source,np.zeros((1,3)))[0];basis=(source_world_points(source,np.eye(3))-origin).T;inverse=np.linalg.inv(basis)
109
+ names=['eyeBlink'+o['side'].title() for o in openings];targets=[];counts=[]
110
+ faces=np.concatenate([accessor(doc,data,p['indices']).reshape(-1,3) for p in mesh['primitives']]);original_normals=accessor(doc,data,prim['attributes']['NORMAL'])
111
+ groups=surface_normal_groups(local)
112
+ def geometric_normals(vertices):return surface_normals(vertices,faces,groups)
113
+ base_normals=geometric_normals(local)
114
+ for o in openings:
115
+ closed,weight=closed_lid(world,o);delta=(closed-world)@inverse.T
116
+ adjusted=transport_normals(original_normals,base_normals,geometric_normals(local+delta));normal_delta=adjusted-original_normals;normal_delta[weight==0]=0
117
+ targets.append({'POSITION':append_accessor(doc,data,delta.astype('<f4'),'VEC3',5126),'NORMAL':append_accessor(doc,data,normal_delta.astype('<f4'),'VEC3',5126)});counts.append(int((weight>0).sum()))
118
+ for o in openings:
119
+ for direction,title in [(1,'Up'),(-1,'Down')]:
120
+ followed,active=follow_lid(world,o,direction);delta=(followed-world)@inverse.T
121
+ adjusted=transport_normals(original_normals,base_normals,geometric_normals(local+delta))
122
+ normal_delta=adjusted-original_normals;normal_delta[~active]=0
123
+ targets.append({'POSITION':append_accessor(doc,data,delta.astype('<f4'),'VEC3',5126),'NORMAL':append_accessor(doc,data,normal_delta.astype('<f4'),'VEC3',5126)})
124
+ names.append('eyeLidLook'+title+o['side'].title())
125
+ for p in mesh['primitives']:p['targets']=targets.copy()
126
+ mesh['weights']=[0]*len(names);mesh.setdefault('extras',{})['targetNames']=names
127
+ times=np.array([0,.8,.9,.98,1.12,2],dtype='<f4');values=np.array([0,0,1,1,0,0],dtype='<f4');time_index=append_accessor(doc,data,times,'SCALAR',5126)
128
+ animation={'name':'Eyes Blink','samplers':[],'channels':[]}
129
+ def channel(node,weights):
130
+ i=len(animation['samplers']);animation['samplers'].append({'input':time_index,'output':append_accessor(doc,data,weights.astype('<f4').reshape(-1),'SCALAR',5126),'interpolation':'LINEAR'});animation['channels'].append({'sampler':i,'target':{'node':node,'path':'weights'}})
131
+ parent=next(i for i,n in enumerate(doc['nodes']) if n.get('mesh')==0);channel(parent,np.column_stack([values,values,np.zeros((len(values),len(names)-2))]))
132
+ gaze_bindings=[(parent,names)]
133
+ # Orbital backing stays head-fixed behind the optical envelope. Moving it
134
+ # toward the cornea during closure crosses the recessed iris at partial blinks.
135
+ for node_index,node in enumerate(doc['nodes']):
136
+ if 'mesh' not in node or node['mesh']==0:continue
137
+ m=doc['meshes'][node['mesh']];p=m['primitives'][0];material=doc['materials'][p['material']]
138
+ if material.get('extras',{}).get('eyeLayer') not in ['lid-contact','tear-meniscus','eyelashes','eyelid-skin','canthus-wall']:continue
139
+ side=next(i for i,o in enumerate(openings) if o['side']==material['extras']['eyeSide'])
140
+ p0=accessor(doc,data,p['attributes']['POSITION']);motion_points=accessor(doc,data,p['attributes']['_LID_WALL_ROOT']) if '_LID_WALL_ROOT' in p['attributes'] else accessor(doc,data,p['attributes']['_LASH_ROOT']) if '_LASH_ROOT' in p['attributes'] else p0;world0=source_world_points(source,motion_points);closed,weight=closed_lid(world0,openings[side])
141
+ transport=accessor(doc,data,p['attributes']['_LID_WALL_WEIGHT']).reshape(-1,1) if '_LID_WALL_WEIGHT' in p['attributes'] else 1
142
+ delta=((closed-world0)@inverse.T)*transport
143
+ p['targets']=[{'POSITION':append_accessor(doc,data,delta.astype('<f4'),'VEC3',5126)}]
144
+ def add_patch_normals(target,offset):
145
+ if material['extras']['eyeLayer'] not in ['eyelid-skin','canthus-wall']:return
146
+ faces=accessor(doc,data,p['indices']).reshape(-1,3);groups=surface_normal_groups(p0)
147
+ original=accessor(doc,data,p['attributes']['NORMAL'])
148
+ adjusted=transport_normals(original,surface_normals(p0,faces,groups),surface_normals(p0+offset,faces,groups))
149
+ target['NORMAL']=append_accessor(doc,data,(adjusted-original).astype('<f4'),'VEC3',5126)
150
+ add_patch_normals(p['targets'][-1],delta)
151
+ local_names=[names[side]]
152
+ for direction,title in [(1,'Up'),(-1,'Down')]:
153
+ followed,_=follow_lid(world0,openings[side],direction);delta=((followed-world0)@inverse.T)*transport
154
+ p['targets'].append({'POSITION':append_accessor(doc,data,delta.astype('<f4'),'VEC3',5126)})
155
+ add_patch_normals(p['targets'][-1],delta)
156
+ local_names.append('eyeLidLook'+title+openings[side]['side'].title())
157
+ m['weights']=[0]*len(local_names);m['extras']={'targetNames':local_names}
158
+ channel(node_index,np.column_stack([values,np.zeros((len(values),2))]));gaze_bindings.append((node_index,local_names))
159
+ doc.setdefault('animations',[]).append(animation)
160
+ gaze={'name':'Eyes Gaze','samplers':[],'channels':[]};gaze_times=append_accessor(doc,data,np.array([0,1,2,3,4],dtype='<f4'),'SCALAR',5126)
161
+ for i,node in enumerate(eye_nodes):
162
+ rest=Rotation.from_quat(doc['nodes'][node]['rotation']);rotations=np.array([(rest*Rotation.from_euler('yx',[yaw,pitch])).as_quat() for yaw,pitch in [(0,0),(.2,0),(0,.1),(-.2,0),(0,0)]],dtype='<f4')
163
+ gaze['samplers'].append({'input':gaze_times,'output':append_accessor(doc,data,rotations,'VEC4',5126),'interpolation':'LINEAR'});gaze['channels'].append({'sampler':i,'target':{'node':node,'path':'rotation'}})
164
+ for node,target_names in gaze_bindings:
165
+ weights=np.zeros((5,len(target_names)),dtype='<f4')
166
+ for j,name in enumerate(target_names):
167
+ if name.startswith('eyeLidLookDown'):weights[2,j]=.1/.35
168
+ sampler=len(gaze['samplers']);gaze['samplers'].append({'input':gaze_times,'output':append_accessor(doc,data,weights.reshape(-1),'SCALAR',5126),'interpolation':'LINEAR'})
169
+ gaze['channels'].append({'sampler':sampler,'target':{'node':node,'path':'weights'}})
170
+ doc['animations'].append(gaze)
171
+ return {'controls':names,'affectedVertices':counts,'method':'contour-defined eyelid closure with surface-following depth and smooth outer falloff','normals':'shortest-frame rotation transports authored normals with area-weighted deformed surface normals'}
@@ -0,0 +1,173 @@
1
+ """Reconstruct a lid band between two explicitly authored ocular contours."""
2
+ import numpy as np
3
+ from eyelid_morph import conform_open_lid,surface_normals,surface_normal_groups
4
+
5
+ def authored_corner_support(hit,plane,opening):
6
+ """Apply an explicitly reviewed corner depth, fading to source support."""
7
+ result=np.asarray(hit,float).copy();forward=-opening['direction']
8
+ pixel=np.array([((plane-opening['target'])@opening['right']/opening['span']+.5)*opening['size'],(.5-(plane-opening['target'])@opening['up']/opening['span'])*opening['size']])
9
+ for patch in opening.get('cornerDepths',[]):
10
+ radius=patch['radiusM']*opening['unit']/opening['span']*opening['size']
11
+ if radius<=0 or not np.isfinite([radius,patch['depthM']]).all():raise ValueError('Invalid authored corner depth')
12
+ t=np.clip(1-np.linalg.norm(pixel-np.asarray(patch['pixel']))/radius,0,1);weight=t*t*(3-2*t)
13
+ result+=forward*((patch['depthM']*opening['unit']-(result-opening['center'])@forward)*weight)
14
+ return result
15
+
16
+ def lid_contact_depth(radial_distance,radius,globe_depth,source_depth):
17
+ """Beyond the sphere, retain authored face support instead of collapsing to zero."""
18
+ t=np.clip((radial_distance/radius-.85)/.15,0,1)
19
+ t=t*t*(3-2*t)
20
+ return globe_depth+t*np.maximum(0,source_depth-globe_depth)
21
+
22
+ def corner_contact_depth(radial,globe,source,plane,o):
23
+ depth=lid_contact_depth(radial,o['radius'],globe,source)
24
+ for patch in o.get('cornerDepths',[]):
25
+ pixel=np.asarray(patch['pixel']);corner=o['target']+o['right']*(pixel[0]/o['size']-.5)*o['span']+o['up']*(.5-pixel[1]/o['size'])*o['span']
26
+ rel=corner-o['center'];end=np.linalg.norm(rel-(rel@o['direction'])*o['direction']);start=.85*o['radius']
27
+ if end<=start:raise ValueError('Authored corner must lie outside the inner globe support')
28
+ axis=rel-(rel@o['direction'])*o['direction'];axis/=end
29
+ lateral=plane-o['center'];lateral-=((lateral@o['direction'])*o['direction'])
30
+ distance=np.linalg.norm(lateral-(lateral@axis)*axis)
31
+ width=patch['radiusM']*o['unit']
32
+ w=np.clip((width-distance)/(width*.5),0,1);w=w*w*(3-2*w)
33
+ if lateral@axis<=0:w=0
34
+ if radial>start and w>0:
35
+ t=np.clip((radial-start)/(end-start),0,1);z=np.sqrt(o['radius']**2-start**2);slope=-start/z
36
+ curve=(2*t**3-3*t*t+1)*z+(t**3-2*t*t+t)*slope*(end-start)+(-2*t**3+3*t*t)*patch['depthM']*o['unit']
37
+ # Preserve the globe clearance and blend away from the authored corner.
38
+ depth=max(globe,depth*(1-w)+curve*w)
39
+ return depth
40
+
41
+ def sample_paired_contours(inner,outer,projected_triangles=None):
42
+ """Preserve source-edge breakpoints on the reconstructed band's outer rim."""
43
+ inner=np.asarray(inner,float);outer=np.asarray(outer,float);inside=[];outside=[]
44
+ if projected_triangles is not None:
45
+ a=projected_triangles.reshape(-1,2)
46
+ b=np.roll(projected_triangles,-1,axis=1).reshape(-1,2);edge=b-a
47
+ for i,p in enumerate(outer):
48
+ q=outer[(i+1)%len(outer)];d=q-p;ts=list(np.arange(8)/8)
49
+ if projected_triangles is not None:
50
+ offset=a-p;den=d[0]*edge[:,1]-d[1]*edge[:,0];valid=abs(den)>1e-12
51
+ safe=np.where(valid,den,1)
52
+ t=(offset[:,0]*edge[:,1]-offset[:,1]*edge[:,0])/safe
53
+ u=(offset[:,0]*d[1]-offset[:,1]*d[0])/safe
54
+ valid&=(t>0)&(t<1)&(u>=-1e-10)&(u<=1+1e-10)
55
+ ts.extend(t[valid])
56
+ collinear=(~(abs(den)>1e-12))&(abs(offset[:,0]*d[1]-offset[:,1]*d[0])<1e-10)
57
+ for endpoints in [a[collinear],b[collinear]]:
58
+ along=(endpoints-p)@d/np.dot(d,d);ts.extend(along[(along>0)&(along<1)])
59
+ t=np.unique(np.round(ts,12));t=t[t<1]
60
+ outside.extend(p[None,:]*(1-t[:,None])+q[None,:]*t[:,None])
61
+ inside.extend(inner[i][None,:]*(1-t[:,None])+inner[(i+1)%len(inner)][None,:]*t[:,None])
62
+ return np.asarray(inside),np.asarray(outside)
63
+
64
+ def sample_hit(origin,direction,points,triangles,uv,rgb,normals):
65
+ a=points[triangles[:,0]];e1=points[triangles[:,1]]-a;e2=points[triangles[:,2]]-a
66
+ h=np.cross(direction,e2);det=(e1*h).sum(1);safe=np.where(abs(det)>1e-12,det,1)
67
+ s=origin-a;u=(s*h).sum(1)/safe;q=np.cross(s,e1);v=(q*direction).sum(1)/safe;t=(e2*q).sum(1)/safe
68
+ valid=(abs(det)>1e-12)&(u>=0)&(v>=0)&(u+v<=1)&(t>0)
69
+ if not valid.any():raise ValueError('Authored outer lid contour misses the source surface')
70
+ index=np.argmin(np.where(valid,t,np.inf));tex=np.array([1-u[index]-v[index],u[index],v[index]])@uv[triangles[index]]
71
+ x=np.clip(tex[0]*rgb.shape[1]-.5,0,rgb.shape[1]-1);y=np.clip(tex[1]*rgb.shape[0]-.5,0,rgb.shape[0]-1)
72
+ ix,iy=int(x),int(y);jx,jy=min(ix+1,rgb.shape[1]-1),min(iy+1,rgb.shape[0]-1)
73
+ c=(rgb[iy,ix]*(1-(x-ix))+rgb[iy,jx]*(x-ix))*(1-(y-iy))+(rgb[jy,ix]*(1-(x-ix))+rgb[jy,jx]*(x-ix))*(y-iy)
74
+ c=c/255;c=np.where(c<=.04045,c/12.92,((c+.055)/1.055)**2.4)
75
+ normal=np.array([1-u[index]-v[index],u[index],v[index]])@normals[triangles[index]]
76
+ normal/=max(np.linalg.norm(normal),1e-12)
77
+ return origin+direction*t[index],c,normal
78
+
79
+ def fair_lid_depth(vertices,faces,forward,boundary):
80
+ """Harmonic depth fairing on the projected mesh, with both rims fixed."""
81
+ from scipy.sparse import coo_matrix,diags
82
+ from scipy.sparse.linalg import spsolve
83
+ vertices=np.asarray(vertices);depth=vertices@forward
84
+ xy=vertices-depth[:,None]*forward
85
+ edges=np.unique(np.sort(np.concatenate([faces[:,[0,1]],faces[:,[1,2]],faces[:,[2,0]]]),axis=1),axis=0)
86
+ length=np.linalg.norm(xy[edges[:,0]]-xy[edges[:,1]],axis=1)
87
+ weight=1/np.maximum(length,1e-10)
88
+ row=np.r_[edges[:,0],edges[:,1]];col=np.r_[edges[:,1],edges[:,0]]
89
+ adjacency=coo_matrix((np.r_[weight,weight],(row,col)),shape=(len(vertices),len(vertices))).tocsr()
90
+ lap=diags(np.asarray(adjacency.sum(1)).ravel())-adjacency
91
+ free=np.flatnonzero(~boundary);fixed=np.flatnonzero(boundary)
92
+ fitted=depth.copy();fitted[free]=spsolve(lap[free][:,free],-lap[free][:,fixed]@depth[fixed])
93
+ result=vertices+(fitted-depth)[:,None]*forward;result[boundary]=vertices[boundary]
94
+ return result
95
+
96
+ def build_lid_band(o,outer,points,triangles,uv,rgb,inverse,origin_basis,source_normals=None,skin_samples=None,outer_edge_sampling=None,depth_fairing=False):
97
+ inner=np.asarray(o['contour'],float);outer=np.asarray(outer,float)
98
+ if outer.shape!=inner.shape:raise ValueError('Lid contours need paired authored vertices')
99
+ area=np.sum(outer[:,0]*np.roll(outer[:,1],-1)-outer[:,1]*np.roll(outer[:,0],-1))
100
+ sign=1 if area>0 else -1
101
+ for a,b in zip(outer,np.roll(outer,-1,axis=0)):
102
+ edge=b-a
103
+ if np.any(sign*(edge[0]*(inner[:,1]-a[1])-edge[1]*(inner[:,0]-a[0]))<0):raise ValueError('Outer lid contour must contain the optical aperture')
104
+ direction=o['direction'];forward=-direction;right=o['right'];up=o['up'];target=o['target'];span=o['span'];size=o['size']
105
+ projection=np.stack([((points-target)@right/span+.5)*size,(.5-(points-target)@up/span)*size],1)
106
+ lo=outer.min(0)-1;hi=outer.max(0)+1;projected=projection[triangles]
107
+ candidates=(projected.min(1)<=hi).all(1)&(projected.max(1)>=lo).all(1)
108
+ candidates&=np.linalg.norm(points[triangles].mean(1)-o['center'],axis=1)<o['radius']*3
109
+ local_triangles=triangles[candidates]
110
+ if not len(local_triangles):raise ValueError('No source triangles behind authored lid band')
111
+ if outer_edge_sampling not in [None,'source-intersections-v1']:raise ValueError('Unsupported lid boundary sampling')
112
+ if source_normals is None:source_normals=surface_normals(points,triangles,surface_normal_groups(points))
113
+ inner,outer=sample_paired_contours(inner,outer,projection[local_triangles] if outer_edge_sampling else None)
114
+ edge_points=[];edge_colors=[];edge_normals=[];inside=[]
115
+ ray_offset=np.ptp(points,axis=0).max()*2
116
+ for p,q in zip(inner,outer):
117
+ plane=target+right*((q[0]/size)-.5)*span+up*(.5-q[1]/size)*span
118
+ hit,color,normal=sample_hit(plane-direction*ray_offset,direction,points,local_triangles,uv,rgb,source_normals)
119
+ edge_points.append(hit);edge_colors.append(color);edge_normals.append(normal)
120
+ plane=target+right*(p[0]/size-.5)*span+up*(.5-p[1]/size)*span
121
+ xy=np.array([(plane-o['center'])@right,(plane-o['center'])@up]);r=np.linalg.norm(xy)
122
+ depth=np.sqrt(max(0,o['radius']**2-r*r))
123
+ if r<o['iris']:depth=np.sqrt(o['radius']**2-o['iris']**2)+o['bulge']*max(0,1-(r/o['iris'])**2)
124
+ # Canthus support belongs to the aperture point, not the outer band's
125
+ # cheek/upper-fold sample. Reusing the latter pulls the inner corner
126
+ # toward a different facial depth and creates a visible ramp.
127
+ support_hit=hit
128
+ if r>o['radius']*.85:
129
+ support_hit,_,_=sample_hit(plane-direction*ray_offset,direction,points,local_triangles,uv,rgb,source_normals)
130
+ support_hit=authored_corner_support(support_hit,plane,o)
131
+ depth=corner_contact_depth(r,depth,(support_hit-o['center'])@forward,plane,o)
132
+ inside.append(o['center']+right*xy[0]+up*xy[1]+forward*(depth+.00025*o['unit']))
133
+ edge_points=np.asarray(edge_points);inside=np.asarray(inside);edge_colors=np.asarray(edge_colors);edge_normals=np.asarray(edge_normals)
134
+ inner_colors=edge_colors.copy()
135
+ if skin_samples is not None:
136
+ samples=np.asarray(skin_samples,float)
137
+ if samples.ndim!=2 or samples.shape[1]!=2 or len(samples)<3 or not np.isfinite(samples).all():
138
+ raise ValueError('Reconstructed lid skin requires at least three explicit valid donor pixels')
139
+ donors=[];donor_colors=[]
140
+ for q in samples:
141
+ plane=target+right*((q[0]+.5)/size-.5)*span+up*(.5-(q[1]+.5)/size)*span
142
+ hit,color,_=sample_hit(plane-direction*ray_offset,direction,points,triangles,uv,rgb,source_normals)
143
+ donors.append(hit);donor_colors.append(color)
144
+ distances=np.linalg.norm(inside[:,None,:]-np.asarray(donors)[None,:,:],axis=2)
145
+ weights=1/np.maximum(distances,1e-8)**2;weights/=weights.sum(1,keepdims=True)
146
+ inner_colors=weights@np.asarray(donor_colors)
147
+ chord=inside-edge_points
148
+ inner_normals=inside-o['center'];inner_normals/=np.maximum(np.linalg.norm(inner_normals,axis=1,keepdims=True),1e-12)
149
+ outer_tangent=chord-edge_normals*(chord*edge_normals).sum(1)[:,None]
150
+ inner_tangent=chord-inner_normals*(chord*inner_normals).sum(1)[:,None]
151
+ vertices=[];colors=[];faces=[];count=len(inner);rings=9
152
+ for row,t in enumerate(np.linspace(0,1,rings)):
153
+ # Outer boundary stays on the sampled source. The inner boundary follows
154
+ # the optical envelope. Existing source eye paint is not resampled inside.
155
+ vertices.extend((2*t**3-3*t*t+1)*edge_points+(t**3-2*t*t+t)*outer_tangent+(-2*t**3+3*t*t)*inside+(t**3-t*t)*inner_tangent);blend=t*t*(3-2*t);colors.extend(edge_colors*(1-blend)+inner_colors*blend)
156
+ if row:
157
+ for j in range(count):
158
+ a=(row-1)*count+j;b=(row-1)*count+(j+1)%count;faces.extend([[a,b,a+count],[b,b+count,a+count]])
159
+ world=np.asarray(vertices);faces=np.asarray(faces)
160
+ if depth_fairing:
161
+ # Hermite tangents can also bunch the projected columns at the canthi.
162
+ # Use the paired contours' straight projected strips for the solve.
163
+ linear=np.concatenate([edge_points*(1-t)+inside*t for t in np.linspace(0,1,rings)])
164
+ world=linear+((world-linear)@forward)[:,None]*forward
165
+ boundary=np.zeros(len(world),bool);boundary[:count]=True;boundary[-count:]=True
166
+ world=fair_lid_depth(world,faces,forward,boundary)
167
+ world,_=conform_open_lid(world,o);local=(world-origin_basis)@inverse.T
168
+ cross=np.cross(local[faces[:,1]]-local[faces[:,0]],local[faces[:,2]]-local[faces[:,0]])
169
+ if np.mean(cross@(inverse@forward))<0:faces=faces[:,::-1]
170
+ normals=surface_normals(local,faces,surface_normal_groups(local))
171
+ outer_local_normals=edge_normals@np.linalg.inv(inverse);outer_local_normals/=np.maximum(np.linalg.norm(outer_local_normals,axis=1,keepdims=True),1e-12)
172
+ normals[:count]=outer_local_normals
173
+ return local,normals,np.asarray(colors),faces
@@ -0,0 +1,51 @@
1
+ """Offline front-face atlas rasterization and landmark correspondence.
2
+
3
+ Coordinates are authoring space, not guesses made by the renderer. Rasterization
4
+ keeps the closest triangle and its interpolated attributes, including UV seams.
5
+ """
6
+ import numpy as np
7
+ from scipy.ndimage import map_coordinates
8
+ from scipy.spatial import Delaunay
9
+ from scipy.interpolate import RBFInterpolator
10
+
11
+
12
+ def rasterize(points, triangles, values, bounds, size=1024):
13
+ lo=np.asarray(bounds[:2]);hi=np.asarray(bounds[2:])
14
+ xy=(points[:,:2]-lo)/(hi-lo)*(size-1)
15
+ image=np.zeros((size,size,values.shape[1]),np.float32)
16
+ depth=np.full((size,size),-np.inf,np.float32)
17
+ for ids in triangles:
18
+ t=xy[ids];d=points[ids,2]
19
+ if not np.isfinite(t).all():continue
20
+ xmin,ymin=np.maximum(np.floor(t.min(0)).astype(int),0)
21
+ xmax,ymax=np.minimum(np.ceil(t.max(0)).astype(int),size-1)
22
+ if xmax<xmin or ymax<ymin:continue
23
+ den=(t[1,1]-t[2,1])*(t[0,0]-t[2,0])+(t[2,0]-t[1,0])*(t[0,1]-t[2,1])
24
+ if abs(den)<1.e-8:continue
25
+ yy,xx=np.mgrid[ymin:ymax+1,xmin:xmax+1]
26
+ a=((t[1,1]-t[2,1])*(xx-t[2,0])+(t[2,0]-t[1,0])*(yy-t[2,1]))/den
27
+ b=((t[2,1]-t[0,1])*(xx-t[2,0])+(t[0,0]-t[2,0])*(yy-t[2,1]))/den
28
+ c=1-a-b;z=a*d[0]+b*d[1]+c*d[2]
29
+ dst=depth[ymin:ymax+1,xmin:xmax+1]
30
+ mask=(a>=-1.e-5)&(b>=-1.e-5)&(c>=-1.e-5)&(z>dst)
31
+ dst[mask]=z[mask]
32
+ field=image[ymin:ymax+1,xmin:xmax+1]
33
+ field[mask]=(a[...,None]*values[ids[0]]+b[...,None]*values[ids[1]]+c[...,None]*values[ids[2]])[mask]
34
+ return image,np.isfinite(depth)
35
+
36
+
37
+ def sample(image,uv):
38
+ coords=np.array([uv[...,1]*(image.shape[0]-1),uv[...,0]*(image.shape[1]-1)])
39
+ if image.ndim==2:return map_coordinates(image,coords,order=1,mode='nearest')
40
+ return np.stack([map_coordinates(image[:,:,i],coords,order=1,mode='nearest') for i in range(image.shape[2])],axis=-1)
41
+
42
+
43
+ def register(points,target_landmarks,source_landmarks):
44
+ """Smooth thin-plate anatomical correspondence; rejects points outside the hull."""
45
+ tri=Delaunay(target_landmarks);ids=tri.find_simplex(points)
46
+ landmarks=np.asarray(target_landmarks)
47
+ center=landmarks.mean(0);scale=np.ptp(landmarks,axis=0)
48
+ # Smooth first derivatives avoid shading seams at affine triangle boundaries.
49
+ warp=RBFInterpolator((landmarks-center)/scale,np.asarray(source_landmarks),kernel='thin_plate_spline',smoothing=0.0001)
50
+ mapped=np.concatenate([warp((chunk-center)/scale) for chunk in np.array_split(points,64)])
51
+ return mapped,ids>=0
@@ -0,0 +1,112 @@
1
+ """Repair an explicitly reviewed surface patch, retaining source rig correspondence.
2
+
3
+ The agent selects the patch and a clean surrounding annulus. No defect detection runs.
4
+ """
5
+ import hashlib,json
6
+ from pathlib import Path
7
+ import numpy as np
8
+ from scipy.ndimage import distance_transform_edt
9
+ from glb_arrays import accessor
10
+ from glb_output import append_accessor,append_texture,image_array
11
+ from vision_materials import source_world_points
12
+ from eyelid_morph import surface_normals,surface_normal_groups,transport_normals
13
+ from texture_repair import harmonic_fill,surface_swatch_fill
14
+
15
+ def evidence_texel(uv,shape):
16
+ """Evidence uses Blender UVs; decoded image arrays have a top-left origin."""
17
+ return np.clip((np.array([1-uv[1],uv[0]])*np.array(shape)).astype(int),0,np.array(shape)-1)
18
+
19
+ def quadratic_patch(points,pixels,direction,center,full_radius,outer_radius,fit_ring,depth_center,depth_tolerance):
20
+ points=np.asarray(points,float);pixels=np.asarray(pixels,float);direction=np.asarray(direction,float)
21
+ if not 0<full_radius<outer_radius<fit_ring[0]<fit_ring[1]:raise ValueError('Repair and clean support annulus must be separate')
22
+ q=(pixels-center)/fit_ring[1];r=np.linalg.norm(pixels-center,axis=1);depth=points@direction
23
+ visible=abs(depth-depth_center)<depth_tolerance
24
+ fit=visible&(r>=fit_ring[0])&(r<=fit_ring[1])
25
+ if fit.sum()<30:raise ValueError('Insufficient clean surface support for authored geometry repair')
26
+ def basis(v):return np.stack([np.ones(len(v)),v[:,0],v[:,1],v[:,0]**2,v[:,0]*v[:,1],v[:,1]**2],1)
27
+ coefficients,_,rank,_=np.linalg.lstsq(basis(q[fit]),depth[fit],rcond=None)
28
+ if rank!=6:raise ValueError('Degenerate support annulus')
29
+ weight=np.clip((outer_radius-r)/(outer_radius-full_radius),0,1);weight=weight*weight*(3-2*weight);weight*=visible
30
+ delta=(basis(q)@coefficients-depth)*weight
31
+ result=points+delta[:,None]*direction
32
+ result[weight==0]=points[weight==0]
33
+ residual=basis(q[fit])@coefficients-depth[fit]
34
+ return result,weight,{'supportVertices':int(fit.sum()),'movedVertices':int((weight>0).sum()),'maximumDisplacement':float(abs(delta).max()),'supportResidualP95':float(np.percentile(abs(residual),95))}
35
+
36
+ def apply_geometry_repair(doc,data,profile_path,out,source_sha):
37
+ profile_path=Path(profile_path);profile=json.loads(profile_path.read_text())
38
+ if profile['sourceSha256']!=source_sha:raise ValueError('Geometry repair belongs to a different source')
39
+ evidence=Path(profile['evidence']);manifest=json.loads((evidence/'manifest.json').read_text())
40
+ if manifest['sourceSha256']!=source_sha:raise ValueError('Geometry repair evidence source mismatch')
41
+ view=next(v for v in manifest['views'] if v['name']==profile['view'])
42
+ image=evidence/(profile['view']+'.png')
43
+ if hashlib.sha256(image.read_bytes()).hexdigest()!=manifest['evidenceHashes'][image.name]:raise ValueError('Geometry repair evidence image changed')
44
+ primitives=doc['meshes'][0]['primitives'];attributes=primitives[0]['attributes']
45
+ if any(p['attributes']['POSITION']!=attributes['POSITION'] for p in primitives):raise ValueError('Repair requires shared source positions')
46
+ local=accessor(doc,data,attributes['POSITION']);world=source_world_points(doc,local)
47
+ source_height=float(np.ptp(world[:,2]));unit=source_height/profile['heightM'];center=(world.min(0)+world.max(0))*.5
48
+ direction=np.asarray(view['direction'],float);direction/=np.linalg.norm(direction)
49
+ up=np.asarray(view.get('up',[0,0,1]),float);right=np.cross(direction,up);right/=np.linalg.norm(right);up=np.cross(right,direction)
50
+ target=center+source_height*np.asarray(view.get('targetOffset',[0,0,0]));span=source_height*view['span'];size=manifest['size']
51
+ def project(p):return np.stack([((p-target)@right/span+.5)*size,(.5-(p-target)@up/span)*size],-1)
52
+ pixel_center=np.asarray(profile['centerPx'],float)
53
+ on_plane=target+right*(pixel_center[0]/size-.5)*span+up*(.5-pixel_center[1]/size)*span
54
+ faces=np.concatenate([accessor(doc,data,p['indices']).reshape(-1,3) for p in primitives])
55
+ from eye_assets import ray_hit
56
+ hit=ray_hit(on_plane-direction*source_height*2,direction,world,faces);depth_center=hit@direction
57
+ changed,weight,receipt=quadratic_patch(world,project(world),direction,pixel_center,profile['fullRadiusPx'],profile['outerRadiusPx'],profile['supportRingPx'],depth_center,profile['depthToleranceM']*unit)
58
+ if receipt['maximumDisplacement']/unit>profile['maximumDisplacementM']:raise ValueError('Geometry correction exceeds the authored displacement bound')
59
+ origin=source_world_points(doc,np.zeros((1,3)))[0];basis=(source_world_points(doc,np.eye(3))-origin).T
60
+ corrected=local+(changed-world)@np.linalg.inv(basis).T
61
+ groups=surface_normal_groups(local);old_normals=accessor(doc,data,attributes['NORMAL'])
62
+ new_geometric=surface_normals(corrected,faces,groups)
63
+ normals=transport_normals(old_normals,surface_normals(local,faces,groups),new_geometric)
64
+ normals=normals*(1-weight[:,None])+new_geometric*weight[:,None]
65
+ normals/=np.maximum(np.linalg.norm(normals,axis=1,keepdims=True),1e-12)
66
+ touched=np.zeros(len(local),bool);touched[np.unique(faces[np.any(weight[faces]>0,axis=1)])]=True;normals[~touched]=old_normals[~touched]
67
+ position_index=append_accessor(doc,data,corrected.astype('<f4'),'VEC3',5126);normal_index=append_accessor(doc,data,normals.astype('<f4'),'VEC3',5126)
68
+ for p in primitives:
69
+ p['attributes'].setdefault('_BIND_SOURCE_POSITION',p['attributes']['POSITION'])
70
+ p['attributes']['POSITION']=position_index;p['attributes']['NORMAL']=normal_index
71
+ # Exact UV-surface positions come from the reviewed dense evidence bake.
72
+ surfaces=Path(profile['surfaceEvidence']);mask_report=json.loads((surfaces.parent/'report.json').read_text())
73
+ if mask_report['sourceSha256']!=source_sha:raise ValueError('Repair UV evidence belongs to a different source')
74
+ atlas=np.load(surfaces);positions=atlas['position'];occupied=atlas['occupied'];pixels=project(positions)
75
+ r=np.linalg.norm(pixels-pixel_center,axis=-1);near=occupied&(abs(positions@direction-depth_center)<profile['depthToleranceM']*unit)
76
+ distance,nearest=distance_transform_edt(~occupied,return_indices=True)
77
+ gutter=(~occupied)&(distance<=4)
78
+ r[gutter]=r[tuple(nearest[:,gutter])];near[gutter]=near[tuple(nearest[:,gutter])]
79
+ positions=positions.copy();positions[gutter]=positions[tuple(nearest[:,gutter])]
80
+ del distance,nearest,pixels
81
+ material=doc['materials'][primitives[0]['material']];texture=material['pbrMetallicRoughness']['baseColorTexture']['index'];color=image_array(doc,data,texture).copy()
82
+ if color.shape[:2]!=occupied.shape:raise ValueError('Repair atlas and base color resolution differ')
83
+ paint=profile['paint'];cleanup=near&(r<paint['cleanupRadiusPx'])
84
+ if paint.get('skinSamplesPx'):
85
+ uv_path=evidence/(profile['view']+'.npz')
86
+ if hashlib.sha256(uv_path.read_bytes()).hexdigest()!=manifest['evidenceHashes'][uv_path.name]:raise ValueError('Paint donor correspondence changed')
87
+ correspondence=np.load(uv_path);donors=[]
88
+ for x,y in paint['skinSamplesPx']:
89
+ if correspondence['material'][y,x]!=0:raise ValueError('Paint donor is outside the reviewed source material')
90
+ uv=correspondence['uv'][y,x];yx=evidence_texel(uv,occupied.shape)
91
+ if not occupied[tuple(yx)]:
92
+ region=occupied[max(0,yx[0]-2):yx[0]+3,max(0,yx[1]-2):yx[1]+3];candidates=np.argwhere(region)+np.maximum(0,yx-2)
93
+ if not len(candidates):raise ValueError('Paint donor misses occupied UV surface')
94
+ yx=candidates[np.argmin(((candidates-yx)**2).sum(1))]
95
+ donors.append(yx)
96
+ color=surface_swatch_fill(color[:,:,:3],cleanup,positions,donors,allowed_mask=near,blend_width=paint.get('blendWidthM',.001)*unit)
97
+ else:color=harmonic_fill(color[:,:,:3],cleanup)
98
+ alpha=np.clip((paint['radiusPx']+.5-r),0,1)*near
99
+ original=color.astype(float)/255;original=np.where(original<=.04045,original/12.92,((original+.055)/1.055)**2.4)
100
+ pigment=np.asarray(paint['colorSRGB'],float)/255;pigment=np.where(pigment<=.04045,pigment/12.92,((pigment+.055)/1.055)**2.4)
101
+ mixed=original*(1-alpha[:,:,None])+pigment*alpha[:,:,None];encoded=np.where(mixed<=.0031308,mixed*12.92,1.055*mixed**(1/2.4)-.055)
102
+ painted=color.copy();active=alpha>0;painted_count=int(active.sum());painted[active]=np.round(np.clip(encoded[active],0,1)*255).astype(np.uint8)
103
+ material['pbrMetallicRoughness']['baseColorTexture']={'index':append_texture(doc,data,painted,'geometry-repair-base-color',out)}
104
+ if material.get('normalTexture'):
105
+ normal=image_array(doc,data,material['normalTexture']['index']).copy()
106
+ if normal.shape[:2]!=occupied.shape:raise ValueError('Repair normal atlas resolution differs')
107
+ w=np.clip((profile['outerRadiusPx']-r)/(profile['outerRadiusPx']-profile['fullRadiusPx']),0,1);w=w*w*(3-2*w)*near
108
+ active=w>0;normal[active,:3]=np.round(normal[active,:3]*(1-w[active,None])+np.array([128,128,255])*w[active,None]).astype(np.uint8)
109
+ material['normalTexture']={**material['normalTexture'],'index':append_texture(doc,data,normal,'geometry-repair-normal',out)}
110
+ receipt.update(method='agent-authored quadratic patch from clean surrounding surface; feathered boundary',sourceSha256=source_sha,profileSha256=hashlib.sha256(profile_path.read_bytes()).hexdigest(),maximumDisplacementM=receipt.pop('maximumDisplacement')/unit,supportResidualP95M=receipt.pop('supportResidualP95')/unit,paintedTexels=painted_count,topologyAndUVsPreserved=True,sourceBindingPreserved=True)
111
+ (Path(out)/'geometry-repair.json').write_text(json.dumps(receipt,indent=2)+'\n')
112
+ return receipt
@@ -0,0 +1,13 @@
1
+ """GLB binary array readers usable from Python and Blender without Pillow."""
2
+ import json,struct
3
+ import numpy as np
4
+
5
+ def read(path):
6
+ raw=path.read_bytes();n=struct.unpack_from('<I',raw,12)[0];doc=json.loads(raw[20:20+n]);off=20+n;size=struct.unpack_from('<I',raw,off)[0]
7
+ return doc,bytearray(raw[off+8:off+8+size])
8
+
9
+ def accessor(doc,data,index):
10
+ a=doc['accessors'][index];v=doc['bufferViews'][a['bufferView']]
11
+ dt=np.dtype({5126:'<f4',5125:'<u4',5123:'<u2',5121:'u1'}[a['componentType']]);w={'SCALAR':1,'VEC2':2,'VEC3':3,'VEC4':4,'MAT4':16}[a['type']]
12
+ return np.ndarray((a['count'],w),dtype=dt,buffer=data,offset=v.get('byteOffset',0)+a.get('byteOffset',0),strides=(v.get('byteStride',dt.itemsize*w),dt.itemsize)).copy()
13
+
@@ -0,0 +1,21 @@
1
+ """Shared embedded-GLB output helpers; no semantic classification."""
2
+ import io
3
+ import numpy as np
4
+ from PIL import Image
5
+
6
+ def image_array(doc,data,texture,mode='RGB'):
7
+ im=doc['images'][doc['textures'][texture]['source']];v=doc['bufferViews'][im['bufferView']];start=v.get('byteOffset',0)
8
+ return np.array(Image.open(io.BytesIO(data[start:start+v['byteLength']])).convert(mode))
9
+
10
+ def append_view(doc,data,raw):
11
+ data.extend(b'\0'*((-len(data))%4));offset=len(data);data.extend(raw)
12
+ doc['bufferViews'].append({'buffer':0,'byteOffset':offset,'byteLength':len(raw)});return len(doc['bufferViews'])-1
13
+
14
+ def append_accessor(doc,data,values,kind,component):
15
+ view=append_view(doc,data,values.tobytes());doc['accessors'].append({'bufferView':view,'componentType':component,'count':len(values),'type':kind});return len(doc['accessors'])-1
16
+
17
+ def append_texture(doc,data,pixels,name,folder):
18
+ im=Image.fromarray(pixels.astype(np.uint8));raw=io.BytesIO();im.save(raw,format='PNG');(folder/(name+'.png')).write_bytes(raw.getvalue())
19
+ if not doc.get('samplers'):doc['samplers']=[{}]
20
+ view=append_view(doc,data,raw.getvalue());doc['images'].append({'bufferView':view,'mimeType':'image/png','name':name});doc['textures'].append({'source':len(doc['images'])-1,'sampler':0});return len(doc['textures'])-1
21
+
@@ -0,0 +1,66 @@
1
+ """Trace a reviewed image pixel to its UV texel and contributing authoring views.
2
+
3
+ Read-only geometry/provenance diagnostic. Never chooses or changes an anatomical label.
4
+ Requires a dense bake made with --surface-data.
5
+ """
6
+ import argparse,hashlib,json
7
+ from pathlib import Path
8
+ import numpy as np
9
+ from PIL import Image
10
+ from skin_views import LABELS
11
+
12
+ def digest(path):return hashlib.sha256(path.read_bytes()).hexdigest()
13
+
14
+ def texel_footprint(positions,occupied,row,col,source_height,physical_height):
15
+ if not np.isfinite(physical_height) or physical_height<=0:raise ValueError('Physical height must be positive and finite')
16
+ if not np.isfinite(source_height) or source_height<=0:raise ValueError('Source height must be positive and finite')
17
+ result=[];h,w=occupied.shape
18
+ for dx,dy in [(-1,0),(1,0),(0,-1),(0,1)]:
19
+ x=col+dx;y=row+dy
20
+ if 0<=x<w and 0<=y<h and occupied[y,x]:
21
+ result.append({'atlasOffset':[dx,dy],'distanceM':float(np.linalg.norm(positions[y,x]-positions[row,col])*physical_height/source_height)})
22
+ return {'physicalHeightM':physical_height,'neighbours':result,'interpretation':'Local occupied-texel spacing, not guaranteed detail resolution. UV discontinuities can make a neighbour distant; inspect individual values.'}
23
+
24
+ def inspect(evidence,masks,view_name,x,y,physical_height=None):
25
+ evidence=Path(evidence);masks=Path(masks)
26
+ manifest=json.loads((evidence/'manifest.json').read_text())
27
+ report=json.loads((masks/'report.json').read_text())
28
+ archived=json.loads((masks/'dense-request.json').read_text())
29
+ for key in ['sourceSha256','size','views','materials','worldBounds']:
30
+ if archived.get(key)!=manifest.get(key):raise ValueError('Bake/evidence mismatch: '+key)
31
+ if report['sourceSha256']!=manifest['sourceSha256'] or digest(Path(manifest['source']))!=manifest['sourceSha256']:
32
+ raise ValueError('Source identity changed')
33
+ if digest(masks/'annotations.json')!=report['annotationSha256']:raise ValueError('Baked annotations changed')
34
+ if view_name not in [v['name'] for v in manifest['views']]:raise ValueError('Unknown evidence view')
35
+ if not 0<=x<manifest['size'] or not 0<=y<manifest['size']:raise ValueError('Pixel outside evidence image')
36
+ for ext in ['.png','.npz']:
37
+ name=view_name+ext
38
+ if digest(evidence/name)!=manifest['evidenceHashes'][name]:raise ValueError('Evidence changed: '+name)
39
+ with np.load(evidence/(view_name+'.npz')) as data:
40
+ material=int(data['material'][y,x]);uv=data['uv'][y,x]
41
+ if material<0 or not np.isfinite(uv).all() or (uv<0).any() or (uv>1).any():raise ValueError('Pixel has no supported source UV surface')
42
+ labels=np.asarray(Image.open(masks/f'material-{material}-labels.png'));h,w=labels.shape
43
+ col=min(int(uv[0]*w),w-1);row=min(int((1-uv[1])*h),h-1)
44
+ footprint=None
45
+ with np.load(masks/f'material-{material}-surface.npz') as surface:
46
+ occupied=surface['occupied'];positions=surface['position']
47
+ if not occupied[row,col]:raise ValueError('Selected UV texel is unoccupied')
48
+ point=positions[row,col];normal=surface['normal'][row,col]
49
+ visible=int(surface['visibilityBits'][row,col]);voted=int(surface['viewBits'][row,col])
50
+ if physical_height is not None:footprint=texel_footprint(positions,occupied,row,col,np.ptp(np.asarray(manifest['worldBounds'])[:,2]),physical_height)
51
+ conflicts=np.asarray(Image.open(masks/f'material-{material}-conflicts.png'))
52
+ bounds=np.asarray(manifest['worldBounds']);height=bounds[1,2]-bounds[0,2];center=bounds.mean(0);names={v:k for k,v in LABELS.items()};contributors=[]
53
+ for index,view in enumerate(manifest['views']):
54
+ if not visible&(1<<index):continue
55
+ direction=np.asarray(view['direction'],float);direction/=np.linalg.norm(direction)
56
+ up=np.asarray(view.get('up',[0,0,1]),float);right=np.cross(direction,up);right/=np.linalg.norm(right);up=np.cross(right,direction)
57
+ target=center+height*np.asarray(view.get('targetOffset',[0,0,0]));span=height*view.get('span',1.1)
58
+ px=int(np.floor(((point-target)@right/span+.5)*manifest['size']));py=int(np.floor((.5-(point-target)@up/span)*manifest['size']))
59
+ if not 0<=px<manifest['size'] or not 0<=py<manifest['size']:raise ValueError('Visibility bit projects outside its archived camera')
60
+ label_file=masks/(view['name']+'-annotation.npy');kind=int(np.load(label_file)[py,px]) if label_file.exists() else 0
61
+ contributors.append({'view':view['name'],'pixel':[px,py],'label':names[kind],'facing':float(abs(normal@direction)),'contributed':bool(voted&(1<<index))})
62
+ return {'sourceSha256':manifest['sourceSha256'],'annotationSha256':report['annotationSha256'],'view':view_name,'pixel':[x,y],'material':material,'atlasPixel':[col,row],'label':names[int(labels[row,col])],'conflict':bool(conflicts[row,col]),'contributors':contributors,'texelFootprint':footprint,'decision':'No semantic decision made; inspect the listed source pixels before editing annotations.'}
63
+
64
+ if __name__=='__main__':
65
+ parser=argparse.ArgumentParser(description=__doc__);parser.add_argument('--evidence',required=True,type=Path);parser.add_argument('--masks',required=True,type=Path);parser.add_argument('--view',required=True);parser.add_argument('--x',required=True,type=int);parser.add_argument('--y',required=True,type=int);parser.add_argument('--height-m',type=float,help='Known authored physical height, for local texel-spacing diagnostics');a=parser.parse_args()
66
+ print(json.dumps(inspect(a.evidence,a.masks,a.view,a.x,a.y,a.height_m),indent=2))