用 Vite + Electron + React + Python 重造 3D 服装打版软件

从论文原型到桌面应用:用 Vite + Electron + React + Python 重造 3D 服装打版软件

把一个"只能跑脚本"的科研原型 Costumy,改造成点几下鼠标就能给 3D 模特穿衣服的桌面应用。前端只管预览和调参,脏活累活全交给 Python。本文记录完整实现过程,以及三个让我排查到怀疑人生的坑。

前言

Costumy 是 CDRIN 开源的 3D 服装原型工具,打通了 2D 纸样 → 3D 服装 的完整链路:

  1. freesewing.org(一个开源的参数化打版库)根据人体尺寸生成 2D 纸样
  2. 把纸样三角化成网格,标注缝合边
  3. 用 Blender 的布料物理引擎把纸样"披"到人体模型上,缝合、垂坠、导出 OBJ

但它的使用方式非常"极客":写 Python 脚本、调 Blender API、等几分钟烘焙、再用另一个静态 HTML 文件加载 OBJ 看效果。改一个参数?重来一遍。

于是我把它重构成了一个桌面应用 Costumy Studio

  • Vite + Electron + React:参数面板、3D 预览、2D 纸样预览,全部图形化
  • Python 后端:打版计算、三角化、Blender 布料模拟,通过 HTTP API 把数据传给前端

最终效果:左边拖拖滑块,右边 1 秒出 2D 纸样;点一下「布料模拟」,25 秒后红色背心就穿在了模特身上。

先上整体效果(模拟完成后的 3D 视口):

css 复制代码
┌────────────────────────────────────────────────────┐
│  Costumy Studio          [自动预览] [后端在线]      │
│  ┌──────────┐  ┌──────────────────────────────┐    │
│  │ 款式预设  │  │                              │    │
│  │ 人体模板  │  │      🧍 灰色人体模型          │    │
│  │ 测量数据  │  │      👕 红色背心(布料模拟)  │    │
│  │ 设计参数  │  │                              │    │
│  │ 模拟参数  │  │   拖拽旋转 · 滚轮缩放         │    │
│  └──────────┘  └──────────────────────────────┘    │
│  [done] 完成:5265 个顶点 · 任务 ebb5d153(sport)  │
└────────────────────────────────────────────────────┘

一、技术选型:为什么是"前后端分离"而不是纯 JS

最省事的想法是把整个管线都用 JS 重写(freesewing 本身就是 JS 的)。但布料模拟这一环绕不开:

  • Blender 的布料物理引擎(缝合弹簧、自碰撞、网格碰撞体)是几十年积累的工业级实现
  • Python 的 bpy 模块可以直接把 Blender 当库用,不需要装 Blender 客户端
  • JS 生态里没有同等成熟度的布料求解器(自己写 XPBD 是另一个大坑,文末再聊)

所以定下了分工原则:前端做所有"快"的事,Python 做所有"重"的事

scss 复制代码
React 前端 (参数修改/预览)  ──HTTP──>  Python 后端 (复杂计算)
 ├─ three.js 3D 视口                  ├─ server.py     HTTP API(纯标准库)
 ├─ SVG 2D 纸样                       ├─ sim_worker.py bpy 布料模拟子进程
 ├─ 参数面板 (Element Plus 规范)      ├─ costumy/      原版包:打版/三角化/缝线映射
 └─ Electron 窗口                     └─ node/         freesewing + cubic2quad

技术栈清单:

技术 作用
前端框架 React 18 + Vite 5 参数面板、视图切换
3D 渲染 three.js (OBJLoader + OrbitControls) 人体 + 服装预览
桌面壳 Electron 33 窗口管理、拉起 Python 进程
后端服务 Python 标准库 http.server HTTP API,零新增依赖
布料模拟 bpy 5.0.1(Blender 模块) 物理烘焙
打版 freesewing (node) + costumy 参数化纸样生成
三角化 triangle (python binding) 纸样 → 网格

二、整体架构与数据流

2.1 两条核心链路

