A 3D Garment CAD Tool Rebuilt from Python Scripts into a Desktop App
From Research Prototype to Desktop App: Rebuilding a 3D Garment Pattern-Making Tool with Vite + Electron + React + Python
Turning Costumy, a research prototype that could only run scripts, into a desktop application where a few mouse clicks dress a 3D model. The frontend handles preview and parameter tweaking; all the heavy lifting is offloaded to Python. This article records the full implementation and three bugs that had me questioning my sanity.
Foreword
Costumy is an open-source 3D garment prototyping tool by CDRIN that connects the full 2D pattern → 3D garment pipeline:
- Uses freesewing.org (an open-source parametric pattern-making library) to generate 2D patterns from body measurements
- Triangulates the pattern into a mesh and marks seam edges
- Uses Blender's cloth physics engine to "drape" the pattern onto a body model, sew, simulate drape, and export an OBJ
But its usage is extremely "geeky": write Python scripts, call the Blender API, wait minutes for baking, then load the OBJ in a separate static HTML file to see the result. Change one parameter? Do it all over again.
So I rebuilt it as a desktop application Costumy Studio:
- Vite + Electron + React: parameter panel, 3D preview, 2D pattern preview — all graphical
- Python backend: pattern calculation, triangulation, Blender cloth simulation, passing data to the frontend via HTTP API
The final result: drag a slider on the left, and a 2D pattern appears in 1 second on the right; click "Cloth Simulation", and 25 seconds later a red vest is worn on the model.
First, the overall effect (the 3D viewport after simulation completes):
┌────────────────────────────────────────────────────┐
│ Costumy Studio [Auto Preview] [Backend Online] │
│ ┌──────────┐ ┌──────────────────────────────┐ │
│ │ Style Presets │ │ │ │
│ │ Body Templates│ │ 🧍 Gray body model │ │
│ │ Measurements │ │ 👕 Red vest (cloth sim) │ │
│ │ Design Params│ │ │ │
│ │ Sim Params │ │ Drag to rotate · Scroll to zoom │ │
│ └──────────┘ └──────────────────────────────┘ │
│ [done] Complete: 5265 vertices · Task ebb5d153 (sport) │
└────────────────────────────────────────────────────┘
1. Technology Choices: Why "Frontend/Backend Separation" Instead of Pure JS
The laziest idea would be to rewrite the entire pipeline in JS (freesewing itself is JS). But one link cannot be bypassed — cloth simulation:
- Blender's cloth physics engine (sewing springs, self-collision, mesh colliders) is an industrial-grade implementation built over decades
- Python's
bpymodule can use Blender directly as a library, no Blender client installation needed - The JS ecosystem has no cloth solver of equivalent maturity (writing your own XPBD is another huge rabbit hole, discussed at the end)
So the division of labor was set: the frontend does everything "fast", Python does everything "heavy".
React Frontend (param changes/preview) ──HTTP──> Python Backend (complex computation)
├─ three.js 3D viewport ├─ server.py HTTP API (pure stdlib)
├─ SVG 2D pattern ├─ sim_worker.py bpy cloth sim subprocess
├─ Parameter panel (Element Plus spec) ├─ costumy/ original package: pattern/triangulation/seam mapping
└─ Electron window └─ node/ freesewing + cubic2quad
Tech stack list:
| Layer | Technology | Role |
|---|---|---|
| Frontend Framework | React 18 + Vite 5 | Parameter panel, view switching |
| 3D Rendering | three.js (OBJLoader + OrbitControls) | Body + garment preview |
| Desktop Shell | Electron 33 | Window management, launching Python process |
| Backend Service | Python stdlib http.server |
HTTP API, zero new dependencies |
| Cloth Simulation | bpy 5.0.1 (Blender module) | Physics baking |
| Pattern Making | freesewing (node) + costumy | Parametric pattern generation |
| Triangulation | triangle (python binding) | Pattern → mesh |
2. Overall Architecture and Data Flow
2.1 Two Core Pipelines
Pipeline A: Fast Pattern Preview (about 1~3 seconds)
Frontend drags slider → POST /api/pattern {measurements, options}
→ Python calls node to run freesewing, generates SVG
→ cubic2quad curve degree reduction (node script)
→ costumy cleans panels, maps seam edges (front/back 4 seam lines)
→ returns { svg, spec.json, stats } → frontend renders 2D pattern
Pipeline B: Cloth Simulation (about 25 seconds)
Frontend clicks "Cloth Simulation" → POST /api/simulate → returns jobId
→ Python launches **[subprocess]** sim_worker.py:
1. Pattern making (same as Pipeline A)
2. bpy loads body OBJ, calculates neck point, front/back bounding surface references
3. Aligns front/back panels to body
4. triangle triangulation (several thousand triangles per panel)
5. Blender cloth simulation: sewing springs + collider, bakes 55 frames
6. Exports garment.obj
7. Writes status.json at each step to update progress
Frontend polls GET /api/jobs/<id> every 1.2s → progress bar
→ after done, gets garmentUrl → three.js loads and displays
2.2 Project Structure
11/
├── electron/
│ ├── main.cjs # Main process: detect Python, start backend, open window
│ └── preload.cjs
├── src/
│ ├── App.jsx # State hub: polling, auto preview, task management
│ ├── api.js # HTTP client
│ ├── components/
│ │ ├── ParamsPanel.jsx # Parameter panel (style/body/design/simulation four groups)
│ │ ├── Viewer3D.jsx # three.js 3D viewport
│ │ ├── Pattern2D.jsx # SVG pattern (scroll zoom/drag pan)
│ │ └── ui.jsx # Element Plus style base components
│ └── styles.css # Element Plus design spec tokens
├── python/
│ ├── server.py # HTTP API (stdlib, no third-party dependencies)
│ ├── sim_worker.py # Cloth simulation worker process
│ ├── costumy/ # Original costumy package (includes node scripts and dependencies)
│ └── body_tpose.obj # T-pose body collider (16340 vertices)
└── workspace/jobs/<id>/ # Products of each simulation: params/status/garment.obj...
3. Python Backend Implementation
3.1 Why Use the Standard Library for an HTTP Service
The backend's required numpy/svg.path/triangle/bpy are all installed in an existing venv, and I didn't want to pollute the environment by installing Flask/FastAPI. http.server.ThreadingHTTPServer writing a JSON API is only about a hundred lines, and allows precise control over CORS and static files:
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": # Fast pattern (in-process computation)
return self._json(generate_pattern(payload))
if path == "/api/simulate": # Cloth simulation (subprocess)
return self._json({"jobId": start_sim_job(payload)})
Route table:
| Method | Path | Description |
|---|---|---|
| GET | /api/health |
Health check (frontend probes every 5s) |
| GET | /api/config |
Default measurements, min/default/max for 10 design params, 3 style presets, 4 body templates |
| POST | /api/pattern |
Generate 2D pattern SVG + spec JSON |
| POST | /api/simulate |
Start cloth simulation task |
| GET | /api/jobs/<id> |
Poll task progress (includes log tail, product URL) |
| GET | /api/files/<path> |
Download products like garment.obj from workspace |
3.2 Simulation Must Run in a Subprocess
This is a lesson inherited from the original Costumy, for two hard reasons:
Reason 1: The triangle library can "silently crash". The original author's workaround is practically performance art — run the triangulation script in a subprocess, and the main process recursively retries until the subprocess prints $$success$$ (up to 40 times), because try/except simply cannot catch crashes at the C level:
# Original code from 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")
# ...start subprocess, only complete when "$$success$$" is read
return self._make_mesh_for_sim(temp_pickle_path, temp_json_path, n_attemps)
Reason 2: bpy is not thread-safe. Running Blender operations inside the HTTP service's worker threads will blow up unpredictably.
So the architecture is: the HTTP service main process only accepts requests, /api/simulate spawns an independent Python subprocess to run the simulation, reporting progress via a status.json file:
proc = subprocess.Popen(
[sys.executable, str(BASE_DIR / "sim_worker.py"), str(job_dir)],
stdout=log_file, stderr=subprocess.STDOUT,
)
With this isolation, even if the simulation process crashes, the HTTP service is unharmed; the frontend just gets "task failed + log".
3.3 Cloth Simulation Worker Process
sim_worker.py fully replicates the original's physics parameters and workflow:
# 1. Pattern making
design = Aaron(measurements)
pattern = design.new_pattern(options=options, tolerance=tolerance)
# 2. bpy loads body, normalizes to 1.65m, calculates alignment references
bpy.ops.wm.obj_import(filepath=str(BODY_OBJ), up_axis="Y", forward_axis="NEGATIVE_Z")
# Neck root ≈ 85% of height; chest band area |x|<0.18m and z∈[1.15,1.38],
# uses 2%/98% percentiles to resist outlier vertices like hair/arms, gets torso front/back bounding surfaces
references = {"neck": [0, 0, neck_z * 100], "bound_front": ..., "bound_back": ...}
# 3. Align panels
pattern.align_panels(references)
# 4. Triangulation + cloth simulation (physics params identical to original)
garment = pattern.as_garment(collider=body, output_path=str(out_obj), bake=True, ...)
Key Blender cloth physics parameters (original recipe):
cloth.settings.mass = 1.2 # kg
cloth.settings.use_sewing_springs = True # sewing springs
cloth.settings.sewing_force_max = 38
cloth.settings.quality = 10 # simulation quality
cloth.collision_settings.collision_quality = 5
cloth.collision_settings.use_self_collision = True # self-collision
cloth.settings.tension_stiffness = 20
cloth.settings.bending_stiffness = 0.5
# Bake 55 frames, then use Weld modifier to weld seam vertices together
4. React Frontend Implementation
4.1 Interface Layout (Element Plus Design Spec)
┌─────────────────────────────────────────────────┐
│ Top bar: logo · auto preview toggle · backend status badge · two buttons │
├──────────┬──────────────────────────────────────┤
│ Left 336px│ Tabs: 3D Preview / 2D Pattern / Spec JSON │
│ Param Panel│ │
│ · Style Presets│ three.js viewport / SVG / JSON │
│ · Body Templates│ │
│ · Design Params│ │
│ · Sim Params│ │
├──────────┴──────────────────────────────────────┤
│ Status bar: progress · task info · log │
└─────────────────────────────────────────────────┘
Styling strictly follows Element Plus tokens: primary color #409eff, success #67c23a, control height 32px, border-radius 4px, 0.3s ease-in-out transitions. Since this is a React project, the Element Plus component library itself wasn't used; instead, a set of same-style base components (Button/Switch/Slider/Select/Collapse/Badge/Toast) was handwritten, totaling under 100 lines.
4.2 3D Viewport
three.js scene setup: ambient light + key light (with shadows) + rim light, grid floor + ShadowMaterial circular shadow receiver. The body OBJ is metric and loaded directly; the garment OBJ is centimeter-scale, loaded with scale.setScalar(0.01) to align with the body — this is the convention from the original preview.html.
const garmentMat = new THREE.MeshPhysicalMaterial({
color: "#e05563", roughness: 0.85,
sheen: 0.4, sheenColor: "#ffffff", // fabric sheen
side: THREE.DoubleSide, // cloth must render both sides
});
A floating tool card sits in the top-left of the viewport: 6 fabric color swatches, and three toggles for showing the body, wireframe mode, and auto-rotation — replicating the original's interaction.
4.3 Three Views Mounted Persistently
A common React pitfall: if tab switching uses tab === "3d" && <Viewer3D/> conditional rendering, the three.js scene is unmounted every time you switch away, requiring scene rebuild, OBJ reload, and lost camera position when switching back. Changed to persistent mounting + CSS show/hide:
<div style={{ display: tab === "3d" ? "contents" : "none" }}>
<Viewer3D garmentUrl={garmentUrl} />
</div>
When hidden, the canvas size becomes 0; ResizeObserver corrects the renderer size when it's shown again.
4.4 Auto Preview: Debounce + Silent Recalculation
"Dragging a slider doesn't change the preview" was the first bug reported by an early tester (my boss). The solution:
useEffect(() => {
if (skipAuto.current) return; // skip initial config load
if (!autoPreview || !online || !config) return;
if (garmentUrl) setDirty3d(true); // 3D result stale, status bar shows orange warning
clearTimeout(autoTimer.current);
autoTimer.current = setTimeout(async () => {
if (job?.running) return; // don't compete for resources during simulation
const r = await api.pattern({ measurements, options, tolerance });
setPatternSvg(r.svg); // silent update, no toast, no tab switch
}, 1000);
}, [measurements, options, tolerance, ...]);
A 2D pattern can be recalculated in seconds, suitable for real-time preview; 3D cloth simulation takes 25 seconds, so it's strictly manual trigger + progress bar, with a dirty3d flag warning "current 3D is not from the latest parameters". This is also the "preview/final" dual-mode thinking found in industrial software (CLO3D, etc.).
4.5 Electron Main Process: Python Detection and Lifecycle
function findPython() {
const candidates = [
process.env.COSTUMY_PYTHON, // env var takes priority
path.join(ROOT, "python", "venv", "Scripts", "python.exe"), // in-project venv
path.join(ROOT, "..", "Costumy-main", "venv", "Scripts", "python.exe"), // sibling project venv
"python",
].filter(Boolean);
...
}
The main process spawns the Python backend, and kills it when the window closes, ensuring no orphan processes. In dev mode, concurrently + wait-on launches Vite + Electron + Python all with one command.
5. Three Bugs That Had Me Questioning My Sanity
Bug 1: Full-Page White Screen — Betrayed by the IDE's Import
Symptom: After adding the "Auto Preview" toggle, the entire page went white; the React tree was completely unmounted (rootChildren: 0).
Investigation: Used an offscreen Electron window to capture the renderer console (this is the most bookmark-worthy debugging trick in this article):
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(); // headless screenshot
The error was Switch is not defined. But I had clearly exported it in ui.jsx and written the import in App.jsx — looking at the file, the Switch in the import line had vanished into thin air.
Truth: App.jsx was open in the IDE at the time, and the IDE's file sync overwrote an edit on disk (the buffer held an old version without Switch, and saving overwrote the disk). Lesson: Agent modifying files + IDE having the same file open = Schrödinger's code. When something "clearly changed but didn't take effect", first re-read the file to confirm the actual content on disk.
Bug 2: "Simulation Keeps Hanging" — OBJ Reload Storm Caused by Polling
Symptom: After simulation completed, the interface appeared frozen, with the 3D viewport flickering.
Investigation: Checked the backend access logs and found garment.obj being repeatedly fetched with different ?t=timestamp query strings, every 1.2 seconds.
Truth: In the task polling code, every response carrying a garmentUrl was refreshing the frontend's garmentUrl state:
// Wrong approach: polling produces a new URL every time → useEffect repeatedly reloads the 12MB OBJ
if (st.garmentUrl) {
setGarmentUrl(`${api.base}${st.garmentUrl}?t=${Date.now()}`);
}
And the state has a garmentUrl as long as the garment.obj file exists — between "file just written" and "task marked done" there are several polling cycles, each triggering three.js to reload and parse 120,000 vertices.
Fix: Only load once at the task's terminal state:
if (st.garmentUrl && (st.stage === "done" || st.stage === "error")) {
setGarmentUrl(`${api.base}${st.garmentUrl}?t=${Date.now()}`);
}
Bug 3: White Speckles — Two Stray Edges Ruin an Entire Garment (The Hardest Bug in This Article)
Symptom: The 3D result of one simulation turned into a mass of "white noise", vaguely the shape of a garment.
I eliminated possibilities step by step following a chain of evidence, a textbook investigation process worth recording in full:
Step 1: Suspect simulation data. Rendered this OBJ offline in Blender — the red vest was perfectly intact, drape normal. Data was fine.
Step 2: Geometry check. Wrote a script to scan the OBJ: 10305 faces, 0 degenerate triangles, 96.6% face winding consistent, bounding box dimensions normal. Geometry was fine.
Step 3: Minimal reproduction. Wrote an 80-line standalone three.js page loading the same OBJ — still white. Ruled out application code, locked onto an interaction problem between three.js and this specific file.
Step 4: Dimensionality reduction. Directly parsed in Node using three's OBJLoader, comparing a "good file" and a "bad file":
const root = new OBJLoader().parse(text);
root.traverse((o) => console.log(o.type, o.name));
=== Good file a82eae5a
Group
Mesh Garment ← normal mesh
=== Bad file ebb5d153
Group
LineSegments Garment ← mesh became line segments! All faces lost!
Step 5: Locate the culprit. Used a Counter to tally OBJ record types for both files; the only difference: the bad file had 2 extra l records (stray edges).
Good file: {'v': 12346, 'vn': 12346, 'vt': 12430, 'f': 24200}
Bad file: {'v': 5265, 'vn': 5265, 'vt': 5333, 'f': 10305, 'l': 2}
Truth: Blender's Weld modifier occasionally leaves behind a few stray edges (edges not belonging to any face), written as l records on OBJ export. When three.js's OBJLoader encounters mixed f (face) and l (line) records within the same object, it incorrectly constructs the entire object as LineSegments — 10305 faces all lost, and the dense line network left is that mass of "white speckles".
Fix (two layers):
Backend root fix, strip l records after export:
# sim_worker.py: OBJ post-processing
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))
Frontend defense, line/point objects not displayed as fabric:
obj.traverse((o) => {
if (o.isMesh) {
o.material = s.garmentMat;
o.geometry.computeVertexNormals();
} else if (o.isLine || o.isPoints) {
o.visible = false;
}
});
Post-fix render verification: the red sports vest perfectly worn on the model.
The lesson of this bug: File format conversion is the "Bermuda Triangle" of cross-language pipelines. Any one end's "corner case interpretation" of a format can silently leak data — and neither end reports an error. To investigate such problems, perform a "data audit" at every boundary (record type counts, vertex/face counts), rather than staring at code.
6. Performance Data
Measured on an i7 + GTX workstation:
| Step | Time | Notes |
|---|---|---|
| freesewing pattern + cubic2quad | ~2-3s | Two node process calls |
| Triangulation | <1s | Includes silent crash retries |
| Blender cloth bake 55 frames | ~20s | Garment of ~10k vertices |
| OBJ export + frontend load & render | <1s | 120k vertices (after non-indexed expansion) |
| Full simulation | ~25s | From click to worn on model |
7. Why "Real-Time Preview" Isn't Possible (And How It Could Be)
CLO3D / Marvelous Designer can drag a parameter and instantly see cloth changes. The core difference lies in four points:
- Solver resident in memory: Their XPBD solver advances only a small step per frame (16ms budget); changing a parameter just affects the next frame. We do offline baking, fully baking 55 frames each time.
- Zero process boundaries: Their pattern mesh, body, and solver are in the same process; we cross 6 layers (React→HTTP→Python→node→subprocess→bpy→OBJ file→HTTP→three.js), with OBJ write+transfer+parse alone taking 1-2 seconds.
- Collision cheating: Real-time software uses capsules/SDF distance fields for body collision; we use a full mesh collider.
- Incremental updates: They only recompute changed panels; we start over from SVG in full each time.
If one wanted to add real-time preview to this project, feasible routes ranked by cost:
| Approach | Real-time | Cost |
|---|---|---|
bake=false fast mode (already implemented) |
5-8s | Zero cost, shows fit but not drape |
| Frontend Web Worker running XPBD + three.js | 30-60fps | Write your own seam constraints/collision solver |
| Python resident XPBD + WebSocket push | 5-15fps | Rewrite solver, save OBJ round-trip |
8. Summary
This project validates the feasibility of the "frontend for interaction, Python for heavy computation" architecture for 3D content creation tools:
- The key to productizing a research prototype is not rewriting algorithms, but wrapping it in a "breathing" shell: subprocess isolation for unstable C libraries, a file-as-progress task model, polling-driven progress bars.
- Cross-language pipelines require data auditing at boundaries: vertex/face counts, record type tallies locate problems faster than reading code.
- Electron offscreen rendering + console forwarding + capturePage screenshots is a divine toolkit for headless frontend debugging, especially when automated test channels are unavailable.
The current version already supports: free combination of 4 body templates × 3 style presets × 10 design parameters, automatic pattern preview, and full cloth simulation. Future directions: integrating freesewing's Brian (sleeved top) pattern, frontend XPBD real-time preview, and automating the measurement step (direct body measurement from 3D body mesh).
Author's note: If this article helped you avoid even one pitfall, give it a like so more people see it. Comments welcome for discussion on cloth simulation, 3D toolchains, and Electron engineering topics.
Top 1 of 2 from juejin.cn, machine-translated. The original thread is authoritative.
The architecture is very clear, and the front-end/back-end separation design is clever. I've run into similar cross-language data interaction problems when building Electron apps before — especially the case you mentioned where stray edges mixed into the OBJ file caused front-end parsing errors. That's way too real: each module looks fine on its own, but when you chain them together, everything blows up. The approach of isolating Blender calls in a Python subprocess is great; it avoids main-process crashes from memory or threading issues. The off-screen window trick for capturing logs and screenshots is also really practical — I've been burned before by the separation of main-process and renderer-process logs in similar apps, and this solution is a solid idea. I'm curious: for the 25-second 3D simulation pipeline, have you considered adding a progress callback? If users could see a percentage while waiting, the experience would be much better.
There is a progress callback — clicking 'generate cloth' shows it in real time.