{"slug": "tripo3d-ai-model-downloader", "title": "Tripo3d.ai Model Downloader", "summary": "A JavaScript script called `tripo3d_model_downloader.js` that extracts 3D models from the Tripo3D.ai website. The script works by accessing the site's Vue 3 and TresJS/Three.js renderer components through the browser console to locate the loaded 3D model mesh and its textures. It then manually assembles the geometry and texture data into a valid GLB (GLTF 2.0 binary) file and triggers a download, bypassing the site's standard export tool.", "body_md": "Created\nMay 13, 2026 05:33\n\n-\n-\nSave JackTYM/baa22f2584eab498aa4c1bb29c1fa9af to your computer and use it in GitHub Desktop.\n\nTripo3d.ai Model Downloader\n\nThis file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.\n\n[Learn more about bidirectional Unicode characters](https://github.co/hiddenchars)| /** | |\n| * tripo3d_model_downloader.js | |\n| * ───────────────────────────────────────────────────────────────────────────── | |\n| * Extracts the loaded 3D model directly from the TresJS/Three.js renderer on | |\n| * studio.tripo3d.ai, bypasses the export tool entirely, and triggers a .glb | |\n| * download. | |\n| * | |\n| * HOW IT WORKS | |\n| * ───────────────────────────────────────────────────────────────────────────── | |\n| * 1. The site is a Nuxt 3 / Vue 3 app using TresJS (Vue wrapper for Three.js). | |\n| * 2. The Vue app is mounted on #__nuxt with __vue_app__ attached to the element. | |\n| * 3. The current page component is accessible via: | |\n| * vueApp → $router → currentRoute.matched[0].instances.default._ | |\n| * 4. Traversing the component subTree finds the TresJS \"Context\" component, | |\n| * whose `provides.useTres` object holds { scene, camera, renderer }. | |\n| * 5. The model mesh (named \"tripo_node_<operatorId>\") lives inside a Group | |\n| * two levels deep in the scene. | |\n| * 6. Geometry buffers (position, normal, uv, indices) are raw TypedArrays. | |\n| * 7. The diffuse texture is an ImageBitmap — drawn to an offscreen canvas and | |\n| * exported as PNG. | |\n| * 8. A valid GLB (GLTF 2.0 binary) is assembled manually and downloaded. | |\n| * | |\n| * USAGE | |\n| * ───────────────────────────────────────────────────────────────────────────── | |\n| * Paste this entire script into the browser DevTools console while on any | |\n| * studio.tripo3d.ai/3d-model/<id> page and wait for the download prompt. | |\n| * | |\n| * TESTED ON | |\n| * ───────────────────────────────────────────────────────────────────────────── | |\n| * Three.js r183, TresJS, Nuxt 3, Pinia — May 2026 | |\n| */ | |\n| (async function extractTripoGLB() { | |\n| // ── 1. Locate the Vue app ───────────────────────────────────────────────── | |\n| const appEl = document.querySelector('[data-v-app]') || document.querySelector('#__nuxt'); | |\n| if (!appEl || !appEl.__vue_app__) { | |\n| throw new Error('Vue app not found. Are you on a Tripo Studio model page?'); | |\n| } | |\n| const vueApp = appEl.__vue_app__; | |\n| // ── 2. Locate the current page component instance ──────────────────────── | |\n| const router = vueApp.config?.globalProperties?.$router; | |\n| const route = router?.currentRoute?.value; | |\n| const matched = route?.matched?.[0]; | |\n| if (!matched) throw new Error('No matched route found.'); | |\n| const pageInternal = matched.instances?.default?._; | |\n| if (!pageInternal) throw new Error('Page component internal instance not found.'); | |\n| // ── 3. Walk the vnode subTree to find a component by display name ───────── | |\n| function findComponentByName(vnode, targetName, depth = 0) { | |\n| if (!vnode || depth > 40) return null; | |\n| if (vnode.component) { | |\n| const inst = vnode.component; | |\n| const name = inst.type?.name || inst.type?.__name || ''; | |\n| if (name === targetName) return inst; | |\n| const found = findComponentByName(inst.subTree, targetName, depth + 1); | |\n| if (found) return found; | |\n| return null; | |\n| } | |\n| if (Array.isArray(vnode.children)) { | |\n| for (const child of vnode.children) { | |\n| if (child && typeof child === 'object') { | |\n| const found = findComponentByName(child, targetName, depth); | |\n| if (found) return found; | |\n| } | |\n| } | |\n| } | |\n| return null; | |\n| } | |\n| // ── 4. Get TresJS context (scene / camera / renderer) ──────────────────── | |\n| // TresJS v2+ exposes context via a \"Context\" component's provide key \"useTres\" | |\n| const contextComp = findComponentByName(pageInternal.subTree, 'Context'); | |\n| if (!contextComp) throw new Error('\"Context\" TresJS component not found in vnode tree.'); | |\n| const tres = contextComp.provides?.useTres; | |\n| if (!tres) throw new Error('\"useTres\" not found in Context provides.'); | |\n| const scene = tres.scene?.value ?? tres.scene; | |\n| if (!scene?.isScene) throw new Error('THREE.Scene not found in useTres.scene.'); | |\n| console.log('[tripo-extract] Scene found:', scene.type, '— children:', scene.children.length); | |\n| // ── 5. Find the model mesh in the scene ─────────────────────────────────── | |\n| function findMeshes(obj, results = []) { | |\n| if (!obj) return results; | |\n| if (obj.isMesh) results.push(obj); | |\n| if (obj.children) for (const c of obj.children) findMeshes(c, results); | |\n| return results; | |\n| } | |\n| const allMeshes = findMeshes(scene); | |\n| // The model mesh has the most vertices; filter out tiny background quads | |\n| const modelMesh = allMeshes.reduce((best, m) => | |\n| (m.geometry?.attributes?.position?.count ?? 0) > | |\n| (best.geometry?.attributes?.position?.count ?? 0) ? m : best | |\n| , allMeshes[0]); | |\n| if (!modelMesh) throw new Error('No mesh found in scene.'); | |\n| const vCount = modelMesh.geometry.attributes.position.count; | |\n| const iCount = modelMesh.geometry.index?.count ?? 0; | |\n| console.log(`[tripo-extract] Model mesh: \"${modelMesh.name}\" — ${vCount} vertices, ${iCount} indices`); | |\n| // ── 6. Extract geometry typed arrays ───────────────────────────────────── | |\n| const geo = modelMesh.geometry; | |\n| const positions = geo.attributes.position.array; // Float32Array | |\n| // Skip normals — Tripo's AI-generated normals are inconsistent (random directions). | |\n| // By omitting them, Blender will calculate smooth normals from face winding order. | |\n| const normals = null; | |\n| const uvs = geo.attributes.uv?.array; // Float32Array (may be absent) | |\n| const indices = geo.index?.array; // Uint32Array (may be absent) | |\n| console.log('[tripo-extract] Skipping original normals — Blender will recalculate smooth normals'); | |\n| // ── 7. Extract diffuse texture → PNG ArrayBuffer ───────────────────────── | |\n| let texturePNGBytes = null; | |\n| const texImage = modelMesh.material?.map?.image; | |\n| if (texImage) { | |\n| const offscreen = document.createElement('canvas'); | |\n| offscreen.width = texImage.width ?? texImage.naturalWidth ?? 1024; | |\n| offscreen.height = texImage.height ?? texImage.naturalHeight ?? 1024; | |\n| const ctx2d = offscreen.getContext('2d'); | |\n| ctx2d.drawImage(texImage, 0, 0); | |\n| const pngBlob = await new Promise(res => offscreen.toBlob(res, 'image/png')); | |\n| texturePNGBytes = new Uint8Array(await pngBlob.arrayBuffer()); | |\n| console.log(`[tripo-extract] Texture: ${offscreen.width}×${offscreen.height} → ${(texturePNGBytes.byteLength/1024).toFixed(0)} KB PNG`); | |\n| } else { | |\n| console.warn('[tripo-extract] No diffuse texture found; exporting geometry only.'); | |\n| } | |\n| // ── 8. Build binary buffer layout (4-byte aligned) ──────────────────────── | |\n| const align4 = n => Math.ceil(n / 4) * 4; | |\n| const vertexCount = positions.length / 3; | |\n| const posBytes = positions.byteLength; | |\n| const normBytes = normals ? align4(vertexCount * 3 * 4) : 0; | |\n| const uvBytes = uvs ? align4(uvs.byteLength) : 0; | |\n| const idxBytes = indices ? align4(indices.byteLength) : 0; | |\n| const texBytes = texturePNGBytes ? align4(texturePNGBytes.byteLength) : 0; | |\n| const normOffset = align4(posBytes); | |\n| const uvOffset = normOffset + normBytes; | |\n| const idxOffset = uvOffset + uvBytes; | |\n| const texOffset = idxOffset + idxBytes; | |\n| const totalBin = align4(texOffset + texBytes); | |\n| // ── 9. Build GLTF JSON ──────────────────────────────────────────────────── | |\n| // Compute AABB for position accessor (required by spec) | |\n| let minPos = [Infinity, Infinity, Infinity]; | |\n| let maxPos = [-Infinity, -Infinity, -Infinity]; | |\n| for (let i = 0; i < positions.length; i += 3) { | |\n| minPos[0] = Math.min(minPos[0], positions[i]); | |\n| minPos[1] = Math.min(minPos[1], positions[i+1]); | |\n| minPos[2] = Math.min(minPos[2], positions[i+2]); | |\n| maxPos[0] = Math.max(maxPos[0], positions[i]); | |\n| maxPos[1] = Math.max(maxPos[1], positions[i+1]); | |\n| maxPos[2] = Math.max(maxPos[2], positions[i+2]); | |\n| } | |\n| const accessors = []; | |\n| const bufferViews = []; | |\n| const primitiveAttr = {}; | |\n| // POSITION accessor (index 0) | |\n| bufferViews.push({ buffer: 0, byteOffset: 0, byteLength: posBytes, target: 34962 }); | |\n| accessors.push({ | |\n| bufferView: 0, byteOffset: 0, componentType: 5126, count: positions.length / 3, | |\n| type: 'VEC3', min: minPos, max: maxPos | |\n| }); | |\n| primitiveAttr.POSITION = 0; | |\n| // NORMAL accessor | |\n| if (normals) { | |\n| const normBvIdx = bufferViews.length; | |\n| bufferViews.push({ buffer: 0, byteOffset: normOffset, byteLength: vertexCount * 3 * 4, target: 34962 }); | |\n| const normAccIdx = accessors.length; | |\n| accessors.push({ bufferView: normBvIdx, byteOffset: 0, componentType: 5126, count: vertexCount, type: 'VEC3' }); | |\n| primitiveAttr.NORMAL = normAccIdx; | |\n| } | |\n| // TEXCOORD_0 accessor | |\n| if (uvs) { | |\n| const uvBvIdx = bufferViews.length; | |\n| bufferViews.push({ buffer: 0, byteOffset: uvOffset, byteLength: uvs.byteLength, target: 34962 }); | |\n| const uvAccIdx = accessors.length; | |\n| accessors.push({ bufferView: uvBvIdx, byteOffset: 0, componentType: 5126, count: uvs.length / 2, type: 'VEC2' }); | |\n| primitiveAttr.TEXCOORD_0 = uvAccIdx; | |\n| } | |\n| // INDEX accessor | |\n| let indicesAccessorIdx = null; | |\n| if (indices) { | |\n| const bvIdx = bufferViews.length; | |\n| bufferViews.push({ buffer: 0, byteOffset: idxOffset, byteLength: indices.byteLength, target: 34963 }); | |\n| indicesAccessorIdx = accessors.length; | |\n| accessors.push({ bufferView: bvIdx, byteOffset: 0, componentType: 5125, count: indices.length, type: 'SCALAR' }); | |\n| } | |\n| // IMAGE buffer view | |\n| let imageBufferView = null; | |\n| if (texturePNGBytes) { | |\n| imageBufferView = bufferViews.length; | |\n| bufferViews.push({ buffer: 0, byteOffset: texOffset, byteLength: texturePNGBytes.byteLength }); | |\n| } | |\n| // Primitive | |\n| const primitive = { attributes: primitiveAttr }; | |\n| if (indicesAccessorIdx !== null) primitive.indices = indicesAccessorIdx; | |\n| if (imageBufferView !== null) primitive.material = 0; | |\n| // Full GLTF JSON | |\n| const gltf = { | |\n| asset: { version: '2.0', generator: 'tripo3d-browser-extractor' }, | |\n| scene: 0, | |\n| scenes: [{ name: 'Scene', nodes: [0] }], | |\n| nodes: [{ name: modelMesh.name || 'tripo_model', mesh: 0 }], | |\n| meshes: [{ name: 'tripo_mesh', primitives: [primitive] }], | |\n| accessors, | |\n| bufferViews, | |\n| buffers: [{ byteLength: totalBin }], | |\n| ...(imageBufferView !== null ? { | |\n| materials: [{ | |\n| name: 'tripo_mat', | |\n| pbrMetallicRoughness: { | |\n| baseColorTexture: { index: 0 }, | |\n| metallicFactor: 0.0, | |\n| roughnessFactor: 0.5 | |\n| }, | |\n| doubleSided: true // Match Tripo's web renderer — renders both face directions | |\n| }], | |\n| textures: [{ source: 0 }], | |\n| images: [{ mimeType: 'image/png', bufferView: imageBufferView }] | |\n| } : {}) | |\n| }; | |\n| // ── 10. Assemble GLB binary ─────────────────────────────────────────────── | |\n| const jsonEncoded = new TextEncoder().encode(JSON.stringify(gltf)); | |\n| const jsonPadded = align4(jsonEncoded.length); | |\n| const totalSize = 12 // GLB header | |\n| + 8 + jsonPadded // JSON chunk | |\n| + 8 + totalBin; // BIN chunk | |\n| const glb = new ArrayBuffer(totalSize); | |\n| const dv = new DataView(glb); | |\n| const buf = new Uint8Array(glb); | |\n| let off = 0; | |\n| // GLB header | |\n| dv.setUint32(off, 0x46546C67, true); off += 4; // magic \"glTF\" | |\n| dv.setUint32(off, 2, true); off += 4; // version 2 | |\n| dv.setUint32(off, totalSize, true); off += 4; // total length | |\n| // JSON chunk | |\n| dv.setUint32(off, jsonPadded, true); off += 4; // chunk length | |\n| dv.setUint32(off, 0x4E4F534A, true); off += 4; // \"JSON\" | |\n| buf.set(jsonEncoded, off); | |\n| for (let i = jsonEncoded.length; i < jsonPadded; i++) buf[off + i] = 0x20; // pad with spaces | |\n| off += jsonPadded; | |\n| // BIN chunk | |\n| dv.setUint32(off, totalBin, true); off += 4; // chunk length | |\n| dv.setUint32(off, 0x004E4942, true); off += 4; // \"BIN\\0\" | |\n| const binStart = off; | |\n| buf.set(new Uint8Array(positions.buffer, positions.byteOffset, positions.byteLength), binStart); | |\n| if (normals) buf.set(new Uint8Array(normals.buffer, normals.byteOffset, vertexCount * 3 * 4), binStart + normOffset); | |\n| if (uvs) buf.set(new Uint8Array(uvs.buffer, uvs.byteOffset, uvs.byteLength), binStart + uvOffset); | |\n| if (indices) buf.set(new Uint8Array(indices.buffer, indices.byteOffset, indices.byteLength), binStart + idxOffset); | |\n| if (texturePNGBytes) buf.set(texturePNGBytes, binStart + texOffset); | |\n| // ── 11. Verify & download ───────────────────────────────────────────────── | |\n| const magic = dv.getUint32(0, true); | |\n| const fileMB = (totalSize / 1024 / 1024).toFixed(2); | |\n| if (magic !== 0x46546C67) throw new Error('GLB magic header mismatch — build failed.'); | |\n| console.log(`[tripo-extract] ✓ GLB valid — ${fileMB} MB`); | |\n| const modelId = window.location.pathname.split('/').filter(Boolean).pop() || 'model'; | |\n| const filename = `tripo_${modelId}.glb`; | |\n| const blobUrl = URL.createObjectURL(new Blob([glb], { type: 'model/gltf-binary' })); | |\n| const anchor = document.createElement('a'); | |\n| anchor.href = blobUrl; | |\n| anchor.download = filename; | |\n| anchor.click(); | |\n| // Revoke after a short delay to free memory | |\n| setTimeout(() => URL.revokeObjectURL(blobUrl), 10_000); | |\n| console.log(`[tripo-extract] ✓ Download triggered: ${filename} (${fileMB} MB)`); | |\n| return { filename, fileMB, vertices: vCount, indices: iCount }; | |\n| })(); |", "url": "https://wpnews.pro/news/tripo3d-ai-model-downloader", "canonical_source": "https://gist.github.com/JackTYM/baa22f2584eab498aa4c1bb29c1fa9af", "published_at": "2026-05-13 05:33:02+00:00", "updated_at": "2026-05-21 18:36:44.438304+00:00", "lang": "en", "topics": ["developer-tools", "open-source", "products"], "entities": ["Tripo3d.ai", "TresJS", "Three.js", "Nuxt 3", "Vue 3"], "alternates": {"html": "https://wpnews.pro/news/tripo3d-ai-model-downloader", "markdown": "https://wpnews.pro/news/tripo3d-ai-model-downloader.md", "text": "https://wpnews.pro/news/tripo3d-ai-model-downloader.txt", "jsonld": "https://wpnews.pro/news/tripo3d-ai-model-downloader.jsonld"}}