链路 A:快速纸样预览(约 1~3 秒)

bash 复制代码
前端拖滑块 → POST /api/pattern {measurements, options}
  → Python 调 node 跑 freesewing 生成 SVG
  → cubic2quad 曲线降阶(node 脚本)
  → costumy 清洗裁片、映射缝合边(front/back 4 条缝线)
  → 返回 { svg, spec.json, 统计 } → 前端渲染 2D 纸样

链路 B:布料模拟(约 25 秒)

bash 复制代码
前端点「布料模拟」→ POST /api/simulate → 返回 jobId
  → Python 启动【子进程】sim_worker.py:
      1. 打版(同链路 A)
      2. bpy 载入人体 OBJ,计算颈点/前后包围面 references
      3. 对齐前后裁片到人体
      4. triangle 三角化(每片约几千个三角形)
      5. Blender 布料模拟:缝合弹簧 + 碰撞体,烘焙 55 帧
      6. 导出 garment.obj
      7. 每步写 status.json 更新进度
前端每 1.2s 轮询 GET /api/jobs/<id> → 进度条
  → done 后拿到 garmentUrl → three.js 加载展示

2.2 项目结构

bash 复制代码
11/
├── electron/
│   ├── main.cjs              # 主进程:探测 Python、启动后端、开窗口
│   └── preload.cjs
├── src/
│   ├── App.jsx               # 状态中枢:轮询、自动预览、任务管理
│   ├── api.js                # HTTP 客户端
│   ├── components/
│   │   ├── ParamsPanel.jsx   # 参数面板(款式/人体/设计/模拟四组)
│   │   ├── Viewer3D.jsx      # three.js 3D 视口
│   │   ├── Pattern2D.jsx     # SVG 纸样(滚轮缩放/拖拽平移)
│   │   └── ui.jsx            # Element Plus 风格基础组件
│   └── styles.css            # Element Plus 设计规范 token
├── python/
│   ├── server.py             # HTTP API(stdlib,无第三方依赖)
│   ├── sim_worker.py         # 布料模拟工作进程
│   ├── costumy/              # 原版 costumy 包(含 node 脚本与依赖)
│   └── body_tpose.obj        # T-pose 人体碰撞体(16340 顶点)
└── workspace/jobs/<id>/      # 每次模拟的产物:params/status/garment.obj...

三、Python 后端实现

3.1 为什么用标准库写 HTTP 服务

后端需要的 numpy/svg.path/triangle/bpy 都装在一个现成的 venv 里,不想再往里面装 Flask/FastAPI 污染环境http.server.ThreadingHTTPServer 写个 JSON API 也就百来行,还能精准控制 CORS 和静态文件:

python 复制代码
class ApiHandler(BaseHTTPRequestHandler):
    def do_POST(self):
        path = urlparse(self.path).path
        payload = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0))))

        if path == "/api/pattern":            # 快速纸样(进程内计算)
            return self._json(generate_pattern(payload))
        if path == "/api/simulate":           # 布料模拟(子进程)
            return self._json({"jobId": start_sim_job(payload)})

路由表:

方法 路径 说明
GET /api/health 健康检查(前端每 5s 探活)
GET /api/config 默认测量值、10 个设计参数的 min/default/max、3 种风格预设、4 套人体模板
POST /api/pattern 生成 2D 纸样 SVG + 规格 JSON
POST /api/simulate 启动布料模拟任务
GET /api/jobs/<id> 轮询任务进度(含日志尾部、产物 URL)
GET /api/files/<path> 下载 workspace 里的 garment.obj 等产物

3.2 模拟必须放在子进程里

这是从原版 Costumy 继承的教训,有两个硬原因:

原因一:triangle 库会"静默崩溃"。 原版作者的应对方式堪称行为艺术------在子进程里跑三角化脚本,主进程递归重试直到子进程打印出 $$success$$(最多 40 次),因为 try/except 根本接不住 C 层面的崩溃:

