YaroslavLinder commited on
Commit
e0d67d9
·
1 Parent(s): 813c192
3D/animations/540-hook/540-hook.bvh ADDED
The diff for this file is too large to render. See raw diff
 
3D/animations/540-hook/540-hook.glb ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:59a281d94cb60a061e23f39d1108fe78f4a25f60d9b15e5700b271a19279d264
3
+ size 2427592
3D/animations/540-hyper-round/540-hyper-round.glb CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:9c3e315887218ef506077a2c79729c4eafd5629752fd795068d59a10f2cacde1
3
- size 974712
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1bbcc4726bde32860565f2546d0319ca99dfa8a8724f61f08b56b5a3fd48ce8a
3
+ size 2067292
3D/animations/hook-kick/hook-kick.glb CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:70a571efa0b12ae4a3593f7d8e0d866ae6c439e6de239549cfc045ab758fc860
3
- size 468180
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:960001f0fef339d34d47fbbf3cf0a85fe88ebd0bed3122c1a2b7a2ca30694fa8
3
+ size 1175532
3D/animations/roundhouse-kick/roundhouse-kick.glb CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:95a774c32018253659e5b3df4f7e34111fe68c927df7f63139472efb78c2fcca
3
- size 416920
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:cd2791c6d49a32d0bcc7d67ca0cd20ebdcabde10ab9c5c2a05fb7edbfd3abeb4
3
+ size 1124240
3D/animations/side-kick/side-kick.glb CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:e9d8ed045c36756c7d27bddb4dd13e224c7805eefd3d16817f08f4824cf64039
3
- size 550424
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:044a470e8afd4aafb9796b7184400f1fe2497f91559b801823c5389367fbec94
3
+ size 1257728
3D/animations/sketchfab_animation.py CHANGED
@@ -7,14 +7,24 @@ Usage:
7
  Requires Python 3.9+ and a local Chrome/Chromium/Edge installation; no pip
8
  packages. Chrome runs in a temporary profile and never uses your open tabs.
9
  The default output is ./model-name/{model-name.bvh,model-name.glb}.
10
- Both files contain the animated skeleton, not the character mesh. BVH uses
11
- source units; GLB defaults to a 0.01 scale (centimeters to meters). Use --scale
 
12
  for models with different units. BVH is resampled near 30 fps while preserving
13
  the duration; GLB keeps original keyframe times except duplicate timestamps.
14
 
15
  This implements the viewer's current skeletal-animation layout. Unsupported
16
  layouts fail explicitly. It does not fetch original FBX files. Live viewer
17
  changes may require updating the decoder integration.
 
 
 
 
 
 
 
 
 
18
  """
19
  import argparse
20
  import base64
@@ -38,7 +48,7 @@ import sys
38
  import tempfile
39
  import threading
40
  import time
41
- from urllib.parse import urlparse
42
  from urllib.request import Request, urlopen
43
 
44
 
@@ -119,13 +129,16 @@ def find_chrome(explicit):
119
  raise ExportError("Install Chrome/Chromium locally or pass --chrome PATH. In WSL, use a Linux browser; alternatively run this script with Windows Python.")
120
 
121
 
122
- def decode_scene(payload, protection, scripts, directory, browser, no_sandbox):
123
- try:
124
- value = json.loads(payload)
125
- if isinstance(value, dict):
126
- return value
127
- except (ValueError, UnicodeError):
128
- pass
 
 
 
129
  if not protection or not protection.get("b"):
130
  raise ExportError("Unrecognized scene format; no viewer decoder parameters were provided.")
131
  if protection.get("v") != 1:
@@ -136,17 +149,23 @@ def decode_scene(payload, protection, scripts, directory, browser, no_sandbox):
136
  required = ('"kbo/":', 'VHcI:', 'C04p:')
137
  found = set()
138
  selected = []
139
- with ThreadPoolExecutor(max_workers=6) as pool:
140
- for index, data in enumerate(pool.map(download, scripts)):
141
- source = data.decode("utf-8")
142
- markers = {marker for marker in required if marker in source}
143
- if markers:
144
- found.update(markers)
145
- name = f"bundle-{index}.js"
146
- (directory / name).write_text(source, encoding="utf-8")
147
- selected.append(name)
 
 
 
 
 
148
  if found != set(required):
149
  raise ExportError("The Sketchfab viewer decoder modules changed; this script needs an update.")
 
150
 
151
  token = secrets.token_hex(24)
152
  callback_path = "/result-" + token
@@ -184,6 +203,19 @@ try {
184
  });
185
  } catch (error) {failure(error.message);}
186
  </script>'''.replace('CALLBACK', json.dumps(callback_path))
 
 
 
 
 
 
 
 
 
 
 
 
 
187
  (directory / 'decode.html').write_text(document, encoding='utf-8')
188
  result = []
189
  finished = threading.Event()
@@ -225,10 +257,7 @@ try {
225
  raise ExportError('Chrome exited before decoding. ' + (directory / 'chrome.log').read_text(errors='replace')[-1200:])
226
  if time.monotonic() > deadline:
227
  raise ExportError('Timed out waiting for the browser decoder.')
228
- decoded = json.loads(result[0])
229
- if 'error' in decoded:
230
- raise ExportError('Viewer decoder: ' + decoded['error'])
231
- return decoded
232
  finally:
233
  if process is not None and process.poll() is None:
234
  process.terminate()
@@ -257,9 +286,511 @@ def require(condition, message):
257
  raise ExportError(message)
258
 
259
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
260
  # Conversion uses the viewer's packed-vector and delta encodings, then writes
261
  # standard glTF quaternion tracks and a resampled BVH hierarchy.
262
- def export_animation(scene, animation, raw, clip_name, fps, scale):
 
263
  bones=[]; targets={}
264
  def hierarchy(obj, parent=None):
265
  for kind, v in obj.items():
@@ -267,7 +798,8 @@ def export_animation(scene, animation, raw, clip_name, fps, scale):
267
  current=parent
268
  if kind=='osgAnimation.Bone':
269
  current=len(bones)
270
- b={'name':v['Name'], 'parent':parent, 'children':[], 'translation':[0,0,0], 'rotation':[0,0,0,1], 'scale':[1,1,1]}
 
271
  bones.append(b)
272
  if parent is not None: bones[parent]['children'].append(current)
273
  for cb in v.get('UpdateCallbacks',[]):
@@ -381,25 +913,42 @@ def export_animation(scene, animation, raw, clip_name, fps, scale):
381
  require(all(abs(x-1)<1e-5 for v in t['values'] for x in v), 'BVH cannot represent animated scale')
382
  duration=max(t['times'][-1] for t in tracks)
383
 
384
- # A standard glTF scene with animated skeleton nodes. BVH below provides
385
- # explicit joint hierarchy for applications that do not infer bones from nodes.
386
  g={'asset':{'version':'2.0','generator':'sketchfab_animation.py'},'scene':0,'scenes':[{'nodes':[len(bones)]}], 'nodes':[], 'buffers':[], 'bufferViews':[], 'accessors':[], 'animations':[{'name':clip_name,'channels':[],'samplers':[]}]}
387
  for b in bones:
388
- g['nodes'].append({k:b[k] for k in ['name','translation','rotation','scale','children']})
389
- g['nodes'].append({'name':'Source units to meters','scale':[scale]*3,'children':[i for i,b in enumerate(bones) if b['parent'] is None]})
 
 
 
 
 
 
390
  binary=bytearray()
391
- def accessor(vals,dim):
392
- flat=[v for row in vals for v in row];offset=len(binary)
393
- binary.extend(struct.pack('<'+str(len(flat))+'f',*flat))
394
- vi=len(g['bufferViews']);g['bufferViews'].append({'buffer':0,'byteOffset':offset,'byteLength':len(flat)*4})
395
- a={'bufferView':vi,'componentType':5126,'count':len(vals),'type':{1:'SCALAR',3:'VEC3',4:'VEC4'}[dim]}
396
- if dim==1:a.update(min=[min(flat)],max=[max(flat)])
 
 
 
 
 
 
 
397
  g['accessors'].append(a);return len(g['accessors'])-1
398
  for t in tracks:
399
  ai=g['animations'][0];i=accessor([[x] for x in t['times']],1);o=accessor(t['values'],len(t['values'][0]))
400
  ai['channels'].append({'sampler':len(ai['samplers']),'target':{'node':t['bone'],'path':t['path']}})
401
  ai['samplers'].append({'input':i,'output':o,'interpolation':'LINEAR'})
 
 
 
 
402
  g['buffers']=[{'byteLength':len(binary)}]
 
403
  j=json.dumps(g,separators=(',',':')).encode();j+=b' '*(-len(j)%4)
404
  glb=struct.pack('<III',0x46546c67,2,12+8+len(j)+8+len(binary))+struct.pack('<II',len(j),0x4e4f534a)+j+struct.pack('<II',len(binary),0x004e4942)+binary
405
 
@@ -444,7 +993,8 @@ def export_animation(scene, animation, raw, clip_name, fps, scale):
444
  values += [v-o for v,o in zip(sample(i,'translation',f*dt),bones[i]['translation'])]
445
  values += euler_xyz(sample(i,'rotation',f*dt))
446
  lines.append(' '.join(f'{v:.8f}' for v in values))
447
- return glb, '\n'.join(lines)+'\n', {'joints':len(bones),'tracks':len(tracks),'duration':duration,'frames':frames}
 
448
 
449
 
450
  def choose_animation(scene, uid):
@@ -460,6 +1010,40 @@ def choose_animation(scene, uid):
460
  return candidates[0]
461
 
462
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
463
  def run(args):
464
  uid = model_id(args.url)
465
  progress('Fetching model metadata...')
@@ -496,9 +1080,18 @@ def run(args):
496
  except (ValueError, UnicodeError):
497
  protection = next(iter(file_info.get('p', [])), None)
498
  scene = decode_scene(payload, protection, page.scripts, directory, find_chrome(args.chrome), args.no_sandbox)
 
 
 
 
 
 
 
 
499
  progress('Reconstructing skeleton and exporting animation...')
500
  animation = choose_animation(scene, clip['uid'])
501
- glb, bvh, summary = export_animation(scene, animation, raw, model['name'], args.fps, args.scale)
 
502
  # Prepare both outputs before creating the final folder. Intermediate
503
  # downloads, scripts and the temporary browser profile are discarded.
504
  (directory / 'animation.glb').write_bytes(glb)
@@ -509,7 +1102,8 @@ def run(args):
509
  data = (directory / f'animation.{extension}').read_bytes()
510
  with target.open('wb' if args.overwrite else 'xb') as output:
511
  output.write(data)
512
- progress(f"Exported {summary['joints']} joints, {summary['tracks']} tracks, {summary['duration']:.3f} seconds. Skeleton only; no mesh.")
 
513
  for extension in ('bvh', 'glb'):
514
  print(folder / f'{name}.{extension}')
515
 
@@ -530,6 +1124,7 @@ def main():
530
  parser.add_argument('--fps', type=positive_number, default=30, help='approximate BVH sample rate (default: 30)')
531
  parser.add_argument('--scale', type=positive_number, default=0.01, help='source-units-to-meters factor for GLB (default: 0.01); BVH keeps source units')
532
  parser.add_argument('--overwrite', action='store_true', help='replace existing output files')
 
533
  parser.add_argument('--no-sandbox', action='store_true', help='disable Chrome sandbox only if required by your runtime environment')
534
  args = parser.parse_args()
535
  try:
 
7
  Requires Python 3.9+ and a local Chrome/Chromium/Edge installation; no pip
8
  packages. Chrome runs in a temporary profile and never uses your open tabs.
9
  The default output is ./model-name/{model-name.bvh,model-name.glb}.
10
+ GLB includes available geometry, PBR materials/textures and skinning. BVH
11
+ contains only the animated skeleton. BVH uses source units; GLB defaults
12
+ to a 0.01 scale (centimeters to meters). Use --scale
13
  for models with different units. BVH is resampled near 30 fps while preserving
14
  the duration; GLB keeps original keyframe times except duplicate timestamps.
15
 
16
  This implements the viewer's current skeletal-animation layout. Unsupported
17
  layouts fail explicitly. It does not fetch original FBX files. Live viewer
18
  changes may require updating the decoder integration.
19
+
20
+ Materials use glTF's metallic/roughness workflow. Base color/opacity, normal,
21
+ occlusion, emissive and metallic/roughness maps are embedded in the GLB.
22
+ Viewer-only effects and unsupported material channels remain in GLB extras;
23
+ unsupported active surface channels produce warnings. Morph targets and
24
+ non-bone animation tracks are not supported. Use --skeleton-only to omit meshes.
25
+
26
+ In Blender, enable "Disable Bone Shape" when importing to avoid its generated
27
+ glTF_not_exported collection and Icosphere bone-display helper.
28
  """
