Skip to content

About

Drive CLO Standalone’s Python API from an ordinary Python process — import patterns, apply fabrics, simulate, render, export. CLO has no headless mode and no CLI entry point, so this automates its built-in Python editor and documents the traps.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

clo3d-python-bridge

tests

Run CLO Standalone's Python API from an ordinary Python process — import patterns, apply fabrics, simulate, render, export — on a machine where CLO is running.

from clo_bridge import CloSession

with CloSession() as clo:
    info = clo.run("examples/01_hello_clo.py")

print(info["version"], info["patternCount"])

Nine seconds of the film: a flat pattern becomes a garment, then the same pattern changes fabric and its measured hem changes with it

Watch the 72-second film (with sound; the loop above is a silent excerpt). It walks through one run call by call, then the same pattern in seven measured fabrics. Every point in it is a vertex CLO solved and every hem width is measured off the exported mesh. It is drawn by code, and film/ renders it again.

GitHub will not play a video stored in a repository, so that link goes through jsDelivr, which serves the same file. To keep a copy: docs/film.mp4.


Why this exists

CLO exposes 846 Python functions across six modules (measured on CLO 2026.1.310), and gives you no way to call them except by hand: open Edit > Python Script, paste, press Play. There is no headless flag, no clo --script, no socket and no RPC. If you have searched for how to automate CLO, you have already found that there is no answer.

So this package automates that window. Doing so is a few hundred lines. The part that took the time is everything in reference/PITFALLS.md — the ways CLO's UI reports the wrong thing, and what to do about each one. One of them decides the whole design:

Pressing Play tells you nothing. Qt can invalidate the editor's automation handle while CLO is still executing, so your automation reports a failure for a job that actually succeeded. The only evidence of an outcome is a file the worker itself wrote.

How a job reaches CLO and how its result comes back

Install

git clone https://github.com/ZHOUCHENHAO1221/clo3d-python-bridge
cd clo3d-python-bridge
pip install -e .

Windows, Python 3.8 or newer, and pywinauto. CLO must already be running and signed in, with a document open.

Check that it reaches your CLO

python examples/01_hello_clo.py

This is read-only. It opens nothing, changes nothing and saves nothing, which is why it is the right first thing to run.

CLO 2026.1.310
project: my_garment
patterns: 5

Verified against CLO 2026.1.310 on Windows 11.

If you get No CLO window with a Python editor was found, open Edit > Python Script in CLO and run it again. If you get CLO's Python editor contains a script this bridge did not write, that is deliberate — see Safety below.

What happens when you call clo.run()

  1. A fresh job directory is created under your temp directory.

  2. Your payload is written to request.json in it.

  3. The editor's current contents are read. If someone else's script is there, the run stops.

  4. A short loader is typed into the editor and Play is clicked. The loader is plain Python:

    # clo_bridge loader - safe to overwrite
    import sys
    if 'C:/dev/clo3d-python-bridge/src' not in sys.path: sys.path.insert(0, '...')
    exec(compile(open('C:/dev/my_worker.py', encoding='utf-8').read(), '...', 'exec'), {
        '__name__': '__main__', '__file__': '...', 'CLO_JOB_DIR': 'C:/Temp/clo_bridge_jobs/...'})

    Your worker stays a file on disk. It can be reviewed, diffed and version-controlled, which text pasted into a GUI box cannot.

  5. Inside CLO, worker.run(main) calls your function and writes result.json — on every path, including exceptions, with the traceback in it.

  6. The host polls result.json and nothing else. It ignores the click, the control state and any exception from the UI layer, for the reason quoted above.

The job directory is deleted after a successful run. Pass keep_jobs=True while you are developing a worker and the files stay put so you can read them.

A failed job keeps its directory whatever you pass, because the request and the last heartbeat are the only record of what the worker was doing when it stopped. Over a long batch those accumulate, so directories older than a week are removed when a session connects; prune_days=None keeps them for ever. Nothing younger goes, and nothing that is not a job directory this package named — deleting the wrong thing here destroys the evidence about a failure somebody is still investigating.

The package, file by file