python 复制代码
# costumy/classes/pattern.py 原版代码
def _make_mesh_for_sim(self, temp_pickle_path, temp_json_path, n_attemps=0):
    n_attemps += 1
    if n_attemps >= 40:
        raise RecursionError("Failed mesh conversion too many times")
    # ...起子进程,读到 "$$success$$" 才算完
    return self._make_mesh_for_sim(temp_pickle_path, temp_json_path, n_attemps)

原因二:bpy 不是线程安全的。 在 HTTP 服务的 worker 线程里跑 Blender 操作,指不定哪里就炸。

所以架构是:HTTP 服务主进程只接请求,/api/simulate spawn 一个独立 Python 子进程跑模拟 ,通过 status.json 文件汇报进度:

python 复制代码
proc = subprocess.Popen(
    [sys.executable, str(BASE_DIR / "sim_worker.py"), str(job_dir)],
    stdout=log_file, stderr=subprocess.STDOUT,
)

隔离之后,就算模拟进程崩了,HTTP 服务也毫发无伤,前端拿到的只是"任务失败 + 日志"。

3.3 布料模拟工作进程

sim_worker.py 完整复刻原版的物理参数和流程:

python 复制代码
# 1. 打版
design = Aaron(measurements)
pattern = design.new_pattern(options=options, tolerance=tolerance)

# 2. bpy 载入人体,归一化到 1.65m,计算对齐参考
bpy.ops.wm.obj_import(filepath=str(BODY_OBJ), up_axis="Y", forward_axis="NEGATIVE_Z")
# 颈根 ≈ 身高 85%;胸带区 |x|<0.18m 且 z∈[1.15,1.38],
# 用 2%/98% 百分位数抵抗头发/手臂等离群顶点,得到躯干前后包围面
references = {"neck": [0, 0, neck_z * 100], "bound_front": ..., "bound_back": ...}

# 3. 对齐裁片
pattern.align_panels(references)

# 4. 三角化 + 布料模拟(物理参数与原版一致)
garment = pattern.as_garment(collider=body, output_path=str(out_obj), bake=True, ...)

Blender 布料的关键物理参数(原版配方):

python 复制代码
cloth.settings.mass = 1.2                    # kg
cloth.settings.use_sewing_springs = True     # 缝合弹簧
cloth.settings.sewing_force_max = 38
cloth.settings.quality = 10                  # 模拟精度
cloth.collision_settings.collision_quality = 5
cloth.collision_settings.use_self_collision = True  # 自碰撞
cloth.settings.tension_stiffness = 20
cloth.settings.bending_stiffness = 0.5
# 烘焙 55 帧,再用 Weld 修改器把缝合顶点焊起来

四、React 前端实现

4.1 界面布局(Element Plus 设计规范)

javascript 复制代码
┌─────────────────────────────────────────────────┐
│ 顶栏:logo · 自动预览开关 · 后端状态徽章 · 两个按钮 │
├──────────┬──────────────────────────────────────┤
│ 左侧 336px│  页签:3D 预览 / 2D 纸样 / 规格 JSON  │
│ 参数面板  │                                      │
│ · 款式预设 │   three.js 视口 / SVG / JSON         │
│ · 人体模板 │                                      │
│ · 设计参数 │                                      │
│ · 模拟参数 │                                      │
├──────────┴──────────────────────────────────────┤
│ 状态栏:进度 · 任务信息 · 日志                     │
└─────────────────────────────────────────────────┘

样式上严格按 Element Plus 的 token 实现:主色 #409eff、成功 #67c23a、控件高 32px、圆角 4px、0.3s ease-in-out 过渡。因为是 React 项目,没有用 Element Plus 组件库本体,而是手写了一套同风格的基础组件(Button/Switch/Slider/Select/Collapse/Badge/Toast),总共不到 100 行。

4.2 3D 视口

three.js 场景配置:环境光 + 主光(带阴影)+ 轮廓光,网格地面 + ShadowMaterial 圆形阴影承接面。人体 OBJ 是米制直接加载;服装 OBJ 是厘米制,加载后 scale.setScalar(0.01) 与人体对齐------这是原版 preview.html 里的约定。