29
  import argparse
30
  import base64
 
48
  import tempfile
49
  import threading
50
  import time
51
+ from urllib.parse import urlparse, urljoin
52
  from urllib.request import Request, urlopen
53
 
54
 
 
129
  raise ExportError("Install Chrome/Chromium locally or pass --chrome PATH. In WSL, use a Linux browser; alternatively run this script with Windows Python.")
130
 
131
 
132
+ def decode_scene(payload, protection, scripts, directory, browser, no_sandbox, binary=False):
133
+ if not binary:
134
+ try:
135
+ value = json.loads(payload)
136
+ if isinstance(value, dict):
137
+ return value
138
+ except (ValueError, UnicodeError):
139
+ pass
140
+ elif not protection:
141
+ return payload
142
  if not protection or not protection.get("b"):
143
  raise ExportError("Unrecognized scene format; no viewer decoder parameters were provided.")
144
  if protection.get("v") != 1:
 
149
  required = ('"kbo/":', 'VHcI:', 'C04p:')
150
  found = set()
151
  selected = []
152
+ cached = directory / 'decoder-bundles.json'
153
+ if cached.exists():
154
+ selected = json.loads(cached.read_text())
155
+ found = set(required)
156
+ else:
157
+ with ThreadPoolExecutor(max_workers=6) as pool:
158
+ for index, data in enumerate(pool.map(download, scripts)):
159
+ source = data.decode("utf-8")
160
+ markers = {marker for marker in required if marker in source}
161
+ if markers:
162
+ found.update(markers)
163
+ name = f"bundle-{index}.js"
164
+ (directory / name).write_text(source, encoding="utf-8")
165
+ selected.append(name)
166
  if found != set(required):
167
  raise ExportError("The Sketchfab viewer decoder modules changed; this script needs an update.")
168
+ cached.write_text(json.dumps(selected))
169
 
170
  token = secrets.token_hex(24)
171
  callback_path = "/result-" + token
 
203
  });
204
  } catch (error) {failure(error.message);}
