import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.module.js'; import { OrbitControls } from 'https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/controls/OrbitControls.js'; import createTinyGLTF from './tinygltf_v3.js'; const statusEl = document.getElementById('status'); const dropZone = document.getElementById('drop-zone'); const viewerEl = document.getElementById('viewer'); const fileInput = document.getElementById('file-input'); const sampleBtn = document.getElementById('sample-btn'); function setStatus(msg) { statusEl.textContent = msg; statusEl.title = msg; } let Module = null; let renderer = null; let scene = null; let camera = null; let controls = null; /* ---------------------------------------------------------------- */ /* Scene construction */ /* ---------------------------------------------------------------- */ // tg3w_prim layout in bytes: material, mode, vertex_count, index_count, // pos_offset, nrm_offset, uv_offset, idx_offset (all 4-byte ints). function readPrim(i) { const p = Module._tg3w_prim(i); const get = (off) => Module.getValue(p + off, 'i32'); return { material: get(0), mode: get(4), vertexCount: get(8), indexCount: get(12), posOffset: get(16), nrmOffset: get(20), uvOffset: get(24), idxOffset: get(28), }; } function copyF32(offsetFloats, count) { if (!offsetFloats || !count) return null; return Module.HEAPF32.subarray(offsetFloats, offsetFloats + count).slice(); } function makeTexture(texIdx) { const src = Module._tg3w_texture_source(texIdx); if (src < 0) return null; let blobPromise = null; const bv = Module._tg3w_image_buffer_view(src); if (bv >= 0) { const ptr = Module._tg3w_image_bytes(src); const size = Module._tg3w_image_size(src); if (!ptr || !size) return null; const bytes = Module.HEAPU8.slice(ptr, ptr + size); const mime = Module.UTF8ToString(Module._tg3w_image_mime(src)) || 'image/png'; blobPromise = Promise.resolve(new Blob([bytes], { type: mime })); } else { const uriPtr = Module._tg3w_image_uri(src); if (!uriPtr) return null; const uri = Module.UTF8ToString(uriPtr); const m = /^data:([^;,]+)(;base64)?,(.*)$/s.exec(uri); if (!m) return null; // external file URI — not resolvable without FS const bytes = m[2] ? Uint8Array.from(atob(m[3]), (c) => c.charCodeAt(0)) : new TextEncoder().encode(decodeURIComponent(m[3])); blobPromise = Promise.resolve(new Blob([bytes], { type: m[1] })); } return blobPromise.then((blob) => createImageBitmap(blob)).then((bmp) => { const tex = new THREE.CanvasTexture(bmp); tex.colorSpace = THREE.SRGBColorSpace; return tex; }); } function makeMaterial(mi) { const color = copyF32(Module._tg3w_material_base_color(mi) / 4, 4); const metalness = Module._tg3w_material_metallic(mi); const roughness = Module._tg3w_material_roughness(mi); const alphaMode = Module._tg3w_material_alpha_mode(mi); const alphaCutoff = Module._tg3w_material_alpha_cutoff(mi); const doubleSided = Module._tg3w_material_double_sided(mi); const mat = new THREE.MeshStandardMaterial({ color: new THREE.Color(color[0], color[1], color[2]), metalness, roughness, transparent: alphaMode === 2, alphaTest: alphaMode === 1 ? alphaCutoff : 0, side: doubleSided ? THREE.DoubleSide : THREE.FrontSide, }); const texIdx = Module._tg3w_material_base_color_texture(mi); if (texIdx >= 0) { return makeTexture(texIdx).then((tex) => { if (tex) mat.map = tex; return mat; }); } return Promise.resolve(mat); } // Convert triangle strip/fan index buffers to plain triangles. function triangulate(indices, mode) { if (mode === 4) return indices; const out = []; const n = indices.length; if (mode === 5) { // TRIANGLE_STRIP for (let i = 2; i < n; i++) { if (i % 2 === 0) out.push(indices[i - 2], indices[i - 1], indices[i]); else out.push(indices[i - 1], indices[i - 2], indices[i]); } } else if (mode === 6) { // TRIANGLE_FAN for (let i = 2; i < n; i++) out.push(indices[0], indices[i - 1], indices[i]); } return out; } function makePrimitive(i) { const p = readPrim(i); const positions = copyF32(p.posOffset, p.vertexCount * 3); const geometry = new THREE.BufferGeometry(); geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); if (p.nrmOffset >= 0) { geometry.setAttribute('normal', new THREE.BufferAttribute(copyF32(p.nrmOffset, p.vertexCount * 3), 3)); } if (p.uvOffset >= 0) { geometry.setAttribute('uv', new THREE.BufferAttribute(copyF32(p.uvOffset, p.vertexCount * 2), 2)); } if (p.indexCount) { const raw = Module.HEAPU32.subarray(p.idxOffset, p.idxOffset + p.indexCount).slice(); geometry.setIndex(new THREE.BufferAttribute(new Uint32Array(triangulate(raw, p.mode)), 1)); } return makeMaterial(p.material).then((mat) => { if (p.mode === 0) return new THREE.Points(geometry, mat); if (p.mode === 1 || p.mode === 2 || p.mode === 3) { const cls = p.mode === 1 ? THREE.Line : p.mode === 2 ? THREE.LineLoop : THREE.LineSegments; return new cls(geometry, new THREE.LineBasicMaterial({ color: mat.color })); } return new THREE.Mesh(geometry, mat); }); } function buildNodeGraph(nodeIndices, parent) { const jobs = []; const visit = (nodeIdx, parentGroup) => { const trs = copyF32(Module._tg3w_node_trs(nodeIdx) / 4, 10); const group = new THREE.Group(); group.position.set(trs[0], trs[1], trs[2]); group.quaternion.set(trs[3], trs[4], trs[5], trs[6]); group.scale.set(trs[7], trs[8], trs[9]); const meshIdx = Module._tg3w_node_mesh(nodeIdx); if (meshIdx >= 0) { const start = Module._tg3w_mesh_prim_start(meshIdx); const count = Module._tg3w_mesh_prim_count(meshIdx); for (let k = 0; k < count; k++) { jobs.push(makePrimitive(start + k).then((mesh) => { mesh.name = `mesh${meshIdx}[${k}]`; group.add(mesh); })); } } const childCount = Module._tg3w_node_child_count(nodeIdx); const childrenPtr = Module._tg3w_node_children(nodeIdx); for (let c = 0; c < childCount; c++) { const childIdx = Module.getValue(childrenPtr + c * 4, 'i32'); visit(childIdx, group); } parentGroup.add(group); }; for (const n of nodeIndices) visit(n, parent); return Promise.all(jobs).then(() => parent); } async function buildScene() { scene = new THREE.Scene(); scene.background = new THREE.Color(0x14161a); scene.add(new THREE.HemisphereLight(0xffffff, 0x223344, 1.0)); const dirLight = new THREE.DirectionalLight(0xffffff, 1.2); dirLight.position.set(5, 8, 6); scene.add(dirLight); const root = new THREE.Group(); scene.add(root); const sceneCount = Module._tg3w_scene_count(); let nodes = []; const defaultScene = Module._tg3w_default_scene(); if (defaultScene >= 0 && defaultScene < sceneCount) { const n = Module._tg3w_scene_node_count(defaultScene); const p = Module._tg3w_scene_nodes(defaultScene); for (let i = 0; i < n; i++) nodes.push(Module.getValue(p + i * 4, 'i32')); } else { for (let i = 0; i < Module._tg3w_node_count(); i++) nodes.push(i); } await buildNodeGraph(nodes, root); // Frame the camera on the geometry. const box = new THREE.Box3().setFromObject(root); if (!box.isEmpty()) { const center = box.getCenter(new THREE.Vector3()); const size = box.getSize(new THREE.Vector3()).length() || 1; camera.position.copy(center).add(new THREE.Vector3(size, size * 0.8, size)); camera.lookAt(center); controls.target.copy(center); controls.update(); } viewerEl.hidden = false; dropZone.hidden = true; setStatus(`Rendered: ${Module._tg3w_prim_count()} primitives, ${Module._tg3w_node_count()} nodes`); } /* ---------------------------------------------------------------- */ /* Loading */ /* ---------------------------------------------------------------- */ async function loadModel(bytes) { setStatus('Parsing with tinygltf v3 (C/WASM)…'); const ptr = Module._malloc(bytes.length); Module.HEAPU8.set(bytes, ptr); const rc = Module._tg3w_parse(ptr, bytes.length); Module._free(ptr); if (rc !== 0) { const first = Module.UTF8ToString(Module._tg3w_error_message(0)); const last = Module.UTF8ToString(Module._tg3w_last_error()); setStatus(`Parse failed (rc=${rc}): ${first || last}`); return; } const warn = Module._tg3w_error_count(); await buildScene(); if (warn > 0) { setStatus(`Loaded with ${warn} warning(s) — see console`); for (let i = 0; i < warn; i++) { console.warn(`[tg3] ${Module.UTF8ToString(Module._tg3w_error_message(i))}`); } } } async function handleFile(file) { if (!file) return; const buf = await file.arrayBuffer(); await loadModel(new Uint8Array(buf)); } /* ---------------------------------------------------------------- */ /* Setup */ /* ---------------------------------------------------------------- */ async function init() { renderer = new THREE.WebGLRenderer({ antialias: true }); renderer.setPixelRatio(window.devicePixelRatio); renderer.setSize(viewerEl.clientWidth, viewerEl.clientHeight); viewerEl.appendChild(renderer.domElement); camera = new THREE.PerspectiveCamera(50, viewerEl.clientWidth / viewerEl.clientHeight, 0.01, 10000); controls = new OrbitControls(camera, renderer.domElement); window.addEventListener('resize', () => { const w = viewerEl.clientWidth; const h = viewerEl.clientHeight; camera.aspect = w / h; camera.updateProjectionMatrix(); renderer.setSize(w, h); }); renderer.setAnimationLoop(() => { if (scene) renderer.render(scene, camera); }); try { Module = await createTinyGLTF(); } catch (e) { setStatus(`Failed to load WASM: ${e}`); return; } setStatus('WASM ready — drop a .glb/.gltf file'); fileInput.addEventListener('change', () => handleFile(fileInput.files[0])); sampleBtn.addEventListener('click', async () => { try { const res = await fetch('Cube.glb'); if (!res.ok) throw new Error(`HTTP ${res.status}`); await loadModel(new Uint8Array(await res.arrayBuffer())); } catch (e) { setStatus(`Sample load failed: ${e}`); } }); sampleBtn.hidden = false; for (const ev of ['dragenter', 'dragover']) { dropZone.addEventListener(ev, (e) => { e.preventDefault(); dropZone.classList.add('dragover'); }); } for (const ev of ['dragleave', 'drop']) { dropZone.addEventListener(ev, (e) => { e.preventDefault(); dropZone.classList.remove('dragover'); }); } dropZone.addEventListener('drop', (e) => { const f = e.dataTransfer.files && e.dataTransfer.files[0]; if (f) handleFile(f); }); } init();