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"])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.
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.
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.
python examples/01_hello_clo.pyThis 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.
-
A fresh job directory is created under your temp directory.
-
Your
payloadis written torequest.jsonin it. -
The editor's current contents are read. If someone else's script is there, the run stops.
-
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.
-
Inside CLO,
worker.run(main)calls your function and writesresult.json— on every path, including exceptions, with the traceback in it. -
The host polls
result.jsonand 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.
| 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 |
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"})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.zfabloaded '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.
fDensityis grams per square millimetre (0.0001353is 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.
fBuKisfBuK_v2divided by π. EditfBuKand CLO accepts the file, returnsTrueand recomputes it fromfBuK_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.
SetFabricInformationcarries no mechanical word, so no grep forweight|thick|bend|stretchcan 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 2run 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.
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.
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.
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.mdThe 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.
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.
- 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.
CloSessionnow takes a lock on the jobs directory for the whole job and raisesCloBusyinstead. 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 thatExportOBJ(path)opens a dialog. Those are inreference/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.
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.
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.