205
  </script>'''.replace('CALLBACK', json.dumps(callback_path))
206
+ payload = browser_document(document, callback_path, directory, browser, no_sandbox)
207
+ try:
208
+ decoded = json.loads(payload)
209
+ except (ValueError, UnicodeError):
210
+ require(binary, 'Viewer decoder did not return a JSON scene')
211
+ return payload
212
+ if isinstance(decoded, dict) and 'error' in decoded:
213
+ raise ExportError('Viewer decoder: ' + decoded['error'])
214
+ return payload if binary else decoded
215
+
216
+
217
+ def browser_document(document, callback_path, directory, browser, no_sandbox):
218
+ """Run a local document in a disposable headless browser profile."""
219
  (directory / 'decode.html').write_text(document, encoding='utf-8')
220
  result = []
221
  finished = threading.Event()
 
257
  raise ExportError('Chrome exited before decoding. ' + (directory / 'chrome.log').read_text(errors='replace')[-1200:])
258
  if time.monotonic() > deadline:
259
  raise ExportError('Timed out waiting for the browser decoder.')
260
+ return result[0]
 
 
 
261
  finally:
262
  if process is not None and process.poll() is None:
263
  process.terminate()
 
286
  raise ExportError(message)
287
 
288
 
289
+ IDENTITY = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]
290
+
291
+
292
+ def metadata(obj):
293
+ return {v['Name']: v['Value'] for v in obj.get('UserDataContainer', {}).get('Values', [])}
294
+
295
+
296
+ def matrix_multiply(a, b):
297
+ # OSG and glTF both serialize column-major matrices.
298
+ return [sum(a[k * 4 + row] * b[col * 4 + k] for k in range(4))
299
+ for col in range(4) for row in range(4)]
300
+
301
+
302
+ class GeometryReader:
303
+ """Decode shared OSG arrays and the viewer's quantized geometry format."""
304
+
305
+ def __init__(self, scene, buffers):
306
+ self.buffers = buffers
307
+ self.references = {}
308
+ self.decoded = {}
309
+ for _, value in walk(scene):
310
+ if isinstance(value, dict) and 'UniqueID' in value and len(value) > 1:
311
+ self.references[value['UniqueID']] = value
312
+
313
+ def resolve(self, obj):
314
+ if set(obj) == {'UniqueID'}:
315
+ require(obj['UniqueID'] in self.references, 'Missing shared geometry reference')
316
+ return self.references[obj['UniqueID']]
317
+ return obj
318
+
319
+ def array(self, desc):
320
+ desc = self.resolve(desc)
321
+ dim = desc['ItemSize']
322
+ require(isinstance(dim, int) and 1 <= dim <= 16, 'Invalid geometry array dimensions')
323
+ if 'Elements' in desc:
324
+ return list(desc['Elements']), dim
325
+ typ, info = next(iter(desc['Array'].items()))
326
+ formats = {'Float32Array': 'f', 'Float64Array': 'd', 'Uint16Array': 'H',
327
+ 'Int16Array': 'h', 'Uint32Array': 'I', 'Int32Array': 'i',
328
+ 'Uint8Array': 'B', 'Int8Array': 'b'}
329
+ require(typ in formats, 'Unsupported geometry array type: ' + typ)
330
+ if 'Elements' in info:
331
+ return list(info['Elements']), dim
332
+ require(info.get('File') in self.buffers, 'Missing geometry buffer: ' + str(info.get('File')))
333
+ raw = self.buffers[info['File']]
334
+ count, pos = info['Size'] * dim, info.get('Offset', 0)
335
+ require(isinstance(count, int) and 0 < count <= 100_000_000 and
336
+ isinstance(pos, int) and 0 <= pos < len(raw), 'Invalid geometry buffer bounds')
337
+ if info.get('Encoding') == 'varint':
338
+ values = []
339
+ for _ in range(count):
340
+ value = shift = 0
341
+ while True:
342
+ require(pos < len(raw) and shift <= 28, 'Malformed geometry varint')
343
+ byte = raw[pos]; pos += 1
344
+ value |= (byte & 127) << shift
345
+ if byte < 128:
346
+ break
347
+ shift += 7
348
+ if typ.startswith('Int'):
349
+ value = (value >> 1) ^ -(value & 1)
350
+ values.append(value)
351
+ else:
352
+ require(not info.get('Encoding'), 'Unsupported geometry array encoding')
353
+ require(pos + count * struct.calcsize(formats[typ]) <= len(raw), 'Truncated geometry array')
354
+ values = list(struct.unpack_from('<' + str(count) + formats[typ], raw, pos))
355
+ require(all(math.isfinite(x) for x in values), 'Non-finite geometry values')
356
+ return values, dim
357
+
358
+ def indices(self, primitive, user, watermark):
359
+ kind, primitive = next(iter(primitive.items()))
360
+ mode = primitive.get('Mode', 'TRIANGLES')
361
+ modes = {'POINTS': 0, 'LINES': 1, 'LINE_LOOP': 2, 'LINE_STRIP': 3,
362
+ 'TRIANGLES': 4, 'TRIANGLE_STRIP': 5, 'TRIANGLE_FAN': 6}
363
+ require(mode in modes or mode in modes.values(), 'Unsupported primitive mode: ' + str(mode))
364
+ mode = modes.get(mode, mode)
365
+ if kind == 'DrawArrays':
366
+ first = primitive.get('First', 0)
367
+ return list(range(first, first + primitive['Count'])), mode
368
+ require(kind.startswith('DrawElements'), 'Unsupported primitive: ' + kind)
369
+ values, dim = self.array(primitive['Indices'])
370
+ require(dim == 1, 'Index array must be scalar')
371
+ if int(user.get('attributes', 0)) & 16 and mode in (4, 5):
372
+ flags = int(user.get('triangle_mode', 0))
373
+ require(flags & ~7 == 0, 'Unsupported triangle compression flags')
374
+ start = 0
375
+ if flags & 4:
376
+ require(mode == 5 and len(values) >= 3, 'Invalid implicit triangle strip')
377
+ length, masks, expected = values[:3]
378
+ start = 3 + masks
379
+ require(0 < length <= 100_000_000 and masks == (length + 31) // 32 and
380
+ start < len(values), 'Invalid implicit index header')
381
+ if flags & 1:
382
+ for i in range(start + 1, len(values)):
383
+ v = values[i]
384
+ values[i] = (values[i - 1] + ((v >> 1) ^ -(v & 1))) & 0xffffffff
385
+ if flags & 4:
386
+ expanded = []
387
+ cursor = start
388
+ for i in range(masks):
389
+ padding = masks * 32 - length if i == masks - 1 else 0
390
+ for bit in range(padding, 32):
391
+ if values[3 + i] & (0x80000000 >> bit):
392
+ require(cursor < len(values), 'Truncated implicit indices')
393
+ expanded.append(values[cursor]); cursor += 1
394
+ else:
395
+ expanded.append(expected)
396
+ if not flags & 2:
397
+ expected += 1
398
+ require(cursor == len(values), 'Unexpected implicit index payload')
399
+ values = expanded
400
+ if flags & 2:
401
+ for i, value in enumerate(values):
402
+ value = watermark[0] - value
403
+ values[i] = value
404
+ watermark[0] = max(watermark[0], value + 1)
405
+ return values, mode
406
+
407
+ def attribute(self, name, desc, user, strip):
408
+ desc = self.resolve(desc)
409
+ key = desc.get('UniqueID')
410
+ if key is not None and key in self.decoded:
411
+ return self.decoded[key]
412
+ values, dim = self.array(desc)
413
+ flag = 4 if name.startswith('TexCoord') else {'Vertex': 1, 'Normal': 2, 'Color': 8, 'Tangent': 32}.get(name, 0)
414
+ if int(user.get('attributes', 0)) & flag:
415
+ if name in ('Normal', 'Tangent'):
416
+ require(dim == 2, 'Unsupported packed normal/tangent layout')
417
+ result = []
418
+ nphi, epsilon = float(user.get('nphi', 720)), float(user.get('epsilon', 0.25))
419
+ require(nphi > 1, 'Invalid normal quantization')
420
+ for pole, azimuth in zip(values[::2], values[1::2]):
421
+ sign = -1 if pole & 1024 else 1
422
+ if name == 'Tangent':
423
+ pole &= ~1024
424
+ theta = pole * (3.14159265359 / (nphi - 1))
425
+ cosine, sine = math.cos(theta), math.sin(theta)
426
+ shifted = theta + 1.57079632679 / (nphi - 1)
427
+ ratio = (math.cos(0.01745329251 * epsilon) - cosine * math.cos(shifted)) / max(1e-5, sine * math.sin(shifted))
428
+ phi = 6.28318530718 * azimuth / math.ceil(3.14159265359 / max(1e-5, math.acos(max(-1, min(1, ratio)))))
429
+ result.extend([sine * math.cos(phi), sine * math.sin(phi), cosine])
430
+ if name == 'Tangent':
431
+ result.append(sign)
432
+ values, dim = result, 4 if name == 'Tangent' else 3
433
+ elif name == 'Vertex' or name.startswith('TexCoord'):
434
+ prefix = 'vtx_' if name == 'Vertex' else 'uv_' + name[8:] + '_'
435
+ mode = int(user.get('vertex_mode' if name == 'Vertex' else prefix + 'mode', user.get('vertex_mode', 0)))
436
+ require(mode & ~3 == 0, 'Unsupported vertex compression flags')
437
+ if mode & 2:
438
+ require(strip is not None and len(strip) >= 3, 'Predictive attributes need a triangle strip')
439
+ visited = set(strip[:3])
440
+ for a, b, c, d in zip(strip, strip[1:], strip[2:], strip[3:]):
441
+ if d not in visited:
442
+ visited.add(d)
443
+ for axis in range(dim):
444
+ values[d * dim + axis] += values[b * dim + axis] + values[c * dim + axis] - values[a * dim + axis]
445
+ if mode & 1:
446
+ require(dim in (2, 3), 'Unsupported quantized vertex dimensions')
447
+ low = [float(user[prefix + 'bbl_' + axis]) for axis in 'xyz'[:dim]]
448
+ step = [float(user[prefix + 'h_' + axis]) for axis in 'xyz'[:dim]]
449
+ values = [low[i % dim] + v * step[i % dim] for i, v in enumerate(values)]
450
+ else:
451
+ raise ExportError('Unsupported compressed attribute: ' + name)
452
+ if name == 'Color':
453
+ typ = next(iter(desc.get('Array', {'Float32Array': {}})))
454
+ divisor = {'Uint8Array': 255, 'Uint16Array': 65535}.get(typ, 1)
455
+ values = [x / divisor for x in values]
456
+ require(len(values) % dim == 0 and all(math.isfinite(x) for x in values), 'Invalid decoded attribute')
457
+ rows = [values[i:i + dim] for i in range(0, len(values), dim)]
458
+ if key is not None:
459
+ self.decoded[key] = rows
460
+ return rows
461
+
462
+
463
+ def material_channels(material):
464
+ return {name: channel for name, channel in material.get('channels', {}).items()
465
+ if channel.get('enable', False)}
466
+
467
+
468
+ def prepare_textures(materials, texture_records, directory, browser, no_sandbox):
469
+ """Bake separate scalar maps into glTF's base-color and metallic/roughness images.
470
+
471
+ Canvas handles PNG/JPEG decoding without adding Python dependencies. Flip
472
+ images vertically to keep OSG UVs and tangent bases unchanged in glTF.
473
+ """
474
+ records = {record['uid']: record for record in texture_records}
475
+ cache, jobs = {}, []
476
+
477
+ def image_data(channel):
478
+ tex = channel.get('texture')
479
+ if not tex:
480
+ return None
481
+ uid = tex['uid']
482
+ if uid not in cache:
483
+ require(uid in records, 'Texture is missing from viewer metadata: ' + uid)
484
+ images = records[uid].get('images', [])
485
+ images = [img for img in images if img.get('url') and
486
+ img.get('options', {}).get('format', '').lower() not in ('ktx', 'ktx2', 'basis')]
487
+ require(images, 'No supported image for texture: ' + uid)
488
+ image = max(images, key=lambda img: img.get('width', 0) * img.get('height', 0))
489
+ raw = download(image['url'])
490
+ mime = 'image/png' if raw.startswith(b'\x89PNG') else 'image/jpeg' if raw.startswith(b'\xff\xd8') else 'image/webp' if raw[:4] == b'RIFF' else None
491
+ require(mime is not None, 'Unsupported texture image format for ' + uid)
492
+ cache[uid] = 'data:' + mime + ';base64,' + base64.b64encode(raw).decode()
493
+ return cache[uid]
494
+
495
+ def compatible(channels):
496
+ tex_channels = [c for c in channels if c.get('texture')]
497
+ if len(tex_channels) > 1:
498
+ first = tex_channels[0]
499
+ for channel in tex_channels[1:]:
500
+ require(channel.get('UVTransforms', {}) == first.get('UVTransforms', {}) and
501
+ all(channel['texture'].get(k) == first['texture'].get(k)
502
+ for k in ('texCoordUnit', 'wrapS', 'wrapT', 'minFilter', 'magFilter')),
503
+ 'Cannot pack material maps with different UV transforms or samplers')
504
+
505
+ for key, material in materials.items():
506
+ if not isinstance(material, dict):
507
+ continue
508
+ c = material_channels(material)
509
+ base = c.get('AlbedoPBR', c.get('DiffusePBR', c.get('DiffuseColor', {})))
510
+ opacity = c.get('Opacity', c.get('AlphaMask', {}))
511
+ metal, rough = c.get('MetalnessPBR', {}), c.get('RoughnessPBR', {})
512
+ for name, channels in [('baseColor', [base, opacity]), ('metallicRoughness', [metal, rough]),
513
+ ('normal', [c.get('NormalMap', {})]), ('occlusion', [c.get('AOPBR', {})]),
514
+ ('emissive', [c.get('EmitColor', {})])]:
515
+ if not any(ch.get('texture') for ch in channels):
516
+ continue
517
+ compatible(channels)
518
+ jobs.append({'key': key + '/' + name, 'kind': name,
519
+ 'images': [image_data(ch) for ch in channels],
520
+ 'invert': bool(opacity.get('invert')) if name == 'baseColor' else False,
521
+ 'flipY': bool(channels[0].get('flipY')) if name == 'normal' else False})
522
+ if not jobs:
523
+ return {}
524
+ progress(f'Embedding {len(cache)} textures and packing PBR channels...')
525
+ callback = '/result-' + secrets.token_hex(24)
526
+ document = '<!doctype html><script>const jobs=' + json.dumps(jobs).replace('<', '\\u003c') + ';'
527
+ document += r'''
528
+ async function pixels(url) {
529
+ if (!url) return null;
530
+ const image = new Image(); image.src = url; await image.decode(); return image;
531
+ }
532
+ async function run() {
533
+ const result = {};
534
+ for (const job of jobs) {
535
+ const images = await Promise.all(job.images.map(pixels));
536
+ const width = Math.max(...images.map(i => i ? i.width : 1));
537
+ const height = Math.max(...images.map(i => i ? i.height : 1));
538
+ const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height;
539
+ const ctx = canvas.getContext('2d', {willReadFrequently:true});
540
+ const inputs = images.map(image => {
541
+ if (!image) return null;
542
+ ctx.clearRect(0,0,width,height);
543
+ ctx.save(); ctx.translate(0,height); ctx.scale(1,-1);
544
+ ctx.drawImage(image,0,0,width,height); ctx.restore();
545
+ return ctx.getImageData(0,0,width,height).data;
546
+ });
547
+ const output = ctx.createImageData(width,height), out = output.data;
548
+ for (let i=0; i<out.length; i+=4) {
549
+ const a=inputs[0], b=inputs[1];
550
+ if (job.kind === 'metallicRoughness') {
551
+ out[i]=255; out[i+1]=b?b[i]:255; out[i+2]=a?a[i]:255; out[i+3]=255;
552
+ } else {
553
+ for (let k=0;k<4;k++) out[i+k]=a?a[i+k]:255;
554
+ if (job.kind === 'baseColor' && b) out[i+3]=Math.round(out[i+3]*(job.invert?255-b[i]:b[i])/255);
555
+ if (job.kind === 'normal' && job.flipY) out[i+1]=255-out[i+1];
556
+ }
557
+ }
558
+ ctx.putImageData(output,0,0);
559
+ result[job.key]=canvas.toDataURL('image/png').split(',')[1];
560
+ }
561
+ return result;
562
+ }
563
+ run().then(result=>fetch(CALLBACK,{method:'POST',body:JSON.stringify(result)}))
564
+ .catch(error=>fetch(CALLBACK,{method:'POST',body:JSON.stringify({error:String(error)})}));
565
+ </script>'''.replace('CALLBACK', json.dumps(callback))
566
+ result = json.loads(browser_document(document, callback, directory, browser, no_sandbox))
567
+ require('error' not in result, 'Texture conversion failed: ' + str(result.get('error')))
568
+ return {key: base64.b64decode(value) for key, value in result.items()}
569
+
570
+
571
+ def append_materials(g, options, assets, buffer_view):
572
+ mapping = {}
573
+ sampler_codes = {'REPEAT': 10497, 'CLAMP_TO_EDGE': 33071, 'MIRRORED_REPEAT': 33648,
574
+ 'NEAREST': 9728, 'LINEAR': 9729, 'NEAREST_MIPMAP_NEAREST': 9984,
575
+ 'LINEAR_MIPMAP_NEAREST': 9985, 'NEAREST_MIPMAP_LINEAR': 9986, 'LINEAR_MIPMAP_LINEAR': 9987}
576
+
577
+ def extension(name):
578
+ used = g.setdefault('extensionsUsed', [])
579
+ if name not in used:
580
+ used.append(name)
581
+
582
+ def texture(key, name, channels):
583
+ active = next((c for c in channels if c.get('texture')), None)
584
+ if active is None:
585
+ return None
586
+ asset = key + '/' + name
587
+ require(asset in assets, 'Missing prepared material texture: ' + asset)
588
+ image = len(g.setdefault('images', []))
589
+ g['images'].append({'name': asset, 'bufferView': buffer_view(assets[asset]), 'mimeType': 'image/png'})
590
+ source = active['texture']
591
+ sampler = {k: sampler_codes.get(source.get(k), source.get(k)) for k in ('wrapS', 'wrapT', 'minFilter', 'magFilter') if k in source}
592
+ samplers = g.setdefault('samplers', [])
593
+ if sampler not in samplers:
594
+ samplers.append(sampler)
595
+ index = len(g.setdefault('textures', []))
596
+ g['textures'].append({'source': image, 'sampler': samplers.index(sampler)})
597
+ info = {'index': index, 'texCoord': int(source.get('texCoordUnit', 0))}
598
+ uv = active.get('UVTransforms', {})
599
+ transform = {k: uv[k] for k in ('offset', 'scale', 'rotation') if k in uv}
600
+ if transform:
601
+ extension('KHR_texture_transform')
602
+ info['extensions'] = {'KHR_texture_transform': transform}
603
+ return info
604
+
605
+ for key, material in options.items():
606
+ if not isinstance(material, dict):
607
+ continue
608
+ c = material_channels(material)
609
+ base = c.get('AlbedoPBR', c.get('DiffusePBR', c.get('DiffuseColor', {})))
610
+ opacity = c.get('Opacity', c.get('AlphaMask', {}))
611
+ metal, rough = c.get('MetalnessPBR', {}), c.get('RoughnessPBR', {})
612
+ color = [float(x) * float(base.get('factor', 1)) for x in base.get('color', [1, 1, 1])[:3]]
613
+ alpha = float(opacity.get('factor', 1))
614
+ if opacity.get('invert') and not opacity.get('texture'):
615
+ alpha = 1 - alpha
616
+ pbr = {'baseColorFactor': [max(0, min(1, x)) for x in color] + [max(0, min(1, alpha))],
617
+ 'metallicFactor': float(metal.get('factor', 0)),
618
+ 'roughnessFactor': float(rough.get('factor', 1))}
619
+ out = {'name': material.get('name', key), 'pbrMetallicRoughness': pbr,
620
+ 'doubleSided': material.get('cullFace') == 'DISABLE',
621
+ 'extras': {'sketchfabMaterial': material}}
622
+ for name, field, channels, owner in [
623
+ ('baseColor', 'baseColorTexture', [base, opacity], pbr),
624
+ ('metallicRoughness', 'metallicRoughnessTexture', [metal, rough], pbr),
625
+ ('normal', 'normalTexture', [c.get('NormalMap', {})], out),
626
+ ('occlusion', 'occlusionTexture', [c.get('AOPBR', {})], out),
627
+ ('emissive', 'emissiveTexture', [c.get('EmitColor', {})], out)]:
628
+ info = texture(key, name, channels)
629
+ if info is not None:
630
+ owner[field] = info
631
+ if 'normalTexture' in out:
632
+ out['normalTexture']['scale'] = float(c['NormalMap'].get('factor', 1))
633
+ if 'occlusionTexture' in out:
634
+ out['occlusionTexture']['strength'] = float(c['AOPBR'].get('factor', 1))
635
+ if 'EmitColor' in c:
636
+ emit = c['EmitColor']
637
+ out['emissiveFactor'] = [float(x) for x in emit.get('color', [1, 1, 1])]
638
+ extension('KHR_materials_emissive_strength')
639
+ out.setdefault('extensions', {})['KHR_materials_emissive_strength'] = {'emissiveStrength': float(emit.get('factor', 1))}
640
+ if opacity:
641
+ out['alphaMode'] = 'MASK' if 'AlphaMask' in c and 'Opacity' not in c else 'BLEND'
642
+ if out['alphaMode'] == 'MASK':
643
+ out['alphaCutoff'] = float(opacity.get('threshold', 0.5))
644
+ if material.get('shadeless'):
645
+ extension('KHR_materials_unlit')
646
+ out.setdefault('extensions', {})['KHR_materials_unlit'] = {}
647
+ unsupported = [name for name in ('BumpMap', 'Displacement', 'SpecularPBR', 'GlossinessPBR',
648
+ 'ClearCoat', 'Sheen', 'SubsurfaceScattering', 'SubsurfaceTranslucency')
649
+ if name in c and (c[name].get('texture') or c[name].get('factor', 0))]
650
+ if unsupported:
651
+ progress('Warning: material ' + out['name'] + ': unsupported channels kept in GLB extras: ' + ', '.join(unsupported))
652
+ index = len(g.setdefault('materials', []))
653
+ g['materials'].append(out)
654
+ if 'stateSetID' in material:
655
+ mapping[int(material['stateSetID'])] = index
656
+ else:
657
+ mapping[key] = index
658
+ return mapping
659
+
660
+
661
+ def append_meshes(g, scene, bones, buffers, accessor, materials):
662
+ reader = GeometryReader(scene, buffers)
663
+ bone_ids = {b['source_id']: i for i, b in enumerate(bones)}
664
+ bone_names = {b['name']: i for i, b in enumerate(bones)}
665
+ require(len(bone_names) == len(bones), 'Duplicate bone names are ambiguous for skinning')
666
+ root = len(bones)
667
+ for node in g['nodes']:
668
+ node.pop('children', None)
669
+ mesh_count = 0
670
+
671
+ def visit(obj, parent, skeleton=None, relative=None, material=None):
672
+ nonlocal mesh_count
673
+ for kind, value in obj.items():
674
+ if not isinstance(value, dict) or not kind.startswith('osg'):
675
+ continue
676
+ value = reader.resolve(value)
677
+ user = metadata(value)
678
+ if str(user.get('wireframe', '0')) == '1':
679
+ continue
680
+ require(kind != 'osgAnimation.MorphGeometry', 'Morph geometry is not supported')
681
+ if 'StateSet' in value:
682
+ state = reader.resolve(value['StateSet'].get('osg.StateSet', value['StateSet']))
683
+ material = materials.get(int(metadata(state).get('UniqueID', state.get('UniqueID', -1))), material)
684
+ # The outer source transform converts source units/Y-up to the
685
+ # viewer's meters/Z-up. GLB uses our --scale wrapper and Y-up.
686
+ matrix = value.get('Matrix', IDENTITY) if 'source' not in user else IDENTITY
687
+ require(len(matrix) == 16 and all(math.isfinite(x) for x in matrix), 'Invalid scene transform')
688
+ relative = matrix_multiply(relative or IDENTITY, matrix)
689
+ is_geometry = kind in ('osg.Geometry', 'osgAnimation.RigGeometry')
690
+ if kind == 'osgAnimation.Bone':
691
+ index = bone_ids[id(value)]
692
+ else:
693
+ index = len(g['nodes'])
694
+ node = {'name': value.get('Name', kind.split('.')[-1])}
695
+ if matrix != IDENTITY and not is_geometry:
696
+ node['matrix'] = matrix
697
+ g['nodes'].append(node)
698
+ g['nodes'][parent].setdefault('children', []).append(index)
699
+ if kind == 'osgAnimation.Skeleton':
700
+ skeleton, relative = index, IDENTITY
701
+ if is_geometry:
702
+ rig = value if kind == 'osgAnimation.RigGeometry' else None
703
+ source = reader.resolve(rig['SourceGeometry'].get('osg.Geometry', {})) if rig else value
704
+ require('VertexAttributeList' in source, 'Unsupported source geometry layout')
705
+ source_user = dict(user, **metadata(source))
706
+ if 'StateSet' in source:
707
+ state = reader.resolve(source['StateSet'].get('osg.StateSet', source['StateSet']))
708
+ material = materials.get(int(metadata(state).get('UniqueID', state.get('UniqueID', -1))), material)
709
+ require(material is not None or not materials, 'Cannot determine material for mesh: ' + source.get('Name', 'Mesh'))
710
+ attrs = dict(source['VertexAttributeList'])
711
+ if rig:
712
+ attrs.update(rig.get('VertexAttributeList', {}))
713
+ require('Vertex' in attrs, 'Mesh has no positions')
714
+ position_desc = reader.resolve(attrs['Vertex'])
715
+ raw_positions, position_dim = reader.array(position_desc)
716
+ require(position_dim == 3, 'Positions must be VEC3')
717
+ count = len(raw_positions) // 3
718
+ primitives, watermark = [], [0]
719
+ for primitive in source.get('PrimitiveSetList', []):
720
+ indices, mode = reader.indices(primitive, source_user, watermark)
721
+ require(indices and all(isinstance(i, int) and 0 <= i < count for i in indices), 'Mesh index out of range')
722
+ primitives.append((indices, mode))
723
+ require(primitives, 'Mesh has no primitives')
724
+ strip = next((indices for indices, mode in primitives if mode == 5), None)
725
+ attributes = {}
726
+ for name, desc in attrs.items():
727
+ semantic = {'Vertex': 'POSITION', 'Normal': 'NORMAL', 'Tangent': 'TANGENT', 'Color': 'COLOR_0'}.get(name)
728
+ if name.startswith('TexCoord') and name[8:].isdigit():
729
+ semantic = 'TEXCOORD_' + name[8:]
730
+ if semantic:
731
+ rows = reader.attribute(name, desc, source_user, strip)
732
+ require(len(rows) == count, 'Mismatched vertex attribute counts')
733
+ expected = 2 if semantic.startswith('TEXCOORD') else 4 if name == 'Tangent' else 3
734
+ require(len(rows[0]) == expected or name == 'Color' and len(rows[0]) == 4, 'Invalid attribute dimensions')
735
+ attributes[semantic] = accessor(rows, len(rows[0]), bounds=name == 'Vertex')
736
+ if rig:
737
+ require(skeleton is not None, 'Skinned mesh has no parent skeleton')
738
+ require('Bones' in attrs and 'Weights' in attrs, 'Skinned mesh has no bone weights')
739
+ palette = rig.get('BoneMap', {})
740
+ require(palette and all(name in bone_names for name in palette), 'Skin references unknown bones')
741
+ mapping = {slot: bone_names[name] for name, slot in palette.items()}
742
+ require(len(mapping) == len(palette), 'Duplicate skin palette indices')
743
+ joints = reader.attribute('Bones', attrs['Bones'], source_user, strip)
744
+ weights = reader.attribute('Weights', attrs['Weights'], source_user, strip)
745
+ require(len(joints) == len(weights) == count and all(len(x) == 4 for x in joints + weights), 'Expected four influences per vertex')
746
+ mapped, normalized = [], []
747
+ for js, ws in zip(joints, weights):
748
+ require(all(w >= 0 for w in ws) and sum(ws) > 0, 'Invalid skin weights')
749
+ require(all(j in mapping for j, w in zip(js, ws) if w > 0), 'Skin joint index out of range')
750
+ mapped.append([mapping[j] if w > 0 else 0 for j, w in zip(js, ws)])
751
+ normalized.append([w / sum(ws) for w in ws])
752
+ attributes['JOINTS_0'] = accessor(mapped, 4, component_type=5123)
753
+ attributes['WEIGHTS_0'] = accessor(normalized, 4)
754
+ matrices = []
755
+ for bone in bones:
756
+ bind = bone.get('inverse_bind')
757
+ require(bind is not None and len(bind) == 16, 'Bone has no inverse bind matrix: ' + bone['name'])
758
+ matrices.append(matrix_multiply(bind, relative))
759
+ skin = {'name': source.get('Name', 'Skin'), 'joints': list(range(len(bones))),
760
+ 'skeleton': skeleton, 'inverseBindMatrices': accessor(matrices, 16)}
761
+ g['nodes'][index]['skin'] = len(g.setdefault('skins', []))
762
+ g['skins'].append(skin)
763
+ mesh = {'name': source.get('Name', value.get('Name', 'Mesh')), 'primitives': []}
764
+ for indices, mode in primitives:
765
+ # Explicit triangles avoid degenerate strip faces in DCCs.
766
+ if mode == 5:
767
+ triangles = []
768
+ for i in range(len(indices) - 2):
769
+ a, b, c = indices[i:i + 3]
770
+ if len({a, b, c}) == 3:
771
+ triangles.extend((b, a, c) if i % 2 else (a, b, c))
772
+ indices, mode = triangles, 4
773
+ require(indices, 'Mesh contains no nondegenerate primitives')
774
+ primitive = {'attributes': attributes, 'mode': mode,
775
+ 'indices': accessor([[i] for i in indices], 1, component_type=5125)}
776
+ if material is not None:
777
+ primitive['material'] = material
778
+ mesh['primitives'].append(primitive)
779
+ g['nodes'][index]['mesh'] = len(g.setdefault('meshes', []))
780
+ g['meshes'].append(mesh)
781
+ mesh_count += 1
782
+ for child in value.get('Children', []):
783
+ visit(child, index, skeleton, relative, material)
784
+
785
+ default_material = next(iter(materials.values())) if len(set(materials.values())) == 1 else None
786
+ visit({'osg.Node': scene['osg.Node']}, root, material=default_material)
787
+ return mesh_count
788
+
789
+
790
  # Conversion uses the viewer's packed-vector and delta encodings, then writes
791
  # standard glTF quaternion tracks and a resampled BVH hierarchy.
792
+ def export_animation(scene, animation, raw, clip_name, fps, scale, geometry_buffers=None,
793
+ material_options=None, texture_assets=None):
794
  bones=[]; targets={}
795
  def hierarchy(obj, parent=None):
796
  for kind, v in obj.items():
 
798
  current=parent
799
  if kind=='osgAnimation.Bone':
800
  current=len(bones)
801
+ b={'name':v['Name'], 'parent':parent, 'children':[], 'translation':[0,0,0], 'rotation':[0,0,0,1], 'scale':[1,1,1],
802
+ 'source_id':id(v), 'inverse_bind':v.get('InvBindMatrixInSkeletonSpace')}
803
  bones.append(b)
804
  if parent is not None: bones[parent]['children'].append(current)
805
  for cb in v.get('UpdateCallbacks',[]):
 
913
  require(all(abs(x-1)<1e-5 for v in t['values'] for x in v), 'BVH cannot represent animated scale')
914
  duration=max(t['times'][-1] for t in tracks)
915
 
916
+ # Keep bone node indices stable while adding mesh and scene nodes below.
 
917
  g={'asset':{'version':'2.0','generator':'sketchfab_animation.py'},'scene':0,'scenes':[{'nodes':[len(bones)]}], 'nodes':[], 'buffers':[], 'bufferViews':[], 'accessors':[], 'animations':[{'name':clip_name,'channels':[],'samplers':[]}]}
918
  for b in bones:
919
+ node = {k:b[k] for k in ['name','translation','rotation','scale']}
920
+ if b['children']: node['children'] = list(b['children'])
921
+ q = node['rotation']; norm = math.sqrt(sum(x*x for x in q))
922
+ require(norm > 0, 'Invalid rest quaternion')
923
+ node['rotation'] = [x/norm for x in q]
924
+ g['nodes'].append(node)
925
+ # Keep the unit conversion on a root named after the model for the Outliner.
926
+ g['nodes'].append({'name':slugify(clip_name),'scale':[scale]*3,'children':[i for i,b in enumerate(bones) if b['parent'] is None]})
927
  binary=bytearray()
928
+ def buffer_view(data):
929
+ binary.extend(b'\0' * (-len(binary) % 4))
930
+ offset = len(binary); binary.extend(data)
931
+ g['bufferViews'].append({'buffer':0,'byteOffset':offset,'byteLength':len(data)})
932
+ return len(g['bufferViews']) - 1
933
+ def accessor(vals,dim,component_type=5126,bounds=False):
934
+ flat=[v for row in vals for v in row]
935
+ require(vals and all(len(v) == dim for v in vals) and all(math.isfinite(x) for x in flat), 'Invalid glTF accessor')
936
+ vi=buffer_view(struct.pack('<'+str(len(flat))+{5126:'f',5123:'H',5125:'I'}[component_type],*flat))
937
+ a={'bufferView':vi,'componentType':component_type,'count':len(vals),'type':{1:'SCALAR',2:'VEC2',3:'VEC3',4:'VEC4',16:'MAT4'}[dim]}
938
+ if dim==1 or bounds:
939
+ a.update(min=[min(row[i] for row in vals) for i in range(dim)],
940
+ max=[max(row[i] for row in vals) for i in range(dim)])
941
  g['accessors'].append(a);return len(g['accessors'])-1
942
  for t in tracks:
943
  ai=g['animations'][0];i=accessor([[x] for x in t['times']],1);o=accessor(t['values'],len(t['values'][0]))
944
  ai['channels'].append({'sampler':len(ai['samplers']),'target':{'node':t['bone'],'path':t['path']}})
945
  ai['samplers'].append({'input':i,'output':o,'interpolation':'LINEAR'})
946
+ mesh_count = 0
947
+ if geometry_buffers is not None:
948
+ materials = append_materials(g, material_options or {}, texture_assets or {}, buffer_view)
949
+ mesh_count = append_meshes(g, scene, bones, geometry_buffers, accessor, materials)
950
  g['buffers']=[{'byteLength':len(binary)}]
951
+ binary.extend(b'\0' * (-len(binary) % 4))
952
  j=json.dumps(g,separators=(',',':')).encode();j+=b' '*(-len(j)%4)
953
  glb=struct.pack('<III',0x46546c67,2,12+8+len(j)+8+len(binary))+struct.pack('<II',len(j),0x4e4f534a)+j+struct.pack('<II',len(binary),0x004e4942)+binary
954
 
 
993
  values += [v-o for v,o in zip(sample(i,'translation',f*dt),bones[i]['translation'])]
994
  values += euler_xyz(sample(i,'rotation',f*dt))
995
  lines.append(' '.join(f'{v:.8f}' for v in values))
996
+ return glb, '\n'.join(lines)+'\n', {'joints':len(bones),'tracks':len(tracks),'duration':duration,'frames':frames,
997
+ 'meshes':mesh_count,'materials':len(g.get('materials', [])),'skins':len(g.get('skins', []))}
998
 
999
 
1000
  def choose_animation(scene, uid):
 
1010
  return candidates[0]
1011
 
1012
 
1013
+ def load_geometry_buffers(scene, file_info, scripts, directory, browser, no_sandbox):
1014
+ reader = GeometryReader(scene, {})
1015
+ files = set()
1016
+
1017
+ def collect(obj):
1018
+ for kind, value in obj.items():
1019
+ if not isinstance(value, dict):
1020
+ continue
1021
+ value = reader.resolve(value)
1022
+ if str(metadata(value).get('wireframe', '0')) == '1':
1023
+ continue
1024
+ if kind in ('osg.Geometry', 'osgAnimation.RigGeometry', 'osgAnimation.MorphGeometry'):
1025
+ for _, desc in walk(value):
1026
+ if isinstance(desc, dict):
1027
+ desc = reader.resolve(desc)
1028
+ for typ, info in desc.get('Array', {}).items():
1029
+ if 'File' in info:
1030
+ files.add(info['File'])
1031
+ for child in value.get('Children', []):
1032
+ collect(child)
1033
+ collect({'osg.Node': scene['osg.Node']})
1034
+ result = {}
1035
+ for name in sorted(files):
1036
+ url = urljoin(file_info['osgjsUrl'], name)
1037
+ require(urlparse(url).scheme == 'https' and urlparse(url).hostname == urlparse(file_info['osgjsUrl']).hostname,
1038
+ 'Geometry buffer must be on the model asset host')
1039
+ progress('Downloading geometry buffer: ' + name)
1040
+ raw = download(url)
1041
+ if urlparse(url).path.endswith('.binz') and file_info.get('p'):
1042
+ raw = decode_scene(raw, file_info['p'][0], scripts, directory, browser, no_sandbox, binary=True)
1043
+ result[name] = raw
1044
+ return result
1045
+
1046
+
1047
  def run(args):
1048
  uid = model_id(args.url)
1049
  progress('Fetching model metadata...')
 
1080
  except (ValueError, UnicodeError):
1081
  protection = next(iter(file_info.get('p', [])), None)
1082
  scene = decode_scene(payload, protection, page.scripts, directory, find_chrome(args.chrome), args.no_sandbox)
1083
+ geometry_buffers = material_options = texture_assets = None
1084
+ if not args.skeleton_only:
1085
+ browser = find_chrome(args.chrome)
1086
+ geometry_buffers = load_geometry_buffers(scene, file_info, page.scripts, directory, browser, args.no_sandbox)
1087
+ material_options = model.get('options', {}).get('materials', {})
1088
+ texture_records = next((v.get('results', []) for k, v in page.data.items()
1089
+ if k.startswith(f'/i/models/{uid}/textures') and isinstance(v, dict)), [])
1090
+ texture_assets = prepare_textures(material_options, texture_records, directory, browser, args.no_sandbox)
1091
  progress('Reconstructing skeleton and exporting animation...')
1092
  animation = choose_animation(scene, clip['uid'])
1093
+ glb, bvh, summary = export_animation(scene, animation, raw, model['name'], args.fps, args.scale,
1094
+ geometry_buffers, material_options, texture_assets)
1095
  # Prepare both outputs before creating the final folder. Intermediate
1096
  # downloads, scripts and the temporary browser profile are discarded.
1097
  (directory / 'animation.glb').write_bytes(glb)
 
1102
  data = (directory / f'animation.{extension}').read_bytes()
1103
  with target.open('wb' if args.overwrite else 'xb') as output:
1104
  output.write(data)
1105
+ progress(f"Exported {summary['joints']} joints, {summary['tracks']} tracks, {summary['duration']:.3f} seconds; "
1106
+ f"GLB: {summary['meshes']} meshes, {summary['materials']} materials, {summary['skins']} skins. BVH: skeleton only.")
1107
  for extension in ('bvh', 'glb'):
1108
  print(folder / f'{name}.{extension}')
1109
 
 
1124
  parser.add_argument('--fps', type=positive_number, default=30, help='approximate BVH sample rate (default: 30)')
1125
  parser.add_argument('--scale', type=positive_number, default=0.01, help='source-units-to-meters factor for GLB (default: 0.01); BVH keeps source units')
1126
  parser.add_argument('--overwrite', action='store_true', help='replace existing output files')
1127
+ parser.add_argument('--skeleton-only', action='store_true', help='omit GLB geometry, materials and skinning')
1128
  parser.add_argument('--no-sandbox', action='store_true', help='disable Chrome sandbox only if required by your runtime environment')
1129
  args = parser.parse_args()
1130
  try:
3D/animations/test_sketchfab_animation.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Offline regression tests: python -B -m unittest discover -s animations."""
2
+ import json
3
+ import struct
4
+ import unittest
5
+
6
+ import sketchfab_animation as exporter
7
+
8
+
9
+ def read_glb(data):
10
+ magic, version, length = struct.unpack_from('<III', data)
11
+ assert (magic, version, length) == (0x46546c67, 2, len(data))
12
+ size, kind = struct.unpack_from('<II', data, 12)
13
+ assert kind == 0x4e4f534a
14
+ g = json.loads(data[20:20 + size])
15
+ binary_size, kind = struct.unpack_from('<II', data, 20 + size)
16
+ assert kind == 0x004e4942
17
+ binary = data[28 + size:]
18
+ assert len(binary) == binary_size
19
+ return g, binary
20
+
21
+
22
+ def read_accessor(g, binary, index):
23
+ a = g['accessors'][index]
24
+ view = g['bufferViews'][a['bufferView']]
25
+ dim = {'SCALAR': 1, 'VEC2': 2, 'VEC3': 3, 'VEC4': 4, 'MAT4': 16}[a['type']]
26
+ fmt = {5126: 'f', 5123: 'H', 5125: 'I'}[a['componentType']]
27
+ return list(struct.unpack_from('<' + str(a['count'] * dim) + fmt, binary, view.get('byteOffset', 0)))
28
+
29
+
30
+ def fixture():
31
+ def bone(name, x, children):
32
+ bind = list(exporter.IDENTITY); bind[12] = -x
33
+ matrix = list(exporter.IDENTITY); matrix[12] = x
34
+ return {'osgAnimation.Bone': {
35
+ 'Name': name, 'Matrix': matrix, 'InvBindMatrixInSkeletonSpace': bind,
36
+ 'UpdateCallbacks': [{'osgAnimation.UpdateBone': {
37
+ 'Name': name, 'StackedTransforms': [
38
+ {'osgAnimation.StackedTranslate': {'Translate': [x, 0, 0]}}]}}],
39
+ 'Children': children}}
40
+
41
+ def array(values, dim, typ='Float32Array'):
42
+ return {'Array': {typ: {'Elements': values}}, 'ItemSize': dim}
43
+
44
+ geometry = {'Name': 'Triangle', 'VertexAttributeList': {
45
+ 'Vertex': array([0, 0, 0, 0, 1, 0, 0, 0, 1], 3),
46
+ 'Normal': array([1, 0, 0] * 3, 3),
47
+ 'TexCoord0': array([0, 0, 1, 0, 0, 1], 2)},
48
+ 'PrimitiveSetList': [{'DrawElementsUShort': {
49
+ 'Mode': 'TRIANGLES', 'Indices': array([0, 1, 2], 1, 'Uint16Array')}}]}
50
+ rig = {'osgAnimation.RigGeometry': {
51
+ 'BoneMap': {'Child': 9}, 'SourceGeometry': {'osg.Geometry': geometry},
52
+ 'VertexAttributeList': {'Bones': array([9, 65535, 0, 0] * 3, 4, 'Uint16Array'),
53
+ 'Weights': array([2, 0, 0, 0] * 3, 4)}}}
54
+ matrix = list(exporter.IDENTITY); matrix[12] = 3
55
+ scene = {'osg.Node': {'Children': [{'osgAnimation.Skeleton': {'Children': [
56
+ bone('Root', 0, [bone('Child', 1, [])]),
57
+ {'osg.MatrixTransform': {'Matrix': matrix, 'Children': [rig]}}]}}]}}
58
+ raw = struct.pack('<8f', 0, 1, 1, 0, 0, 2, 0, 0)
59
+ animation = {'osgAnimation.Vec3LerpChannel': {
60
+ 'Name': 'translate', 'TargetName': 'Child', 'KeyFrames': {
61
+ 'Time': {'ItemSize': 1, 'Array': {'Float32Array': {'Size': 2, 'Offset': 0}}},
62
+ 'Key': {'ItemSize': 3, 'Array': {'Float32Array': {'Size': 2, 'Offset': 8}}}}}}
63
+ return scene, animation, raw, rig
64
+
65
+
66
+ class MeshExportTests(unittest.TestCase):
67
+ def test_skin_palette_bind_transform_and_animation(self):
68
+ scene, animation, raw, _ = fixture()
69
+ glb, bvh, summary = exporter.export_animation(scene, animation, raw, 'Test', 30, .01, {})
70
+ g, binary = read_glb(glb)
71
+ self.assertEqual((summary['meshes'], summary['skins']), (1, 1))
72
+ skin = g['skins'][0]
73
+ self.assertEqual(skin['joints'], [0, 1])
74
+ binds = read_accessor(g, binary, skin['inverseBindMatrices'])
75
+ self.assertEqual(binds[12], 3)
76
+ self.assertEqual(binds[28], 2) # inverse child bind (-1) times mesh transform (+3)
77
+ attrs = g['meshes'][0]['primitives'][0]['attributes']
78
+ self.assertEqual(read_accessor(g, binary, attrs['JOINTS_0']), [1, 0, 0, 0] * 3)
79
+ self.assertEqual(read_accessor(g, binary, attrs['WEIGHTS_0']), [1, 0, 0, 0] * 3)
80
+ self.assertEqual(g['animations'][0]['channels'][0]['target'], {'node': 1, 'path': 'translation'})
81
+ for view in g['bufferViews']:
82
+ self.assertEqual(view['byteOffset'] % 4, 0)
83
+ self.assertLessEqual(view['byteOffset'] + view['byteLength'], len(binary))
84
+ self.assertIn('Frames: 31', bvh)
85
+
86
+ def test_invalid_skin_fails_instead_of_exporting_broken_mesh(self):
87
+ scene, animation, raw, rig = fixture()
88
+ rig['osgAnimation.RigGeometry']['VertexAttributeList']['Weights']['Array']['Float32Array']['Elements'] = [0] * 12
89
+ with self.assertRaisesRegex(exporter.ExportError, 'Invalid skin weights'):
90
+ exporter.export_animation(scene, animation, raw, 'Test', 30, .01, {})
91
+
92
+ def test_skeleton_only_keeps_bvh_and_ignores_mesh_errors(self):
93
+ scene, animation, raw, rig = fixture()
94
+ _, expected, _ = exporter.export_animation(scene, animation, raw, 'Test', 30, .01, {})
95
+ rig['osgAnimation.RigGeometry']['SourceGeometry'] = {}
96
+ glb, bvh, summary = exporter.export_animation(scene, animation, raw, 'Test', 30, .01)
97
+ self.assertEqual(expected, bvh)
98
+ self.assertEqual(summary['meshes'], 0)
99
+ self.assertNotIn('meshes', read_glb(glb)[0])
100
+
101
+ def test_meshless_scene(self):
102
+ scene, animation, raw, _ = fixture()
103
+ scene['osg.Node']['Children'][0]['osgAnimation.Skeleton']['Children'].pop()
104
+ glb, _, summary = exporter.export_animation(scene, animation, raw, 'Test', 30, .01, {})
105
+ self.assertEqual(summary['meshes'], 0)
106
+ self.assertNotIn('meshes', read_glb(glb)[0])
107
+
108
+ def test_static_mesh_and_viewer_wireframe(self):
109
+ scene, animation, raw, rig = fixture()
110
+ transform = scene['osg.Node']['Children'][0]['osgAnimation.Skeleton']['Children'][1]['osg.MatrixTransform']
111
+ geometry = rig['osgAnimation.RigGeometry']['SourceGeometry']['osg.Geometry']
112
+ transform['Children'] = [{'osg.Geometry': geometry}, {'osg.Geometry': {
113
+ 'UserDataContainer': {'Values': [{'Name': 'wireframe', 'Value': '1'}]}}}]
114
+ glb, _, summary = exporter.export_animation(scene, animation, raw, 'Test', 30, .01, {})
115
+ g, _ = read_glb(glb)
116
+ self.assertEqual(summary['meshes'], 1)
117
+ self.assertNotIn('skins', g)
118
+ self.assertNotIn('JOINTS_0', g['meshes'][0]['primitives'][0]['attributes'])
119
+ self.assertTrue(any(n.get('matrix', [0] * 16)[12] == 3 for n in g['nodes']))
120
+
121
+ def test_material_factors_and_odd_image_alignment(self):
122
+ scene, animation, raw, _ = fixture()
123
+ materials = {'purple': {'stateSetID': 0, 'name': 'Purple', 'cullFace': 'DISABLE', 'channels': {
124
+ 'AlbedoPBR': {'enable': True, 'color': [.4, .02, .6], 'factor': .5,
125
+ 'texture': {'uid': 'image', 'texCoordUnit': 0}},
126
+ 'MetalnessPBR': {'enable': True, 'factor': .2},
127
+ 'RoughnessPBR': {'enable': True, 'factor': .6}}}}
128
+ glb, _, _ = exporter.export_animation(scene, animation, raw, 'Test', 30, .01, {}, materials,
129
+ {'purple/baseColor': b'odd length!'})
130
+ g, binary = read_glb(glb)
131
+ pbr = g['materials'][0]['pbrMetallicRoughness']
132
+ self.assertEqual(pbr['baseColorFactor'], [.2, .01, .3, 1])
133
+ self.assertEqual(pbr['roughnessFactor'], .6)
134
+ self.assertTrue(g['materials'][0]['doubleSided'])
135
+ self.assertEqual(g['meshes'][0]['primitives'][0]['material'], 0)
136
+ for view in g['bufferViews']:
137
+ self.assertEqual(view['byteOffset'] % 4, 0)
138
+ self.assertEqual(len(binary) % 4, 0)
139
+
140
+
141
+ class GeometryCodecTests(unittest.TestCase):
142
+ def test_high_watermark_implicit_strip_and_predictive_positions(self):
143
+ # Explicit compressed values are all zero: implicit zero codes produce
144
+ # sequential indices through the high-watermark codec, including padding.
145
+ reader = exporter.GeometryReader({}, {})
146
+ desc = {'Array': {'Uint32Array': {'Elements': [4, 1, 0, 8, 0]}}, 'ItemSize': 1}
147
+ indices, mode = reader.indices({'DrawElementsUInt': {'Mode': 'TRIANGLE_STRIP', 'Indices': desc}},
148
+ {'attributes': 16, 'triangle_mode': 7}, [0])
149
+ self.assertEqual((indices, mode), ([0, 1, 2, 3], 5))
150
+ # The fourth point residual is zero: p3 = p1 + p2 - p0.
151
+ vertices = {'Array': {'Int32Array': {'Elements': [0, 0, 0, 1, 0, 0, 0, 1, 0, 0, 0, 0]}}, 'ItemSize': 3}
152
+ positions = reader.attribute('Vertex', vertices, {'attributes': 1, 'vertex_mode': 2}, indices)
153
+ self.assertEqual(positions[-1], [1, 1, 0])
154
+
155
+ def test_unsigned_indices_delta_codec(self):
156
+ reader = exporter.GeometryReader({}, {})
157
+ desc = {'Array': {'Uint32Array': {'Elements': [2, 3, 2]}}, 'ItemSize': 1}
158
+ indices, _ = reader.indices({'DrawElementsUInt': {'Mode': 'TRIANGLES', 'Indices': desc}},
159
+ {'attributes': 16, 'triangle_mode': 1}, [0])
160
+ self.assertEqual(indices, [2, 0, 1])
161
+
162
+ def test_truncated_buffer_and_varint(self):
163
+ reader = exporter.GeometryReader({}, {'mesh.bin': b'\x80'})
164
+ for typ, extra in [('Float32Array', {}), ('Int32Array', {'Encoding': 'varint'})]:
165
+ with self.assertRaises(exporter.ExportError):
166
+ reader.array({'ItemSize': 3, 'Array': {typ: {'File': 'mesh.bin', 'Size': 1, **extra}}})
167
+
168
+
169
+ if __name__ == '__main__':
170
+ unittest.main()
3D/animations/touchdown-raiz/touchdown-raiz.glb CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:ae17a7fa50a687561288822c174bbbe68c90df3146f905cbdfcada20c134f4ef
3
- size 1115584
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:504ca7d4d48b5dce59226bd51b2962d52ff109f835287ad4dedcc11bc60e6e0e
3
+ size 2208156