js 复制代码
const garmentMat = new THREE.MeshPhysicalMaterial({
  color: "#e05563", roughness: 0.85,
  sheen: 0.4, sheenColor: "#ffffff",   // 面料光泽
  side: THREE.DoubleSide,               // 布料必须双面渲染
});

视口左上角有悬浮工具卡:6 色面料色板、显示人体/线框模式/自动旋转三个开关------还原了原版的交互。

4.3 三个视图常驻挂载

一个容易踩的 React 坑:如果页签切换时用 tab === "3d" && <Viewer3D/> 条件渲染,每次切走 three.js 场景就被卸载,切回来要重建场景、重载 OBJ、丢相机位置。改成常驻挂载 + CSS 显隐

jsx 复制代码
<div style={{ display: tab === "3d" ? "contents" : "none" }}>
  <Viewer3D garmentUrl={garmentUrl} />
</div>

隐藏时 canvas 尺寸变 0,ResizeObserver 会在重新显示时把 renderer 尺寸纠正回来。

4.4 自动预览:防抖 + 静默重算

"拖了滑块预览没变化"是早期测试者(也就是我老板)提的第一个 bug。解决思路:

js 复制代码
useEffect(() => {
  if (skipAuto.current) return;              // 跳过首次配置加载
  if (!autoPreview || !online || !config) return;
  if (garmentUrl) setDirty3d(true);          // 3D 结果过期,状态栏给橙色警告
  clearTimeout(autoTimer.current);
  autoTimer.current = setTimeout(async () => {
    if (job?.running) return;                // 模拟期间不抢资源
    const r = await api.pattern({ measurements, options, tolerance });
    setPatternSvg(r.svg);                    // 静默更新,不弹 toast 不切页签
  }, 1000);
}, [measurements, options, tolerance, ...]);

2D 纸样几秒就能重算,适合做实时预览;3D 布料模拟要 25 秒,就老老实实手动触发 + 进度条,并用 dirty3d 标记提醒"当前 3D 不是最新参数的"。这也是工业软件(CLO3D 等)的"预览/精算"双模式思路。

4.5 Electron 主进程:Python 探测与生命周期

js 复制代码
function findPython() {
  const candidates = [
    process.env.COSTUMY_PYTHON,                                  // 环境变量优先
    path.join(ROOT, "python", "venv", "Scripts", "python.exe"),  // 项目内 venv
    path.join(ROOT, "..", "Costumy-main", "venv", "Scripts", "python.exe"), // 兄弟项目 venv
    "python",
  ].filter(Boolean);
  ...
}

主进程 spawn Python 后端,窗口关闭时 kill 掉,保证不留孤儿进程。开发模式用 concurrently + wait-on 一条命令拉起 Vite + Electron + Python 三者。

五、三个排查到怀疑人生的坑

坑 1:整页白屏------被 IDE"背刺"的 import

现象 :加了「自动预览」开关后,整个页面白屏,React 树整个卸载(rootChildren: 0)。

排查:用离屏 Electron 窗口抓取 renderer 控制台(这是本文最值得收藏的调试技巧):

js 复制代码
const win = new BrowserWindow({ show: false, webPreferences: { offscreen: true } });
win.webContents.on("console-message", (e, level, message) => logs.push(message));
await win.loadURL("http://localhost:5188/");
// ...
const img = await win.webContents.capturePage();  // 无头截图

报错是 Switch is not defined。但我明明在 ui.jsx 导出了、也在 App.jsx 写了 import------一看文件,import 行里的 Switch 凭空消失了

真相App.jsx 当时在 IDE 里开着,IDE 的文件同步把磁盘上的一处编辑冲掉了(buffer 里是没有 Switch 的旧版本,保存时覆盖了磁盘)。教训:Agent 改文件 + IDE 开同一文件 = 薛定谔的代码。遇到"明明改了却没生效",先重新读一遍文件确认磁盘上的真实内容。