File Runs where What it is for
src/clo_bridge/session.py your process CloSession — find the editor, send a job, wait for the result, raise an error that tells you what to do
src/clo_bridge/editor.py your process The pywinauto layer. Matches control ids by suffix, so a CLO update does not break it
src/clo_bridge/errors.py your process One exception type per thing you would do differently about it
src/clo_bridge/worker.py inside CLO run(main) always writes a result; beat(stage) says where a job got to
src/clo_bridge/api.py inside CLO require() name-checks a module before you touch the document; changed() asserts the output actually moved
src/clo_bridge/mesh.py your process Measures an exported mesh: boundary loops, the lowest closed one, non-manifold edges. No CLO, so the tests cover it
src/clo_bridge/protocol.py both The loader text, the file names, the polling. No GUI, so this is the part the tests cover
scripts/probe_api.py both Reads the API surface out of your own CLO build
scripts/fetch_official_signatures.py your process Reads CLO's published parameter names, which the binding does not keep
scripts/build_api_map.py your process Merges both into reference/API_MAP.md and reports where they disagree
scripts/build_diagrams.py your process Generates the figures in this README
reference/PITFALLS.md — Read this first. Every trap, with the symptom stated first, because the symptom is what you will have
reference/API_MAP.md — 846 functions grouped by task with CLO's published parameter names, plus the 159 ApiTypes enums and constants
src/clo_bridge/lock.py your process One job at a time across processes, because CLO's editor is a single slot
tests/test_offline.py anywhere The whole contract against a fake editor. No CLO, no Windows, no pytest

Writing a worker

A worker is an ordinary script. The three helpers exist because each of them prevents a specific bad afternoon:

from clo_bridge.api import require, changed
from clo_bridge.worker import run, beat

def main(payload):
    importer = require("import_api", "ImportDXFW")
    pattern  = require("pattern_api", "GetPatternCount")

    before = pattern.GetPatternCount()
    beat("importing")
    importer.ImportDXFW(payload["dxf"])
    changed(before, pattern.GetPatternCount(), "pattern count")

    return {"patterns": pattern.GetPatternCount()}

run(main)

require(module, *names) imports the module and checks every function you are about to call, before anything in the document has been touched. Without it, a build that spells one function differently fails halfway through, with the garment already half-modified.

beat(stage) records what the job is doing now. When a job times out, the host reports the last stage, which is the difference between "it failed" and "it got as far as simulating, and simulation is slow for this garment".

changed(before, after, label) raises if the two are equal. This exists because of the single most expensive mistake in this whole area: you set a property, re-simulate, see no difference and conclude the property does nothing. Nine times out of ten the fabric was never applied to the piece you were looking at, and the simulation genuinely re-ran — on unchanged inputs. "The job ran" and "the parameter took effect" are different claims. Only the second one is worth reporting.

Then run it:

with CloSession() as clo:
    clo.run("my_worker.py", {"dxf": r"C:\patterns\front.dxf"})

The examples

01_hello_clo.py asks CLO for its version, the open project's name and the pattern count, and prints them. Read-only. Every function it uses takes no argument, and the whole example has been run against a live CLO 2026.1.310, so this one is safe to trust exactly as written.

02_import_dxf_simulate_export.py imports a DXF, simulates it, exports an OBJ and returns the path and file size:

python examples/02_import_dxf_simulate_export.py front.dxf out/
3 patterns in the scene after the import
out/front.obj  (3829894 bytes)

The byte count moves between runs: the simulation is not deterministic, so do not assert on it.

Two things in it are worth copying even if you change every call.

It reads the pattern count after the import, so a file that returned True and brought in nothing is reported as an error instead of flowing on into a simulation of an empty scene. Note that the check is not after > before: importing a DXF replaces the pattern set rather than adding to it. Two files of three pieces each, imported one after the other, leave three pieces, not six — so a loop that imports several DXFs expecting to accumulate them keeps only the last.

And it exports through ExportOBJW(path, ImportExportOption()) rather than the shorter ExportOBJ(path). That is the difference between a job that finishes and a job that hangs for ever — see the examples section below and reference/PITFALLS.md.

03_load_measured_fabric.py loads a .zfab and checks that its numbers arrived:

python examples/03_load_measured_fabric.py path/to/fabric.zfab
loaded 'PG1_measured' at index 3
  Weight     300 g/m2     -> 135.3 g/m2
  Thickness  0.5 mm       -> 0.22 mm

It loads a file because no function in fabric_api sets bending, stretch or shear one property at a time. The fifty-nine writers set colour, roughness, metalness, opacity, texture maps, the name, the item number, the roll width and metadata. Across the whole API the only setters with weight or thickness in their names are SetZipperStyleWeight, SetZipperStyleTapeThickness, SetTrimWeight and SetAddlThicknessCollision — a zipper, a trim, and a solver collision tolerance.

The way in is the fabric file. An earlier version of this README said there was none, and that was wrong. ExportFabricW(path, index) writes a .jfab, which is plain JSON, and its mapPhysical block holds density, thickness, stretch, bending, shear and buckling. Edit the file and load it with ChangeFabricWithJsonW or AddFabricW, and the values arrive. Tested on 2026.1.310 on the default fabric of an empty project, reading back through both GetFabricInformationW and a fresh export:

