从论文原型到桌面应用:用 Vite + Electron + React + Python 重造 3D 服装打版软件
把一个"只能跑脚本"的科研原型 Costumy,改造成点几下鼠标就能给 3D 模特穿衣服的桌面应用。前端只管预览和调参,脏活累活全交给 Python。本文记录完整实现过程,以及三个让我排查到怀疑人生的坑。
前言
Costumy 是 CDRIN 开源的 3D 服装原型工具,打通了 2D 纸样 → 3D 服装 的完整链路:
- 用 freesewing.org(一个开源的参数化打版库)根据人体尺寸生成 2D 纸样
- 把纸样三角化成网格,标注缝合边
- 用 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 能拖一下参数立刻看到布料变化,核心差异在四点:
- 求解器常驻内存:它们的 XPBD 求解器每帧只推进一小步(16ms 预算),改参数只是影响下一帧;我们是离线烘焙,每次都完整烘 55 帧。
- 零进程边界:它们版型网格、人体、求解器同进程;我们跨了 6 层(React→HTTP→Python→node→子进程→bpy→OBJ 文件→HTTP→three.js),光 OBJ 写盘+传输+解析就 1-2 秒。
- 碰撞作弊:实时软件用胶囊体/SDF 距离场做人体碰撞,我们用全网格碰撞体。
- 增量更新:它们只重算变化的裁片,我们每次都从 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 工程化的话题。