跪拜 Guibai
← Back to the summary

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:

  1. Uses freesewing.org (an open-source parametric pattern-making library) to generate 2D patterns from body measurements
  2. Triangulates the pattern into a mesh and marks seam edges
  3. 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:

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) │
└────────────────────────────────────────────────────┘

image.png

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:

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 LineSegments10305 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:

  1. 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.
  2. 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.
  3. Collision cheating: Real-time software uses capsules/SDF distance fields for body collision; we use a full mesh collider.
  4. 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 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.

Comments

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.