Sketchfab Model Downloader

Download Sketchfab models

目前为 2025-05-03 提交的版本。查看 最新版本

// ==UserScript==
// @name         Sketchfab Model Downloader
// @version      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;

  const func_drawGeometry    = /(this\._stateCache\.drawGeometry\(this\._graphicContext,t\))/g;
  const func_drawArrays      = /t\.drawArrays\(t\.TRIANGLES,0,6\)/g;
  const func_renderInto1     = /A\.renderInto\(n,E,R/g;
  const func_renderInto2     = /g\.renderInto=function\(e,i,r/g;
  const func_getResourceImage= /getResourceImage:function\(e,t\){/g;

  let zip, folder;
  let objects = {};
  let saveimagecache2 = {};
  let button_dw = false;
  window.allmodel = [];

  function resetDownloadState() {
    zip     = new JSZip();
    folder  = zip.folder('collection');
    objects = {};
    saveimagecache2 = {};
  }

  function addbtnfunc() {
    const titlebar = document.evaluate("//div[@class='titlebar']", document, null, 9, null).singleNodeValue;
    if (titlebar && !button_dw) {
      const btn = document.createElement("a");
      btn.className = "control";
      btn.innerHTML = `
        <pre style="
          background-color: #e74c3c;
          color: white;
          padding: 10px 20px;
          border-radius: 8px;
          font-size: 16px;
          cursor: pointer;
          margin: 4px 2px;
          display: inline-block;
          text-align: center;
        ">DOWNLOAD</pre>`;
      btn.addEventListener("click", dodownload, false);
      titlebar.appendChild(btn);
      button_dw = true;
    } else if (!button_dw) {
      setTimeout(addbtnfunc, 3000);
    }
  }

  function parseobj(obj) {
    const primitives = [];
    obj._primitives.forEach(p => {
      if (p && p.indices) {
        primitives.push({
          mode:    p.mode,
          indices: p.indices._elements
        });
      }
    });

    const attr = obj._attributes;
    const uv = (['TexCoord0','TexCoord1','TexCoord2','TexCoord3','TexCoord4','TexCoord5','TexCoord6','TexCoord7','TexCoord8']
      .find(ch => attr[ch])) || null;

    return {
      vertex:     attr.Vertex._elements,
      normal:     attr.Normal ? attr.Normal._elements : [],
      uv:         uv           ? attr[uv]._elements : [],
      primitives: primitives
    };
  }

  function dosavefile(mdl) {
    const { name, obj } = mdl;
    let str = '';
    str += `mtllib ${name}.mtl\n`;
    str += `o ${name}\n`;
    for (let i = 0; i < obj.vertex.length; i += 3) {
      str += `v ${obj.vertex[i]} ${obj.vertex[i+1]} ${obj.vertex[i+2]}\n`;
    }
    for (let i = 0; i < obj.normal.length; i += 3) {
      str += `vn ${obj.normal[i]} ${obj.normal[i+1]} ${obj.normal[i+2]}\n`;
    }
    for (let i = 0; i < obj.uv.length; i += 2) {
      str += `vt ${obj.uv[i]} ${obj.uv[i+1]}\n`;
    }
    str += `s on\n`;
    const hasVN = obj.normal.length > 0;
    const hasVT = obj.uv.length     > 0;
    obj.primitives.forEach(primitive => {
      if (primitive.mode === 4 || primitive.mode === 5) {
        const isStrip = primitive.mode === 5;
        for (let j = 0; j + 2 < primitive.indices.length; isStrip ? j++ : j += 3) {
          str += 'f ';
          let order = [0,1,2];
          if (isStrip && (j % 2 === 1)) order = [0,2,1];
          for (let k = 0; k < 3; k++) {
            const idx = primitive.indices[j + order[k]] + 1;
            str += idx;
            if (hasVT || hasVN) {
              str += '/';
              if (hasVT) str += idx;
              if (hasVN) str += `/${idx}`;
            }
            str += ' ';
          }
          str += '\n';
        }
      }
    });

    objects[`${name}.obj`] = new Blob([str], { type: 'text/plain' });
  }

  function PackAll() {
    for (const filename in objects) {
      folder.file(filename, objects[filename], { binary: true });
    }
    const modelName = document.querySelector('.model-name__label')?.textContent.trim() || 'sketchfab';
    zip.generateAsync({ type: 'blob' })
       .then(content => saveAs(content, `${modelName}.zip`));
  }

  function dodownload() {
    resetDownloadState();
    window.allmodel.forEach((obj, idx) => {
      const mdl = {
        name: `model_${idx}`,
        obj:  parseobj(obj)
      };
      dosavefile(mdl);
    });
    PackAll();
  }

  window.attachbody = function(obj) {
    if (!obj._faked && (obj._name || (obj.stateset && obj.stateset._name))) {
      obj._faked = true;
      if (obj._name === "composer layer" || obj._name === "Ground - Geometry") return;
      window.allmodel.push(obj);
    }
  };

  window.drawhookcanvas = function(e, imagemodel) {
    if (!imagemodel) return e;
    const format = e.options.format;
    let best = { size:0 }, bestUrl = e.url;
    imagemodel.attributes.images.forEach(img => {
      const okFormat = format==='A' ? img.options.format==='A' : true;
      if (okFormat && img.size > best.size) {
        best = img;
        bestUrl = img.url;
      }
    });
    if (!saveimagecache2[bestUrl]) {
      saveimagecache2[bestUrl] = { name: imagemodel.attributes.name };
    }
    return best;
  };

  window.drawhookimg = function(gl, t) {
    const url = t[5].currentSrc;
    if (!saveimagecache2[url]) return;
    const w = t[5].width, h = t[5].height;
    const px = new Uint8Array(w*h*4);
    gl.readPixels(0, 0, w, h, gl.RGBA, gl.UNSIGNED_BYTE, px);
    const rowBytes = w*4;
    for (let y=0; y<Math.floor(h/2); y++) {
      const topOff = y*rowBytes, botOff = (h-y-1)*rowBytes;
      const tmp = px.slice(topOff, topOff+rowBytes);
      px.copyWithin(topOff, botOff, botOff+rowBytes);
      px.set(tmp, botOff);
    }
    const canvas = document.createElement('canvas');
    canvas.width = w; canvas.height = h;
    const ctx = canvas.getContext('2d');
    const imgData = ctx.createImageData(w,h);
    imgData.data.set(px);
    ctx.putImageData(imgData, 0, 0);
    const baseName = saveimagecache2[url].name.replace(/\.[^\.]+$/, '');
    canvas.toBlob(blob => {
      objects[`${baseName}.png`] = blob;
    }, 'image/png');
  };

  (() => {
    class BeforeEvent {
      constructor(script) {
        this.script = script;
        this._cancel = false;
      }
      preventDefault() { this._cancel = true; }
    }
    const cbs = [];
    window.addBeforeScriptExecuteListener = f => {
      if (typeof f!=='function') throw new Error("Handler must be function");
      cbs.push(f);
    };
    const dispatch = node => {
      if (node.tagName!=='SCRIPT') return;
      const e = new BeforeEvent(node);
      cbs.forEach(fn=>fn(e));
      if (e._cancel) node.remove();
    };
    new MutationObserver(muts => {
      muts.forEach(m =>
        m.addedNodes.forEach(dispatch)
      );
    }).observe(document, { childList:true, subtree:true });
  })();

  window.onbeforescriptexecute = e => {
    const src = e.script?.src||"";
    if (!/web\/dist\/|standaloneViewer/.test(src)) return;
    e.preventDefault();
    const req = new XMLHttpRequest();
    req.open('GET', src, false);
    req.send();
    let txt = req.responseText;
    txt = txt.replace(func_renderInto1, "$& ,i");
    txt = txt.replace(func_renderInto2,  "$& ,image_data");
    txt = txt.replace(func_drawArrays,   "$& ,window.drawhookimg(t,image_data)");
    txt = txt.replace(func_getResourceImage, "$& e = window.drawhookcanvas(e,this._imageModel);");
    txt = txt.replace(func_drawGeometry,  (m, g1) => g1 + ";window.attachbody(t);");
    const s2 = document.createElement('script');
    s2.type = "text/javascript";
    s2.text = txt;
    document.head.appendChild(s2);
    setTimeout(addbtnfunc, 3000);
  };

})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址