坑 2:"模拟一直卡住"------轮询引发的 OBJ 重载风暴

现象:模拟完成后,界面像卡死一样,3D 视口闪烁。

排查 :看后端访问日志,发现 garment.obj 被以不同 ?t=时间戳 重复拉取,每 1.2 秒一次。

真相 :任务轮询代码里,每次 拿到带 garmentUrl 的响应都会刷新前端的 garmentUrl 状态:

js 复制代码
// 错误示范:轮询每次都产生新 URL → useEffect 反复重载 12MB 的 OBJ
if (st.garmentUrl) {
  setGarmentUrl(`${api.base}${st.garmentUrl}?t=${Date.now()}`);
}

而状态里只要 garment.obj 文件存在就有 garmentUrl------从"文件刚写完"到"任务标记 done"之间有好几个轮询周期,每次都触发 three.js 重新加载解析 12 万个顶点。

修复 :只在任务终态装载一次:

js 复制代码
if (st.garmentUrl && (st.stage === "done" || st.stage === "error")) {
  setGarmentUrl(`${api.base}${st.garmentUrl}?t=${Date.now()}`);
}

坑 3:白色斑点------两条游离边毁掉整件衣服(全文最硬核)

现象:某次模拟的 3D 结果变成了一团"白色噪点",隐约是衣服的形状。

这个问题我按"证据链"一步步排除,堪称教科书式的排查流程,值得完整记录:

第 1 步:怀疑模拟数据。 用 Blender 离线渲染这个 OBJ------红色背心完好无损,垂坠正常。数据没问题。

第 2 步:几何检查。 写脚本扫 OBJ:10305 个面片、0 个退化三角形、96.6% 面片绕向一致、包围盒尺寸正常。几何没问题。

第 3 步:最小化复现。 写了个 80 行的独立 three.js 页面加载同一个 OBJ------依然是白的。排除应用代码,锁定是 three.js 与这个文件的交互问题

第 4 步:降维打击。 直接在 Node 里用 three 的 OBJLoader 解析,对比"好文件"和"坏文件":

js 复制代码
const root = new OBJLoader().parse(text);
root.traverse((o) => console.log(o.type, o.name));
diff 复制代码
=== 好文件 a82eae5a
Group
Mesh Garment          ← 正常网格

=== 坏文件 ebb5d153
Group
LineSegments Garment  ← 网格变成了线段!面片全丢了!

第 5 步:定位元凶。 用 Counter 统计两个文件的 OBJ 记录类型,唯一的差异:坏文件多了 2 条 l 记录(游离边)。

yaml 复制代码
好文件: {'v': 12346, 'vn': 12346, 'vt': 12430, 'f': 24200}
坏文件: {'v': 5265,  'vn': 5265,  'vt': 5333,  'f': 10305, 'l': 2}

真相 :Blender 的 Weld 修改器偶尔会留下几条游离边 (不属于任何面片的边),OBJ 导出时写成 l 记录。而 three.js 的 OBJLoader 在同一个对象里遇到 f(面)和 l(线)混排时,会把整个对象错误地构建成 LineSegments------10305 个面片全部丢失,剩下的密集线网就是那团"白色斑点"。

修复(双层)

后端治本,导出后剥离 l 记录:

python 复制代码
# sim_worker.py:OBJ 后处理
lines = out_obj.read_text(encoding="utf8").splitlines(keepends=True)
stripped = [ln for ln in lines if not ln.startswith("l ")]
out_obj.write_text("".join(stripped))

前端防御,线/点对象不作为面料显示:

js 复制代码
obj.traverse((o) => {
  if (o.isMesh) {
    o.material = s.garmentMat;
    o.geometry.computeVertexNormals();
  } else if (o.isLine || o.isPoints) {
    o.visible = false;
  }
});

修复后渲染验证:红色运动背心完美穿在身上。

这个坑的教训 :文件格式转换是跨语言管线的"百慕大三角"。任何一端对格式的" corner case 解读"都可能让数据无声无息地漏掉------而且两端都不报错。排查这类问题,要在每个边界上做"数据审计"(记录类型统计、顶点/面片计数),而不是盯着代码看

