Commit ·
e0d67d9
1
Parent(s): 813c192
update
Browse files- 3D/animations/540-hook/540-hook.bvh +0 -0
- 3D/animations/540-hook/540-hook.glb +3 -0
- 3D/animations/540-hyper-round/540-hyper-round.glb +2 -2
- 3D/animations/hook-kick/hook-kick.glb +2 -2
- 3D/animations/roundhouse-kick/roundhouse-kick.glb +2 -2
- 3D/animations/side-kick/side-kick.glb +2 -2
- 3D/animations/sketchfab_animation.py +633 -38
- 3D/animations/test_sketchfab_animation.py +170 -0
- 3D/animations/touchdown-raiz/touchdown-raiz.glb +2 -2
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:
|
| 3 |
-
size
|
|
|
|
| 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:
|
| 3 |
-
size
|
|
|
|
| 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:
|
| 3 |
-
size
|
|
|
|
| 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:
|
| 3 |
-
size
|
|
|
|
| 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 |
-
|
| 11 |
-
|
|
|
|
| 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 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 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 |
-
#
|
| 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 |
-
|
| 389 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 390 |
binary=bytearray()
|
| 391 |
-
def
|
| 392 |
-
|
| 393 |
-
binary.extend(
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 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:
|
| 3 |
-
size
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:504ca7d4d48b5dce59226bd51b2962d52ff109f835287ad4dedcc11bc60e6e0e
|
| 3 |
+
size 2208156
|