field edited in the .jfab read back after loading
fDensity, fThickness as written — GetFabricInformationW went from 300 g/m2, 0.5 mm to 135.3 g/m2, 0.22 mm
fSuK, fSvK (stretch) as written
fBuK_v2, fBvK_v2 (bending), fLeftShearK_v2, fRightShearK_v2 (shear) as written, and fBuK, fBvK, fLeftShearK moved with them
fBuK, fBvK edited directly the call returned True and nothing changed
the unedited file, reloaded first nothing changed — the control

Three things to know before relying on it:

  • The numbers are solver units. fDensity is grams per square millimetre (0.0001353 is 135.3 g/m²). The bending and shear values are engine coefficients, not the 0–99 index the property editor shows.
  • Bending has two sets of fields and only one is read. fBuK is fBuK_v2 divided by π. Edit fBuK and CLO accepts the file, returns True and recomputes it from fBuK_v2. This is the repository's own trap again: a success value for a write that did not happen.
  • This shows the values reach the fabric record. No simulation was run on them here.

What went wrong the first time is worth a line. The 31 appearance keys this README used to cite come from GetFabricInfoW, which returns a summary. That was taken for the JSON ChangeFabricWithJson reads. It is a different document; the .jfab has about a thousand leaves. CLO's published API list says ChangeFabricWithJson overwrites all the properties of a fabric, and for every mechanical field tried here it did.

SetFabricInformation is the candidate that looks like a way in and is not, and it is worth knowing why. GetFabricInformationW returns a dict containing Weight and Thickness, and SetFabricInformationW(index, dict) is its exact write counterpart — a name with no mechanical word in it, which is why a keyword search never finds it. Tested on 2026.1.310:

call result
SetFabricInformationW(0, {…, "SupplierName": "ACME"}) SupplierName changed
SetFabricInformationW(0, {…, "Weight": "1200 g/m2"}) no change
three more forms, including the 3-argument overload no change

The first row is the control, and it is what makes the rest mean anything: the function does write this dictionary. Weight and Thickness are read-only through it — no error, no change, and it returns None, so nothing in the call tells you it declined. A script that writes a measured weight there and carries on produces a whole study at CLO's default 300 g/m² with every call having "succeeded".

A keyword search cannot establish an absence. SetFabricInformation carries no mechanical word, so no grep for weight|thick|bend|stretch can find it. The claim above rests on enumerating all fifty-nine writers in the module and testing the one candidate against a control, which is what it takes.

Loading a .zfab is the route this example uses, and it reads the values back and compares, because "AddFabricW returned an index" and "the document now holds the fabric I measured" are different claims.

04_sweep.py is the one that produced the contact sheet at the top: one garment, every fabric you hand it, measured.