六、性能数据

在 i7 + GTX 显卡的工作站上实测:

环节 耗时 说明
freesewing 打版 + cubic2quad ~2-3s 两次 node 进程调用
三角化 <1s 含静默崩溃重试
Blender 布料烘焙 55 帧 ~20s 约 1 万顶点的服装
OBJ 导出 + 前端加载渲染 <1s 12 万顶点(非索引展开后)
完整模拟 ~25s 从点击到穿上身

七、为什么做不到"实时预览"(以及怎么做到)

CLO3D / Marvelous Designer 能拖一下参数立刻看到布料变化,核心差异在四点:

  1. 求解器常驻内存:它们的 XPBD 求解器每帧只推进一小步(16ms 预算),改参数只是影响下一帧;我们是离线烘焙,每次都完整烘 55 帧。
  2. 零进程边界:它们版型网格、人体、求解器同进程;我们跨了 6 层(React→HTTP→Python→node→子进程→bpy→OBJ 文件→HTTP→three.js),光 OBJ 写盘+传输+解析就 1-2 秒。
  3. 碰撞作弊:实时软件用胶囊体/SDF 距离场做人体碰撞,我们用全网格碰撞体。
  4. 增量更新:它们只重算变化的裁片,我们每次都从 SVG 全量重来。

如果想给这个项目加实时预览,可行的路线按代价排序:

方案 实时性 代价
bake=false 快速模式(已实现) 5-8s 零成本,只看版型不看垂坠
前端 Web Worker 跑 XPBD + three.js 30-60fps 自己写缝合约束/碰撞求解
Python 常驻 XPBD + WebSocket 推流 5-15fps 重写求解器,省掉 OBJ 往返

八、总结

这个项目验证了"前端做交互、Python 做重计算"架构在 3D 内容生产工具上的可行性:

  • 科研原型产品化的关键不是重写算法,而是给它包一层"会呼吸"的壳:子进程隔离不稳定的 C 库、文件即进度的任务模型、轮询驱动的进度条。
  • 跨语言管线要在边界上做数据审计:顶点/面片计数、记录类型统计,比看代码更快定位问题。
  • ** Electron 离屏渲染 + console 转发 + capturePage 截图**是无头调试前端问题的神器组合,尤其在自动化测试通道不可用时。

目前的版本已经支持:4 套人体模板 × 3 种款式预设 × 10 个设计参数的自由组合、自动纸样预览、完整布料模拟。后续的方向:接入 freesewing 的 Brian(有袖上衣)版型、前端 XPBD 实时预览、以及把测量环节也自动化(从 3D 人体网格直接量体)。


作者的话:如果这篇文章帮你少踩了一个坑,点个赞让更多人看到。评论区欢迎交流布料模拟、3D 工具链、Electron 工程化的话题。

相关推荐
豆沙沙包?24 分钟前
函数重载/类和对象(P14-P60)
前端·算法
三十而立洋39 分钟前
彻底搞懂跨域:原理、CORS、解决方案与实战避坑
前端
计算机魔术师41 分钟前
ASR转录总出错?Google新模型WER降到2.6%,还能自动帮你改口误
前端
xcs1940543 分钟前
新版 IDEA(尤其是 2024/2025/2026)越来越臃肿
前端·人工智能·intellij-idea
Patrick_Wilson43 分钟前
iOS 第三方浏览器图片下载失败问题
前端·ios·浏览器
菜鸟小前端在线卖艺1 小时前
因为找不到好用的前端占位图,于是我自己写了个谁都能用的占位图功能
前端·程序员·产品
Z小明1 小时前
第 1 章 Vite 项目初始化
前端·vue.js
IT_陈寒1 小时前
Redis大KEY删除慢到手抖,这几个方法让我少熬一夜
前端·人工智能·后端
AICoder码农王1 小时前
depcruise 实战:把架构约定变成可执行的检查
前端