coderofpears's picture
Restore static module entrypoint
a39687f verified
Raw
History Blame Contribute Delete
26 kB
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFExporter } from 'three/addons/exporters/GLTFExporter.js';
import { pipeline } from 'https://cdn.jsdelivr.net/npm/@huggingface/transformers@3.8.1/+esm';
import * as ort from 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.22.0/webgpu/+esm';
const MODEL_ROOT = 'https://huggingface.co/needle-tools/SF3D-webgpu/resolve/main/';
const SAMPLE_URL = 'https://huggingface.co/spaces/stabilityai/stable-fast-3d/resolve/main/demo_files/examples/axe.png';
const INPUT_SIZE = 512;
const GRID_RESOLUTION = 160;
const ISO_THRESHOLD = 10;
const TRIPLANE_CHANNELS = 40;
const TRIPLANE_SIZE = 384;
const DECODE_BATCH = 16384;
const BACKGROUND_COLOR = 0.5;
const el = (id) => document.getElementById(id);
const ui = {
file: el('image-file'),
dropzone: el('dropzone'),
previewWrap: el('preview-wrap'),
preview: el('input-preview'),
previewName: el('preview-name'),
previewSize: el('preview-size'),
sample: el('sample-button'),
removeBackground: el('remove-background'),
generate: el('generate-button'),
generateLabel: el('generate-label'),
webgpuDot: el('webgpu-dot'),
webgpuLabel: el('webgpu-label'),
stage: el('viewer-stage'),
canvas: el('viewer-canvas'),
empty: el('viewer-empty'),
progress: el('viewer-progress'),
progressPercent: el('progress-percent'),
progressTitle: el('progress-title'),
progressDetail: el('progress-detail'),
error: el('viewer-error'),
errorTitle: el('error-title'),
errorDetail: el('error-detail'),
errorDismiss: el('error-dismiss'),
reset: el('reset-view'),
stats: el('mesh-stats'),
runtime: el('runtime-metric'),
vertices: el('vertices-metric'),
faces: el('faces-metric'),
download: el('download-button'),
};
let selectedFile = null;
let selectedObjectUrl = null;
let preparedCanvas = null;
let backgroundPipeline = null;
let sessions = null;
let currentMeshObject = null;
let currentMeshData = null;
let webgpuReady = false;
function setWebGPUStatus(kind, label) {
ui.webgpuDot.className = `status-dot ${kind}`;
ui.webgpuLabel.textContent = label;
}
function setProgress(title, detail, percent = null) {
ui.progressTitle.textContent = title;
ui.progressDetail.textContent = detail;
if (percent === null) {
ui.progressPercent.textContent = '…';
} else {
ui.progressPercent.textContent = `${Math.round(percent)}%`;
}
}
function showProgress(visible) {
ui.progress.classList.toggle('is-hidden', !visible);
ui.empty.classList.toggle('is-hidden', visible || Boolean(currentMeshObject));
}
function showError(title, detail) {
ui.errorTitle.textContent = title;
ui.errorDetail.textContent = detail;
ui.error.classList.remove('is-hidden');
showProgress(false);
}
function clearError() {
ui.error.classList.add('is-hidden');
}
function formatBytes(bytes) {
if (!Number.isFinite(bytes) || bytes <= 0) return '—';
const units = ['B', 'KB', 'MB', 'GB'];
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
return `${(bytes / 1024 ** index).toFixed(index ? 1 : 0)} ${units[index]}`;
}
function formatDuration(ms) {
return ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(1)} s`;
}
function isTransparentCanvas(canvas) {
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height);
for (let i = 3; i < data.length; i += 4) {
if (data[i] < 250) return true;
}
return false;
}
function loadImageFromBlob(blob) {
return new Promise((resolve, reject) => {
const url = URL.createObjectURL(blob);
const image = new Image();
image.onload = () => {
URL.revokeObjectURL(url);
resolve(image);
};
image.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('The selected file could not be decoded as an image.'));
};
image.src = url;
});
}
function imageToCanvas(image, maxSize = 1200) {
const scale = Math.min(1, maxSize / Math.max(image.naturalWidth || image.width, image.naturalHeight || image.height));
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round((image.naturalWidth || image.width) * scale));
canvas.height = Math.max(1, Math.round((image.naturalHeight || image.height) * scale));
const ctx = canvas.getContext('2d', { willReadFrequently: true });
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(image, 0, 0, canvas.width, canvas.height);
return canvas;
}
function rawImageToCanvas(rawImage) {
const canvas = document.createElement('canvas');
canvas.width = rawImage.width;
canvas.height = rawImage.height;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const rgba = new Uint8ClampedArray(canvas.width * canvas.height * 4);
const source = rawImage.data;
const channels = rawImage.channels || 4;
for (let i = 0, j = 0; i < source.length; i += channels, j += 4) {
rgba[j] = source[i];
rgba[j + 1] = source[i + 1] ?? source[i];
rgba[j + 2] = source[i + 2] ?? source[i];
rgba[j + 3] = channels >= 4 ? source[i + 3] : 255;
}
ctx.putImageData(new ImageData(rgba, canvas.width, canvas.height), 0, 0);
return canvas;
}
async function getBackgroundPipeline() {
if (!backgroundPipeline) {
setProgress('Loading cleanup model', 'Transformers.js is downloading MODNet for this browser.', null);
backgroundPipeline = await pipeline('background-removal', 'Xenova/modnet', {
device: 'webgpu',
dtype: 'fp16',
});
}
return backgroundPipeline;
}
async function prepareSourceCanvas(file) {
const image = await loadImageFromBlob(file);
const original = imageToCanvas(image);
if (!ui.removeBackground.checked || isTransparentCanvas(original)) {
return original;
}
let blobUrl = null;
try {
const remover = await getBackgroundPipeline();
blobUrl = URL.createObjectURL(file);
const output = await remover(blobUrl);
const first = Array.isArray(output) ? output[0] : output;
if (!first?.data || !first.width || !first.height) {
throw new Error('The cleanup model returned an empty image.');
}
return rawImageToCanvas(first);
} catch (error) {
console.warn('Background cleanup skipped:', error);
setProgress('Using original image', 'Background cleanup was skipped; continuing with the uploaded pixels.', 5);
return original;
} finally {
if (blobUrl) URL.revokeObjectURL(blobUrl);
}
}
function alphaBounds(canvas) {
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const { data, width, height } = ctx.getImageData(0, 0, canvas.width, canvas.height);
let minX = width;
let minY = height;
let maxX = -1;
let maxY = -1;
for (let y = 0; y < height; y += 1) {
for (let x = 0; x < width; x += 1) {
if (data[(y * width + x) * 4 + 3] > 8) {
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
}
}
}
if (maxX < 0) return { x: 0, y: 0, width, height };
return { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 };
}
function prepareModelCanvas(source, ratio = 0.85) {
const bounds = alphaBounds(source);
const side = Math.max(bounds.width, bounds.height) / ratio;
const centerX = bounds.x + bounds.width / 2;
const centerY = bounds.y + bounds.height / 2;
const cropX = centerX - side / 2;
const cropY = centerY - side / 2;
const canvas = document.createElement('canvas');
canvas.width = INPUT_SIZE;
canvas.height = INPUT_SIZE;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
ctx.clearRect(0, 0, INPUT_SIZE, INPUT_SIZE);
ctx.imageSmoothingEnabled = true;
ctx.imageSmoothingQuality = 'high';
ctx.drawImage(source, cropX, cropY, side, side, 0, 0, INPUT_SIZE, INPUT_SIZE);
return canvas;
}
function renderInputPreview(canvas, name, bytes) {
if (selectedObjectUrl) URL.revokeObjectURL(selectedObjectUrl);
canvas.toBlob((blob) => {
if (!blob) return;
selectedObjectUrl = URL.createObjectURL(blob);
ui.preview.src = selectedObjectUrl;
}, 'image/png');
ui.previewName.textContent = name;
ui.previewSize.textContent = formatBytes(bytes);
ui.previewWrap.classList.remove('is-hidden');
}
function selectFile(file) {
if (!file || !file.type.startsWith('image/')) {
showError('Unsupported file', 'Choose a PNG, JPEG, or WebP image.');
return;
}
selectedFile = file;
clearError();
preparedCanvas = null;
ui.generate.disabled = !webgpuReady;
ui.generateLabel.textContent = webgpuReady ? 'Generate 3D mesh' : 'WebGPU required';
ui.previewWrap.classList.add('is-hidden');
const reader = new FileReader();
reader.onload = () => {
ui.preview.src = reader.result;
ui.previewName.textContent = file.name;
ui.previewSize.textContent = formatBytes(file.size);
ui.previewWrap.classList.remove('is-hidden');
};
reader.readAsDataURL(file);
}
async function loadSample() {
ui.sample.disabled = true;
ui.sample.querySelector('span').textContent = 'Loading sample…';
try {
const response = await fetch(SAMPLE_URL);
if (!response.ok) throw new Error(`Sample request failed (${response.status}).`);
const blob = await response.blob();
selectFile(new File([blob], 'axe.png', { type: blob.type || 'image/png' }));
} catch (error) {
showError('Sample unavailable', error.message);
} finally {
ui.sample.disabled = false;
ui.sample.querySelector('span').textContent = 'Try a sample';
}
}
function urlFor(path) {
return `${MODEL_ROOT}${path}?download=true`;
}
function resolveName(names, preferred, index = 0) {
return preferred.find((candidate) => names.includes(candidate)) || names[index];
}
function resolveOutput(outputs, preferred, index = 0) {
for (const name of preferred) {
if (outputs[name]) return outputs[name];
}
const first = Object.keys(outputs)[index];
if (!first) throw new Error('The ONNX graph returned no output tensor.');
return outputs[first];
}
async function createSessions() {
if (sessions) return sessions;
if (!webgpuReady) throw new Error('WebGPU is not available in this browser.');
if (ort.env?.wasm) {
ort.env.wasm.wasmPaths = 'https://cdn.jsdelivr.net/npm/onnxruntime-web@1.22.0/dist/';
ort.env.wasm.numThreads = 1;
}
const options = {
executionProviders: ['webgpu'],
graphOptimizationLevel: 'all',
enableMemPattern: true,
};
setProgress('Loading SF3D graphs', 'Downloading the tokenizer, backbone, and decoder. They will be cached after this run.', null);
const [imageTokenizer, backbone, decoder] = await Promise.all([
ort.InferenceSession.create(urlFor('onnx/image_tokenizer_single.onnx'), options),
ort.InferenceSession.create(urlFor('onnx/backbone_fp16.onnx'), options),
ort.InferenceSession.create(urlFor('onnx/decoder_single.onnx'), options),
]);
sessions = { imageTokenizer, backbone, decoder };
return sessions;
}
async function fetchBinary(path, label, progressOffset, progressScale) {
const response = await fetch(urlFor(path));
if (!response.ok) throw new Error(`Could not load ${label} (${response.status}).`);
const total = Number(response.headers.get('content-length')) || 0;
const reader = response.body?.getReader();
if (!reader) return response.arrayBuffer();
const chunks = [];
let loaded = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
loaded += value.byteLength;
if (total) setProgress('Loading mesh grid', `${label} · ${formatBytes(loaded)} / ${formatBytes(total)}`, progressOffset + (loaded / total) * progressScale);
}
const buffer = new ArrayBuffer(loaded);
const output = new Uint8Array(buffer);
let offset = 0;
for (const chunk of chunks) {
output.set(chunk, offset);
offset += chunk.byteLength;
}
return buffer;
}
async function loadGrid() {
setProgress('Loading mesh grid', 'Fetching the tetrahedral surface grid.', 68);
const [verticesBuffer, indicesBuffer] = await Promise.all([
fetchBinary('tets_vertices.bin', 'vertices', 68, 7),
fetchBinary('tets_indices.bin', 'tetrahedra', 75, 7),
]);
return {
vertices: new Float32Array(verticesBuffer),
indices: new Uint32Array(indicesBuffer),
};
}
function createInputTensors(canvas, imageTokenizer) {
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const { data } = ctx.getImageData(0, 0, INPUT_SIZE, INPUT_SIZE);
const rgb = new Float32Array(INPUT_SIZE * INPUT_SIZE * 3);
for (let i = 0, j = 0; i < data.length; i += 4, j += 3) {
const alpha = data[i + 3] / 255;
rgb[j] = BACKGROUND_COLOR * (1 - alpha) + (data[i] / 255) * alpha;
rgb[j + 1] = BACKGROUND_COLOR * (1 - alpha) + (data[i + 1] / 255) * alpha;
rgb[j + 2] = BACKGROUND_COLOR * (1 - alpha) + (data[i + 2] / 255) * alpha;
}
const inputNames = imageTokenizer.inputNames;
const rgbName = resolveName(inputNames, ['rgb'], 0);
const c2wName = resolveName(inputNames, ['c2w'], 1);
const intrinsicName = resolveName(inputNames, ['intrinsic_normed'], 2);
const focal = 1 / (2 * Math.tan((40 * Math.PI) / 360));
return {
[rgbName]: new ort.Tensor('float32', rgb, [1, INPUT_SIZE, INPUT_SIZE, 3]),
[c2wName]: new ort.Tensor('float32', new Float32Array([
0, 0, 1, 1.6,
1, 0, 0, 0,
0, 1, 0, 0,
0, 0, 0, 1,
]), [1, 4, 4]),
[intrinsicName]: new ort.Tensor('float32', new Float32Array([
focal, 0, 0.5,
0, focal, 0.5,
0, 0, 1,
]), [1, 3, 3]),
};
}
async function decodeGrid(grid, triplane, decoder) {
const vertexCount = grid.vertices.length / 3;
const density = new Float32Array(vertexCount);
const deformation = new Float32Array(vertexCount * 3);
const triplaneTensor = new ort.Tensor('float32', triplane, [1, 3, TRIPLANE_CHANNELS, TRIPLANE_SIZE, TRIPLANE_SIZE]);
const positions = new Float32Array(DECODE_BATCH * 3);
const inputNames = decoder.inputNames;
const triplaneName = resolveName(inputNames, ['triplane'], 0);
const positionsName = resolveName(inputNames, ['positions'], 1);
const densityName = 'density';
const offsetName = 'vertex_offset';
for (let start = 0; start < vertexCount; start += DECODE_BATCH) {
const count = Math.min(DECODE_BATCH, vertexCount - start);
const currentPositions = positions.subarray(0, count * 3);
for (let i = 0; i < count; i += 1) {
const base = (start + i) * 3;
currentPositions[i * 3] = grid.vertices[base] * 2 - 1;
currentPositions[i * 3 + 1] = grid.vertices[base + 1] * 2 - 1;
currentPositions[i * 3 + 2] = grid.vertices[base + 2] * 2 - 1;
}
const outputs = await decoder.run({
[triplaneName]: triplaneTensor,
[positionsName]: new ort.Tensor('float32', currentPositions, [1, count, 3]),
});
const densityTensor = resolveOutput(outputs, [densityName]);
const offsetTensor = resolveOutput(outputs, [offsetName]);
for (let i = 0; i < count; i += 1) {
density[start + i] = densityTensor.data[i];
deformation[(start + i) * 3] = offsetTensor.data[i * 3];
deformation[(start + i) * 3 + 1] = offsetTensor.data[i * 3 + 1];
deformation[(start + i) * 3 + 2] = offsetTensor.data[i * 3 + 2];
}
const percent = 82 + ((start + count) / vertexCount) * 15;
setProgress('Decoding surface', `${(start + count).toLocaleString()} / ${vertexCount.toLocaleString()} grid points`, percent);
}
return { density, deformation };
}
const TRIANGLE_TABLE = [
[-1, -1, -1, -1, -1, -1], [1, 0, 2, -1, -1, -1], [4, 0, 3, -1, -1, -1], [1, 4, 2, 1, 3, 4],
[3, 1, 5, -1, -1, -1], [2, 3, 0, 2, 5, 3], [1, 4, 0, 1, 5, 4], [4, 2, 5, -1, -1, -1],
[4, 5, 2, -1, -1, -1], [4, 1, 0, 4, 5, 1], [3, 2, 0, 3, 5, 2], [1, 3, 5, -1, -1, -1],
[4, 1, 2, 4, 3, 1], [3, 0, 4, -1, -1, -1], [2, 0, 1, -1, -1, -1], [-1, -1, -1, -1, -1, -1],
];
function buildMesh(grid, decoded) {
const vertexCount = grid.vertices.length / 3;
const tetCount = grid.indices.length / 4;
const occupied = new Uint8Array(vertexCount);
for (let i = 0; i < vertexCount; i += 1) occupied[i] = decoded.density[i] > ISO_THRESHOLD ? 1 : 0;
const positions = [];
const faces = [];
const edgeMap = new Map();
const deformed = (index, axis) => {
const value = grid.vertices[index * 3 + axis] + (2 / GRID_RESOLUTION) * Math.tanh(decoded.deformation[index * 3 + axis]);
return value * 2 - 1;
};
const edgeVertex = (a, b) => {
if (occupied[a] === occupied[b]) return -1;
const low = Math.min(a, b);
const high = Math.max(a, b);
const key = low * vertexCount + high;
const existing = edgeMap.get(key);
if (existing !== undefined) return existing;
const s0 = decoded.density[a] - ISO_THRESHOLD;
const s1 = decoded.density[b] - ISO_THRESHOLD;
const denominator = s0 - s1 || 1e-6;
const t = Math.max(0, Math.min(1, s0 / denominator));
const x = deformed(a, 0) * (1 - t) + deformed(b, 0) * t;
const y = deformed(a, 1) * (1 - t) + deformed(b, 1) * t;
const z = deformed(a, 2) * (1 - t) + deformed(b, 2) * t;
const index = positions.length / 3;
positions.push(x, y, z);
edgeMap.set(key, index);
return index;
};
for (let tet = 0; tet < tetCount; tet += 1) {
const offset = tet * 4;
const c0 = grid.indices[offset];
const c1 = grid.indices[offset + 1];
const c2 = grid.indices[offset + 2];
const c3 = grid.indices[offset + 3];
const mask = occupied[c0] | (occupied[c1] << 1) | (occupied[c2] << 2) | (occupied[c3] << 3);
if (mask === 0 || mask === 15) continue;
const edgeIndices = new Int32Array(6);
edgeIndices[0] = edgeVertex(c0, c1);
edgeIndices[1] = edgeVertex(c0, c2);
edgeIndices[2] = edgeVertex(c0, c3);
edgeIndices[3] = edgeVertex(c1, c2);
edgeIndices[4] = edgeVertex(c1, c3);
edgeIndices[5] = edgeVertex(c2, c3);
const table = TRIANGLE_TABLE[mask];
for (let i = 0; i < 6 && table[i] !== -1; i += 3) {
faces.push(edgeIndices[table[i]], edgeIndices[table[i + 1]], edgeIndices[table[i + 2]]);
}
}
if (!faces.length) throw new Error('No surface was found. Try a clearer image with one centered object.');
return { positions: new Float32Array(positions), faces: new Uint32Array(faces) };
}
function initViewer() {
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(35, 1, 0.01, 100);
camera.position.set(0, 0.15, 3.4);
const renderer = new THREE.WebGLRenderer({ canvas: ui.canvas, antialias: true, alpha: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.15;
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.07;
controls.minDistance = 1.2;
controls.maxDistance = 7;
controls.target.set(0, 0, 0);
scene.add(new THREE.HemisphereLight(0xdcd7ff, 0x1f2330, 2.1));
const keyLight = new THREE.DirectionalLight(0xffffff, 3.5);
keyLight.position.set(3, 4, 4);
scene.add(keyLight);
const rimLight = new THREE.DirectionalLight(0xc7baff, 2.2);
rimLight.position.set(-4, 2, -3);
scene.add(rimLight);
const resize = () => {
const width = ui.stage.clientWidth;
const height = ui.stage.clientHeight;
renderer.setSize(width, height, false);
camera.aspect = width / Math.max(height, 1);
camera.updateProjectionMatrix();
};
const reset = () => {
camera.position.set(0, 0.15, 3.4);
controls.target.set(0, 0, 0);
controls.update();
};
window.addEventListener('resize', resize);
ui.reset.addEventListener('click', reset);
resize();
const animate = () => {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
};
animate();
return {
scene,
camera,
reset,
setMesh(meshData) {
if (currentMeshObject) {
scene.remove(currentMeshObject);
currentMeshObject.geometry.dispose();
currentMeshObject.material.dispose();
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(meshData.positions, 3));
geometry.setIndex(new THREE.BufferAttribute(meshData.faces, 1));
geometry.computeVertexNormals();
geometry.computeBoundingBox();
const center = geometry.boundingBox.getCenter(new THREE.Vector3());
const size = geometry.boundingBox.getSize(new THREE.Vector3());
const maxDimension = Math.max(size.x, size.y, size.z) || 1;
geometry.translate(-center.x, -center.y, -center.z);
geometry.scale(2.15 / maxDimension, 2.15 / maxDimension, 2.15 / maxDimension);
const material = new THREE.MeshStandardMaterial({ color: 0xc9b9ff, roughness: 0.44, metalness: 0.14, side: THREE.DoubleSide });
currentMeshObject = new THREE.Mesh(geometry, material);
scene.add(currentMeshObject);
reset();
ui.empty.classList.add('is-hidden');
ui.stats.textContent = `${meshData.faces.length / 3} triangles · drag to orbit`;
ui.download.disabled = false;
},
};
}
const viewer = initViewer();
async function runGeneration() {
if (!selectedFile || !webgpuReady) return;
const started = performance.now();
ui.generate.disabled = true;
ui.download.disabled = true;
clearError();
showProgress(true);
try {
setProgress('Preparing image', 'Composing the subject over SF3D’s neutral background.', 5);
const sourceCanvas = await prepareSourceCanvas(selectedFile);
preparedCanvas = prepareModelCanvas(sourceCanvas);
renderInputPreview(preparedCanvas, `${selectedFile.name} · prepared`, selectedFile.size);
setProgress('Loading models', 'Connecting to the browser-compatible SF3D export.', 15);
const [modelSessions, grid] = await Promise.all([createSessions(), loadGrid()]);
setProgress('Encoding image', 'The image tokenizer is running on WebGPU.', 78);
const tokenizerInputs = createInputTensors(preparedCanvas, modelSessions.imageTokenizer);
const tokenizerOutputs = await modelSessions.imageTokenizer.run(tokenizerInputs);
const imageTokens = resolveOutput(tokenizerOutputs, ['image_tokens']);
setProgress('Reconstructing shape', 'The SF3D backbone is predicting a 3D triplane.', 80);
const backboneInputName = resolveName(modelSessions.backbone.inputNames, ['image_tokens'], 0);
const backboneOutputs = await modelSessions.backbone.run({ [backboneInputName]: imageTokens });
const triplane = resolveOutput(backboneOutputs, ['triplane']).data;
setProgress('Decoding surface', 'Sampling the triplane and extracting the tetrahedral surface.', 82);
const decoded = await decodeGrid(grid, triplane, modelSessions.decoder);
const meshData = buildMesh(grid, decoded);
currentMeshData = meshData;
viewer.setMesh(meshData);
const elapsed = performance.now() - started;
ui.runtime.textContent = formatDuration(elapsed);
ui.vertices.textContent = (meshData.positions.length / 3).toLocaleString();
ui.faces.textContent = (meshData.faces.length / 3).toLocaleString();
setProgress('Done', 'Your mesh is ready.', 100);
showProgress(false);
} catch (error) {
console.error(error);
showError('Generation failed', error?.message || 'The browser could not complete WebGPU inference.');
ui.stats.textContent = 'No mesh loaded';
} finally {
ui.generate.disabled = false;
ui.generateLabel.textContent = 'Generate 3D mesh';
}
}
async function downloadGLB() {
if (!currentMeshObject) return;
ui.download.disabled = true;
try {
const exporter = new GLTFExporter();
const result = await new Promise((resolve, reject) => exporter.parse(currentMeshObject, resolve, reject, { binary: true }));
const blob = new Blob([result], { type: 'model/gltf-binary' });
const url = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = 'stable-fast-3d-mesh.glb';
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (error) {
showError('Download failed', error.message || 'The GLB exporter could not finish.');
} finally {
ui.download.disabled = false;
}
}
async function checkWebGPU() {
if (!navigator.gpu) {
setWebGPUStatus('error', 'WebGPU unavailable');
ui.generateLabel.textContent = 'WebGPU required';
return;
}
try {
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) throw new Error('No compatible GPU adapter found.');
webgpuReady = true;
setWebGPUStatus('ready', 'WebGPU ready');
ui.generate.disabled = !selectedFile;
ui.generateLabel.textContent = selectedFile ? 'Generate 3D mesh' : 'Select an image';
} catch (error) {
setWebGPUStatus('error', 'WebGPU unavailable');
showError('WebGPU is required', error.message || 'Use a recent Chrome or Edge browser with WebGPU enabled.');
}
}
ui.file.addEventListener('change', (event) => selectFile(event.target.files?.[0]));
ui.sample.addEventListener('click', loadSample);
ui.generate.addEventListener('click', runGeneration);
ui.download.addEventListener('click', downloadGLB);
ui.errorDismiss.addEventListener('click', clearError);
ui.dropzone.addEventListener('dragover', (event) => { event.preventDefault(); ui.dropzone.classList.add('dragging'); });
ui.dropzone.addEventListener('dragleave', () => ui.dropzone.classList.remove('dragging'));
ui.dropzone.addEventListener('drop', (event) => {
event.preventDefault();
ui.dropzone.classList.remove('dragging');
selectFile(event.dataTransfer.files?.[0]);
});
window.addEventListener('pagehide', () => {
if (backgroundPipeline?.dispose) backgroundPipeline.dispose();
if (sessions) Object.values(sessions).forEach((session) => session.release?.());
});
checkWebGPU();