> For the complete documentation index, see [llms.txt](https://408550179s-organization.gitbook.io/blog/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://408550179s-organization.gitbook.io/blog/tu-pian-sheng-cheng-3d-mo-xing-de-jie-kou-jin-du-he-three-yu-lan.md).

# 图片生成3D模型的接口进度和Three预览

图片生成 3D 模型这类应用，前端不只是上传文件然后等结果。体验上至少要处理这些事：

* 多图片上传
* SVG 转 PNG
* 调用远端 Gradio 模型接口
* 读取队列和生成进度
* 获取 GLB 地址
* Three.js 加载预览
* 模型居中、缩放、灯光和材质切换

## API 流程

以 ReconViaGen 这类 Gradio 服务为例，流程大概是：

```
upload images
      ↓
/preprocess_images
      ↓
/generate_and_extract_glb
      ↓
GLB / preview url
      ↓
Three.js preview
```

可以先定义统一的参数和默认配置：

```ts
export interface GenerateSettings {
  seed: number;
  ssGuidanceStrength: number;
  ssSamplingSteps: number;
  slatGuidanceStrength: number;
  slatSamplingSteps: number;
  multiimageAlgo: "multidiffusion" | "stochastic";
  meshSimplify: number;
  textureSize: 512 | 1024 | 2048;
}

export const DEFAULT_GENERATE_SETTINGS: GenerateSettings = {
  seed: -1,
  ssGuidanceStrength: 7.5,
  ssSamplingSteps: 30,
  slatGuidanceStrength: 3,
  slatSamplingSteps: 12,
  multiimageAlgo: "multidiffusion",
  meshSimplify: 0.95,
  textureSize: 1024,
};
```

## 处理 Gradio 跨域凭证

调用 HuggingFace Space 或 Gradio Live 地址时，有些场景不希望浏览器带 cookie，可以统一 patch fetch：

```ts
(function patchFetch() {
  if (typeof window === "undefined") return;

  const prev = window.fetch as typeof fetch & { __gradio_patched?: boolean };
  if (prev.__gradio_patched) return;

  const orig = window.fetch.bind(window);

  const next = function (input: RequestInfo | URL, init?: RequestInit) {
    const url =
      typeof input === "string"
        ? input
        : input instanceof URL
          ? input.toString()
          : (input as Request).url;

    if (url.includes(".hf.space") || url.includes(".gradio.live")) {
      return orig(input, { ...init, credentials: "omit" });
    }

    return orig(input, init);
  } as typeof fetch & { __gradio_patched?: boolean };

  next.__gradio_patched = true;
  window.fetch = next;
})();
```

## SSE 进度解析

Gradio 队列常通过 SSE 返回状态，可以写一个通用解析方法：

```ts
function decodeSseEvents(text: string) {
  const chunks = text
    .split(/\n\s*\n/)
    .map((chunk) => chunk.trim())
    .filter(Boolean);

  return chunks.map((chunk) => {
    const lines = chunk.split("\n");
    const eventLine = lines.find((line) => line.startsWith("event:"));
    const dataLines = lines
      .filter((line) => line.startsWith("data:"))
      .map((line) => line.slice(5).trim())
      .join("\n");

    return {
      event: eventLine ? eventLine.slice(6).trim() : "message",
      data: dataLines ? JSON.parse(dataLines) : null,
    };
  });
}
```

如果响应体是 stream，就边读边解析：

```ts
async function collectSseEvents(response: Response, onEvent: (event: any) => void) {
  if (!response.body) {
    const events = decodeSseEvents(await response.text());
    events.forEach(onEvent);
    return events;
  }

  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = "";

  while (true) {
    const { done, value } = await reader.read();
    buffer += decoder.decode(value ?? new Uint8Array(), { stream: !done });

    const chunks = buffer.split(/\n\s*\n/);
    buffer = chunks.pop() ?? "";

    for (const chunk of chunks) {
      decodeSseEvents(chunk).forEach(onEvent);
    }

    if (done) break;
  }
}
```

## SVG 上传前转 PNG

有些模型服务不一定支持 SVG，可以在前端先光栅化：

```ts
function svgToPng(svgFile: File, size = 1024): Promise<File> {
  return new Promise((resolve, reject) => {
    const url = URL.createObjectURL(svgFile);
    const img = new Image();

    img.onload = () => {
      const canvas = document.createElement("canvas");
      canvas.width = img.naturalWidth || size;
      canvas.height = img.naturalHeight || size;

      const ctx = canvas.getContext("2d");
      if (!ctx) return reject(new Error("No 2d context"));

      ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
      URL.revokeObjectURL(url);

      canvas.toBlob((blob) => {
        if (!blob) return reject(new Error("Canvas toBlob failed"));
        resolve(new File([blob], svgFile.name.replace(/\.svg$/i, ".png"), {
          type: "image/png",
        }));
      }, "image/png");
    };

    img.onerror = () => reject(new Error("SVG load failed"));
    img.src = url;
  });
}
```

## Three.js 加载 GLB 并居中

GLB 返回后，用 `GLTFLoader` 加载：

```ts
function loadModel(url: string): Promise<void> {
  return new Promise((resolve, reject) => {
    const loader = new GLTFLoader();

    loader.load(
      url,
      (gltf) => {
        removeLoadedModels();

        const model = gltf.scene;
        model.userData.isLoadedModel = true;

        const box = new THREE.Box3().setFromObject(model);
        const center = box.getCenter(new THREE.Vector3());
        const size = box.getSize(new THREE.Vector3());
        const maxDim = Math.max(size.x, size.y, size.z);

        model.position.sub(center);
        scene.add(model);

        const dist = maxDim * 2.5;
        camera.position.set(dist, dist * 0.7, dist);
        controls.target.set(0, 0, 0);
        controls.update();

        resolve();
      },
      undefined,
      reject,
    );
  });
}
```

## 切换预览风格

生成的模型有时材质不稳定，可以提供几个预览模式：

```ts
const styleConfig = {
  normal: { background: 0x111113, ambient: 0xffffff, key: 0xffffff },
  clay: { background: 0x16181d, ambient: 0xf7efe4, key: 0xffffff, clay: 0xc9c1b6 },
  blue: { background: 0x0f172a, ambient: 0xdbeafe, key: 0x93c5fd },
};

function applyClayMaterial(mesh: THREE.Mesh) {
  mesh.material = new THREE.MeshStandardMaterial({
    color: 0xc9c1b6,
    roughness: 0.82,
    metalness: 0.02,
  });
}
```

## 总结

图片转 3D 的前端重点不只是“调接口”，而是要把整个异步链路做稳定：

* 上传前处理图片格式
* 检查模型服务是否可用
* SSE 进度转成用户能理解的步骤
* GLB 加载后自动居中和适配相机
* 模型预览要支持材质兜底

这样即使模型服务排队很久，用户也能知道当前进行到哪一步。
