// ==UserScript==
// @name Sketchfab Model Downloader
// @version 1.1
// @description Download Sketchfab models
// @author ncikkis
// @include /^https?:\/\/(www\.)?sketchfab\.com\/.*/
// @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.1.5/jszip.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/jszip-utils/0.0.2/jszip-utils.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/1.3.8/FileSaver.js
// @run-at document-start
// @grant unsafeWindow
// @namespace https://gf.qytechs.cn/users/1465318
// ==/UserScript==
(function(){
'use strict';
const window = unsafeWindow;
if (typeof saveAs === 'function') window.saveAs = saveAs;
const rDrawGeom = /(this\._stateCache\.drawGeometry\(this\._graphicContext,t\))/g;
const rDrawArrays = /t\.drawArrays\(t\.TRIANGLES,0,6\)/g;
const rRend1 = /A\.renderInto\(n,E,R/g;
const rRend2 = /g\.renderInto=function\(e,i,r/g;
const rGetResImg = /getResourceImage:function\(e,t\){/g;
let zip, folder, objects, cache, buttonInjected;
window.allmodel = [];
function reset() {
zip = new JSZip();
folder = zip.folder('collection');
objects = {};
cache = {};
console.log('[Downloader] state reset');
}
function tryInsertButton(){
console.log('[Downloader] trying to insert button…');
const sel = document.querySelector('.model-header__actions')
|| document.querySelector('.model-actions')
|| document.querySelector('.titlebar');
if(sel && !buttonInjected){
const a = document.createElement('a');
a.className = 'control';
a.innerHTML = `<pre style="
background:#e74c3c;color:#fff;
padding:10px 20px;border-radius:8px;
font-size:16px;cursor:pointer;
display:inline-block;margin:4px 2px;
">DOWNLOAD</pre>`;
a.addEventListener('click', downloadAll);
sel.appendChild(a);
buttonInjected = true;
console.log('[Downloader] button added');
}
else if(!buttonInjected) setTimeout(tryInsertButton,3000);
}
function parseMesh(o){
const prim = [];
o._primitives.forEach(p=>{
if(p && p.indices) prim.push({mode:p.mode,indices:p.indices._elements});
});
const A = o._attributes;
const uvKey = ['TexCoord0','TexCoord1','TexCoord2','TexCoord3','TexCoord4','TexCoord5','TexCoord6','TexCoord7','TexCoord8']
.find(k=>A[k]);
return {
vertex: A.Vertex._elements,
normal: A.Normal?A.Normal._elements:[],
uv: uvKey?A[uvKey]._elements:[],
primitives: prim
};
}
function saveMesh(m){
let s = `mtllib ${m.name}.mtl\n` + `o ${m.name}\n`;
m.obj.vertex.forEach((v,i)=>{ if(i%3===0) s+=`v ${v} ${m.obj.vertex[i+1]} ${m.obj.vertex[i+2]}\n`; });
m.obj.normal.forEach((v,i)=>{ if(i%3===0) s+=`vn ${v} ${m.obj.normal[i+1]} ${m.obj.normal[i+2]}\n`; });
m.obj.uv.forEach((v,i)=>{ if(i%2===0) s+=`vt ${v} ${m.obj.uv[i+1]}\n`; });
s+='s on\n';
const hasVT = m.obj.uv.length>0, hasVN = m.obj.normal.length>0;
m.obj.primitives.forEach(p=>{
if(p.mode===4||p.mode===5){
const strip = p.mode===5;
for(let j=0; j+2<p.indices.length; strip? j++ : j+=3){
s+='f ';
let order=[0,1,2];
if(strip && (j%2)) order=[0,2,1];
order.forEach(o=>{
const idx=p.indices[j+o]+1;
s+=idx;
if(hasVT||hasVN){
s+='/';
if(hasVT) s+=idx;
if(hasVN) s+=`/${idx}`;
}
s+=' ';
});
s+='\n';
}
}
});
objects[`${m.name}.obj`] = new Blob([s],{type:'text/plain'});
}
function packageAll(){
Object.keys(objects).forEach(fn=> folder.file(fn,objects[fn],{binary:true}));
const title = document.querySelector('.model-name__label')?.textContent.trim()||'sketchfab';
zip.generateAsync({type:'blob'}).then(b=>saveAs(b,`${title}.zip`));
}
function downloadAll(){
console.log('[Downloader] starting download');
reset();
window.allmodel.forEach((o,i)=> saveMesh({name:`model_${i}`,obj:parseMesh(o)}));
packageAll();
}
window.attachbody = o=>{
if(!o._faked && (o._name||(o.stateset&&o.stateset._name))){
o._faked=true;
if(o._name!=="composer layer" && o._name!=="Ground - Geometry"){
window.allmodel.push(o);
console.log('[Downloader] captured mesh',o._name||'unnamed');
}
}
};
window.drawhookcanvas = (e,mod)=>{
if(!mod) return e;
let best={size:0}, url=e.url;
mod.attributes.images.forEach(img=>{
const ok = e.options.format==='A'?img.options.format==='A':true;
if(ok&&img.size>best.size){best=img;url=img.url;}
});
if(!cache[url]) cache[url]={name:mod.attributes.name};
return best;
};
window.drawhookimg = (gl,t)=>{
const url=t[5].currentSrc;
if(!cache[url]) return;
const w=t[5].width, h=t[5].height, px=new Uint8Array(w*h*4);
gl.readPixels(0,0,w,h,gl.RGBA,gl.UNSIGNED_BYTE,px);
const row=w*4;
for(let y=0;y<h/2;y++){
const top=y*row, bot=(h-y-1)*row, tmp=px.slice(top,top+row);
px.copyWithin(top,bot,bot+row); px.set(tmp,bot);
}
const c=document.createElement('canvas');c.width=w;c.height=h;
const cx=c.getContext('2d'), id=cx.createImageData(w,h);
id.data.set(px);cx.putImageData(id,0,0);
const bn=cache[url].name.replace(/\.[^.]+$/,'');
c.toBlob(b=>objects[`${bn}.png`]=b,'image/png');
};
;(function(){
class E{constructor(s){this.script=s;this._cancel=false;}preventDefault(){this._cancel=true;}}
const cbs=[];
window.addBeforeScriptExecuteListener=f=>{if(typeof f!=='function')throw'';cbs.push(f);};
const disp=n=>{ if(n.tagName==='SCRIPT'){const ev=new E(n);cbs.forEach(f=>f(ev)); if(ev._cancel)n.remove();}};
new MutationObserver(ms=>ms.forEach(m=>m.addedNodes.forEach(disp))).observe(document,{childList:true,subtree:true});
})();
window.onbeforescriptexecute = e=>{
const s=e.script?.src||'';
if(!/web\/dist\/|standaloneViewer/.test(s)) return;
e.preventDefault();
const r=new XMLHttpRequest();r.open('GET',s,false);r.send();
let t=r.responseText;
t=t.replace(rRend1,"$& ,i");
t=t.replace(rRend2,"$& ,image_data");
t=t.replace(rDrawArrays,"$& ,window.drawhookimg(t,image_data)");
t=t.replace(rGetResImg,"$& e=window.drawhookcanvas(e,this._imageModel);");
t=t.replace(rDrawGeom,(m,g)=>g+";window.attachbody(t);");
const s2=document.createElement('script');s2.text=t;document.head.appendChild(s2);
setTimeout(tryInsertButton,3000);
};
})();