python examples/04_sweep.py garment.zprj fabrics/*.zfab -o out/ --repeats 2
run                         sec     loop Y loop width   bbox Y
P24_measured_r1            12.0     913.62     431.72   913.62
P28_measured_r1            10.0     903.37     502.46   903.37

Three things in it are the example, and the rest is loop:

It reads the fabric assignment back before simulating. SetPatternPieceFabricIndex returns None, so a sweep where the fabric silently failed to apply gives you N identical garments and a table of plausible numbers — and nobody re-checks a plausible table.

It fixes the solver quality and time step. Those are session state, not project state. Left alone, your runs are not comparable to each other.

It measures off the exported mesh with clo_bridge.mesh, which finds the lowest closed boundary loop rather than the lowest vertex. On a garment those are usually different numbers, because the lowest vertex is normally a fold.

Progress is written after every run, so stopping loses nothing and starting again skips what is done.

All four examples have been run end to end against CLO 2026.1.310. That verifies the calls, not your results: a signature gives the argument count and types, and does not say what an argument means.

What it is for

Twenty-one garments: three patterns simulated in each of seven measured fabrics

Three patterns, seven measured fabrics, twenty-one simulations — plus six repeats to size the run-to-run noise. Twenty-seven runs in 5.3 minutes of solver time, 6.1 s to 21.8 s each, with nobody at the keyboard. Each run does the same thing: open the project, load a .zfab, assign it to every piece and read the assignment back, set the solver quality and time step, simulate, export the mesh, measure the hem off the exported geometry. Progress is written after every run, so stopping loses nothing already done.

The numbers above each garment are measured off the exported mesh, not read out of the simulator. The hem of the widest pattern moves from 432 mm to 502 mm depending only on which measured cloth it is cut in — which is the kind of thing worth having twenty-one of, and the kind of thing nobody produces by hand.

One trap this sweep walks past: the function that assigns a fabric to a piece is pattern_api.SetPatternPieceFabricIndex, not fabric_api.AssignFabricToPattern. See reference/PITFALLS.md.

Where that data went

The seven fabrics in the sweep were measured to ISO methods and turned into .zfab files, and those files and their before/after comparisons are published at drape-delta.netlify.app — a library of measured fabric data for CLO3D, with the gap between a generic library preset and the real cloth shown for each one. This package is the automation underneath it: every run in the contact sheet above went through the bridge.

That is also the honest answer to "why would I want to script CLO at all". Twenty-seven simulations is a long afternoon by hand and nobody checks a long afternoon's work for the mistake where the fabric silently failed to apply.

Finding the function you need

The CLO Python API grouped by task

reference/API_MAP.md lists all 846 names under the task you would have been thinking of — importing, fabric, simulation, camera, export, grading and so on — and a name that fits two tasks is listed under both. Names ending in W are the wide-string variants; use those for any path or label that may contain non-ASCII characters.

The map has two sources. Existence comes from probing the interpreter you are actually running; parameter names come from CLO's published API list, which documents one SDK release and need not match your build. The map ends with a section naming every function the two disagree about — 25 present on the probed build and undocumented, one documented and gone. Regenerate both against your own install:

python scripts/probe_api.py                 # read-only; what your build exposes
python scripts/fetch_official_signatures.py # CLO's published parameter names
python scripts/build_api_map.py             # -> reference/API_MAP.md

The probe is the committed map's own source, and this is what it printed on the build above:

CLO 2026.1.310  (reached a document with 5 patterns)
  utility_api    366
  pattern_api    217
  fabric_api     135
  import_api     39
  export_api     92
  ApiTypes       159
written: .../scripts/api_surface.json

It imports each module, calls dir(), and asks for the version and the pattern count. It opens nothing, changes nothing and saves nothing. The pattern count is only there to show it reached a real document; it is not written to the file.

Safety

The bridge refuses to overwrite a script in the editor that it did not put there. Overwriting would destroy unsaved work, and the marker comment on the loader's first line is what makes that check possible. For the same reason the bridge never saves your project: saving is a destructive act on a document you did not author.

It also handles no credentials, automates no sign-in and bypasses nothing. It drives the editor of an instance a person has already signed in to.

What it does do is exec() the worker file you name, inside CLO, with CLO's API in reach. That is the point of it, and it means CloSession must not be put behind an unauthenticated network service: anything that can choose the script path can run code on that machine.

Honest limits

  • There is no headless mode. Running this on a server means a real desktop session with CLO signed in. Whether your licence allows that is between you and CLO.
  • One job at a time, now enforced. CLO's editor is a single slot. Two bridge processes used to collide silently: the second would recognise the first's loader as this package's own, treat it as a leftover and overwrite a job mid-flight, leaving the first to time out for a result nobody would write. CloSession now takes a lock on the jobs directory for the whole job and raises CloBusy instead. A lock whose owner has died is taken over; a lock held by a live process never is, however old, because a long simulation and a hung one look identical from outside.
  • Windows only, because the UI automation is pywinauto.
  • The map gives you the interface, not the semantics. Parameter names and types are CLO's own, so Simulate(_steps: int) does tell you what the integer is — but nothing in it says that importing a DXF replaces the pattern set, or that ExportOBJ(path) opens a dialog. Those are in reference/PITFALLS.md, and they were found by running it.
  • The UI automation cannot be covered by CI, because CI has no CLO. The contract — loader, worker, results, heartbeats, timeouts, refusals — is fully covered and runs anywhere.

Tests

python tests/test_offline.py
  ok  test_atomic_write_leaves_no_partial_file
  ok  test_heartbeat_survives_into_the_timeout
  ok  test_loader_embeds_windows_paths_without_escape_damage
  ok  test_no_clo_gives_an_actionable_message
  ok  test_overwrites_its_own_leftover_loader
  ok  test_refuses_to_overwrite_someone_elses_script
  ok  test_round_trip
  ok  test_silent_worker_times_out_and_says_where_it_stopped
  ok  test_two_jobs_in_one_session_do_not_share_a_job_dir
  ok  test_worker_exception_reaches_the_host

10 passed

The fake editor executes the loader it is given, because the loader is ordinary Python. So these tests exercise the real loader text, the real worker module and the real result protocol — everything except the four pywinauto calls in editor.py. CI runs them on Ubuntu and Windows, on Python 3.8 and 3.12, on purpose: if they ever need a CLO or a Windows API, they have stopped testing the contract and started testing the UI.

test_two_jobs_in_one_session_do_not_share_a_job_dir is there because this suite caught a real bug. CLO keeps your modules loaded between runs, so a job directory cached at module level made the second job of a session write its result into the first job's directory — which the host had already deleted. The host then waited out its whole timeout for a result that had been written somewhere else.

Licence

MIT. CLO Standalone is a commercial product of CLO Virtual Fashion Inc. This project is not affiliated with or endorsed by them and contains no CLO code.

About

Drive CLO Standalone’s Python API from an ordinary Python process — import patterns, apply fabrics, simulate, render, export. CLO has no headless mode and no CLI entry point, so this automates its built-in Python editor and documents the traps.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages