curlyarrows 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,294 @@
1
+ Metadata-Version: 2.4
2
+ Name: curlyarrows
3
+ Version: 0.2.0
4
+ Summary: Arrow-pushing mechanisms, reactions and molecules from a JSON spec an LLM can write: explicit atom/bond graphs, routed curly arrows, RDKit rendering
5
+ Keywords: chemistry,arrow pushing,mechanism,rdkit,llm,mcp
6
+ License-Expression: MIT
7
+ Classifier: Programming Language :: Python :: 3
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Topic :: Scientific/Engineering :: Chemistry
10
+ Classifier: Intended Audience :: Science/Research
11
+ Requires-Dist: cairosvg>=2.9.1
12
+ Requires-Dist: pillow>=12.3.0
13
+ Requires-Dist: rdkit>=2026.3.6
14
+ Requires-Dist: requests>=2.34.2
15
+ Requires-Dist: epam-indigo>=1.46.0 ; extra == 'indigo'
16
+ Requires-Dist: mcp>=1.0 ; extra == 'mcp'
17
+ Requires-Dist: py2opsin>=1.2.0 ; extra == 'opsin'
18
+ Requires-Python: >=3.10
19
+ Project-URL: Homepage, https://github.com/dyang/curlyarrows
20
+ Provides-Extra: indigo
21
+ Provides-Extra: mcp
22
+ Provides-Extra: opsin
23
+ Description-Content-Type: text/markdown
24
+
25
+ # curlyarrows
26
+
27
+ Draw molecules, reaction schemes and **arrow-pushing mechanisms** from a JSON
28
+ spec an LLM can write directly: molecules as explicit atom/bond graphs (or
29
+ SMILES), and curly arrows between any two atoms or bonds, routed automatically
30
+ around bonds and labels. Natural language and reaction SMILES still work for
31
+ plain schemes.
32
+
33
+ ```json
34
+ {"title": "SN2",
35
+ "frames": [
36
+ {"species": [{"atoms": [{"id": "O1", "element": "O", "charge": -1, "hydrogens": 1}], "bonds": []},
37
+ "[CH3:2][Br:3]"],
38
+ "arrows": ["O1 -> 2", "2-3 -> 3"],
39
+ "caption": "backside attack"},
40
+ {"species": ["[CH3:2][OH:1]", "[Br-:3]"]}
41
+ ]}
42
+ ```
43
+
44
+ ![SN2](examples/v2_sn2.png)
45
+ ![E2](examples/v2_e2.png)
46
+ ![hydration](examples/v2_hydration.png)
47
+ ![Grignard](examples/v2_grignard.png)
48
+
49
+ ## Install
50
+
51
+ ```bash
52
+ pip install curlyarrows # library + CLI (rdkit, cairosvg, requests)
53
+ pip install "curlyarrows[mcp]" # + the MCP server
54
+ ```
55
+
56
+ From a checkout: `uv sync` (or `uv sync --all-extras` for the MCP server, the Indigo backend
57
+ and OPSIN, which needs Java).
58
+
59
+ On a minimal Linux (Docker `python:*-slim`, some sandboxes) RDKit's drawing module needs four
60
+ system libraries: `apt-get install libx11-6 libxext6 libxrender1 libexpat1`. PNG/PDF output
61
+ additionally needs the cairo library (`libcairo2`); without it SVG still works and
62
+ `curlyarrows.png_available()` tells you which case you are in.
63
+
64
+ ### Using it from claude.ai (Skill)
65
+
66
+ `dist/curlyarrows-skill.zip` (built by `uv run python tools/build_skill.py`) is a claude.ai
67
+ Skill: upload it under Settings > Capabilities > Skills. It teaches Claude the frames spec,
68
+ tells it to `pip install curlyarrows` in its sandbox, and ships three worked examples. The
69
+ sandbox needs network access set to allow package managers (the default).
70
+
71
+ ## Use
72
+
73
+ ```python
74
+ import curlyarrows
75
+
76
+ curlyarrows.draw("ethanol + acetic acid -(cat. H2SO4)-> ethyl acetate + water", "ester.png")
77
+ curlyarrows.draw("aspirin", "aspirin.svg")
78
+ svg = curlyarrows.draw("c1ccccc1Br.OB(O)c1ccccc1>[Pd]>c1ccc(-c2ccccc2)cc1") # returns SVG text
79
+
80
+ curlyarrows.draw({
81
+ "title": "Acid-catalysed hydration",
82
+ "steps": [
83
+ {"species": ["[CH2:1]=[CH:2][CH3:3]", "[H:5][OH2+:4]"], "arrows": ["1-2 -> 5", "5-4 -> 4"]},
84
+ {"species": ["[CH3:1][CH+:2][CH3:3]", "[OH2:4]"], "arrows": ["4 -> 2"]},
85
+ ]}, "hydration.png")
86
+
87
+ spec = curlyarrows.parse("cyclohexanone + NaBH4 in MeOH -> cyclohexanol") # inspect / edit before drawing
88
+ spec.to_dict()
89
+ ```
90
+
91
+ CLI:
92
+
93
+ ```bash
94
+ curlyarrows "benzene -[Br2, FeBr3]-> bromobenzene" -o out.png
95
+ curlyarrows mech.json -o mech.svg
96
+ curlyarrows "..." --json # show the parsed spec instead of drawing
97
+ curlyarrows resolve "sodium borohydride" "sertraline"
98
+ curlyarrows guide # the prompt text that teaches an LLM the spec
99
+ curlyarrows tools # Claude tool definitions (JSON)
100
+ curlyarrows serve # MCP server
101
+ ```
102
+
103
+ ### For agents (MCP)
104
+
105
+ `.mcp.json` in this repo registers the server for Claude Code. Tools:
106
+ `draw_chemistry`, `draw_mechanism`, `resolve_name`, `parse_chemistry`.
107
+ Each draw tool returns the output path, the parsed spec and the PNG as an
108
+ image block, so the agent can look at what it drew.
109
+
110
+ ```bash
111
+ claude mcp add curlyarrows -- uv run --project /path/to/chem_draw curlyarrows serve
112
+ ```
113
+
114
+ For direct tool use with the Claude API: `curlyarrows.guide.anthropic_tools()`
115
+ (or `openai_tools()`), and `curlyarrows.GUIDE` is a system-prompt snippet.
116
+
117
+ ## Input forms
118
+
119
+ | Input | Example | Becomes |
120
+ |---|---|---|
121
+ | name / formula / SMILES | `ibuprofen`, `NaBH4`, `CC(=O)O`, `smiles:C1CC1` | `Species` |
122
+ | reaction SMILES | `A.B>agents>C.D` | `Reaction` |
123
+ | prose | `A + B with X in Y at 0 °C gives C` | `Reaction` |
124
+ | chained prose | `A -> B [reagents] -> C [reagents]`, `A -[X]-> B`, `A <=> B` | `Scheme` |
125
+ | JSON | `{"reactants":..}`, `{"steps":[..]}` | `Reaction` / `Scheme` / `Mechanism` |
126
+
127
+ Prose rules: reactants are split on `+`, `and`, commas (not inside `1,2-`);
128
+ `with/using X` goes above the arrow, `in X` / `at X` / temperatures go below;
129
+ reagent-like species (bases, reductants, oxidants, catalysts) listed as
130
+ reactants are moved onto the arrow when a real substrate remains. Anything
131
+ that cannot be resolved raises `ResolveError` naming the token, so an agent can
132
+ retry with `smiles:...`.
133
+
134
+ Name resolution order: built-in dictionary (~1100 solvents, reagents, ions,
135
+ common organics) → the text is SMILES → OPSIN (if installed + Java) → PubChem →
136
+ NCI CIR. Network hits are cached in `~/.cache/curlyarrows/`. `offline=True`
137
+ disables the network.
138
+
139
+ ## The v2 model: graphs, frames, arrows
140
+
141
+ ```python
142
+ from curlyarrows import Molecule, Frame, Mechanism, draw
143
+
144
+ hydroxide = Molecule(name="hydroxide")
145
+ hydroxide.add_atom("O1", "O", charge=-1, hydrogens=1)
146
+
147
+ mebr = Molecule.from_smiles("[CH3:2][Br:3]") # atom maps become ids "2", "3"
148
+
149
+ mech = Mechanism(title="SN2", frames=[
150
+ Frame(species=[hydroxide, mebr], arrows=["O1 -> 2", "2-3 -> 3"], caption="backside attack"),
151
+ Frame(species=["[CH3:2][OH:1]", "[Br-:3]"]),
152
+ ])
153
+ draw(mech, "sn2.png")
154
+ ```
155
+
156
+ **Molecule** (`curlyarrows.graph`): `add_atom(id, element, charge=, radical=, hydrogens=, lone_pairs=, label=)`,
157
+ `add_bond(a, b, order=1|2|3|1.5, stereo=)`, `from_smiles(smiles, ids="map"|"index")`,
158
+ `from_dict`/`to_dict`, `layout(rotate=, flip_x=, flip_y=)`, `set_coords({...})`.
159
+ Hydrogen counts and lone pairs are inferred when omitted and never second-guessed
160
+ when given, odd valences are allowed (mechanisms need them), and an unknown
161
+ element string like `"Nu"` or `"Ph"` becomes a placeholder atom drawn with that text.
162
+
163
+ **Arrow** (`curlyarrows.arrows`): `"O1 -> C1"` (two electrons), `"C1-Br ~> Br"` (one electron, fishhook).
164
+ Endpoints are atom ids or `a-b` bonds; a bond that does not exist yet is a forming bond.
165
+ Dict form `{"from", "to", "electrons", "side", "bend", "label"}` forces the side or curvature.
166
+
167
+ **Routing**: every arrow gets a family of cubic Bézier candidates (both sides, several bends,
168
+ several departure and arrival angles, the lone-pair free side of the atom). Each is sampled and
169
+ scored for bond crossings, grazing other atoms' labels, overlapping captions, and crossing
170
+ earlier arrows, plus small shape penalties. Lowest score wins. Short arrows (lone pair to the
171
+ adjacent bond, bond onto its own atom) get a minimum arc length so they read as arrows, two
172
+ arrows leaving the same bond take opposite sides, and arrows sharing a target get staggered tips.
173
+ `tools/route_debug.py scene.json out.svg` draws all candidates for a scene to debug a route.
174
+
175
+ After the greedy pass, two refinement sweeps re-route each arrow against all the others and keep
176
+ a change only if it helps that arrow without hurting another, which undoes most bad choices the
177
+ order forced. If the best route in some frame still grazes a label or crosses a bond (four
178
+ arrows on a four-membered ring, say), the mechanism is re-rendered once with 1.45× longer bonds
179
+ and the cleaner version is kept (`Style(auto_scale=False)` or `"style": {"auto_scale": false}` to disable,
180
+ `"style": {"bond_length": 45}` to force a size).
181
+
182
+ **Frame / Mechanism** (`curlyarrows.scene`): a frame is species side by side with `+` and routed
183
+ arrows; `caption`, `next_arrow` (forward, equilibrium, resonance, retro, none), `next_label`,
184
+ `plus=False`. Ids must be unique within a frame, or qualify them as `"0:O1"`.
185
+ Species can be `Molecule`, a molecule dict, `{"smiles": ..., "rotate": 30}`, or a SMILES string.
186
+ An intramolecular attack (`"N1 -> C7"` inside one species) shapes the layout: the molecule is
187
+ drawn as if that bond existed, so the nucleophile sits next to its target and the arrow is short,
188
+ the way a chemist draws a ring closure (`forming_layout=False` on the frame turns it off;
189
+ `"forming": [["N1", "C7"]]` on a molecule adds pairs by hand).
190
+
191
+ Errors are written for an agent: an unknown endpoint lists the ids available in that frame.
192
+
193
+ ### v1 spec (still supported)
194
+ The older atom-map-only mechanism spec (`{"steps": [{"species": [...], "arrows": ["1 -> 2-3"]}]}`)
195
+ and the natural-language / reaction-SMILES paths are unchanged; the v1 mechanism class now lives at
196
+ `curlyarrows.spec.Mechanism`.
197
+
198
+ `examples/v2_staudinger.py` is a seven-frame Bertozzi–Staudinger ligation written cold against the
199
+ frames spec (placeholder Ph/R/Me groups, a four-membered ring with four arrows, an intramolecular
200
+ acyl transfer); `examples/v2_staudinger.png` is its output.
201
+
202
+ ## Styling
203
+
204
+ `curlyarrows.Style(bond_length=30, atom_colors=True, curly_color="#7b1fa2", curly_opacity=0.6,
205
+ max_row_width=1100, show_map_numbers=False, ...)` and pass `style=` to any draw
206
+ call, or put the overrides in the mechanism dict as `"style": {...}`. Curly arrows are purple (a colour RDKit does not use for any element) and 60 % opaque by default,
207
+ so they read against black bonds and coloured heteroatoms without hiding anything they cross. `--bw`, `--bond-length`, `--max-width`, `--show-maps`, `--arrow-color`, `--arrow-opacity` on the CLI.
208
+
209
+ ## The landscape: ways to draw chemistry programmatically
210
+
211
+ What exists, what it is good for, and what this package uses.
212
+
213
+ **2D depiction engines (structure → image)**
214
+
215
+ | Tool | Language | Notes |
216
+ |---|---|---|
217
+ | **RDKit** `rdMolDraw2D` | Python/C++ | Used here. SVG/PNG, atom pixel coords available (`GetDrawCoords`), reactions via `DrawReaction`, highlights, no curly arrows natively |
218
+ | **Indigo** (EPAM) | Python/Java/.NET | Optional backend here (`curlyarrows.render.indigo_backend`). Very good automatic reaction layout, SVG/PNG/PDF, no curly arrows |
219
+ | **CDK / CDK Depict** | Java (web service) | `https://www.simolecule.com/cdkdepict/depict/bow/svg?smi=...` renders molecules and reactions incl. reaction SMILES with agents; excellent abbreviation handling. Needs Java or the hosted service |
220
+ | **Open Babel** `obabel -osvg/-opng` | C++/CLI | Quick depictions, many formats; style is dated |
221
+ | **OpenChemLib** | Java/JS | Actelion's toolkit; used by many web tools |
222
+ | **Ketcher** (EPAM) | JS | Full web editor; headless depiction via its API, molfile/rxnfile in/out |
223
+ | **ChemDoodle Web / Kekule.js / JSME / Marvin JS** | JS | Browser editors and renderers |
224
+ | **mol2chemfig + chemfig (LaTeX)** | TeX | Publication-quality; `chemfig` natively supports curly arrows (`\chemmove`) and `\schemestart ... \arrow` schemes. Needs a TeX install |
225
+ | **ChemDraw (PerkinElmer/Revvity)** | desktop | Industry standard; CDXML is readable (`rdkit` can read CDXML), scriptable only via its own JS/Apple events |
226
+ | **Marvin (ChemAxon)** `molconvert` | Java | High quality, commercial |
227
+ | **PyMOL / 3D tools** | – | 3D only; not for schemes |
228
+
229
+ **Name ↔ structure**
230
+
231
+ | Tool | Notes |
232
+ |---|---|
233
+ | OPSIN | Open-source IUPAC parser (Java); `py2opsin` wraps it. Best for systematic names |
234
+ | PubChem PUG-REST | Trivial names, drugs, abbreviations; network |
235
+ | NCI/CADD CIR | Fallback resolver; network |
236
+ | STOUT / ChemBERTa-style models | ML name→SMILES; not used |
237
+ | RDKit `MolFromSmiles` | SMILES/SMARTS/InChI/molfile/CDXML parsing |
238
+
239
+ **Reaction / mechanism representations**
240
+
241
+ | Representation | Notes |
242
+ |---|---|
243
+ | Reaction SMILES `A.B>agents>C` | Universal, used by RDKit/Indigo/CDK; atom maps `[C:1]` carry identity across steps |
244
+ | RXN / RDF / CML / CDXML | File formats for schemes; RDKit reads RXN and CDXML |
245
+ | Atom-mapped SMILES + arrow list | What this package uses for curly arrows: `{"species": [...], "arrows": ["1 -> 2-3"]}` |
246
+ | chemfig `\chemmove` | TeX way to draw electron-pushing arrows |
247
+ | Hand-drawn SVG overlays | What `curlyarrows.render.svg.curly_arrow` does over RDKit atom coordinates |
248
+
249
+ **Where this package sits:** RDKit for molecule depiction (fonts are converted
250
+ to paths, so SVGs are self-contained), its own SVG compositor for `+` signs,
251
+ reaction arrows (forward, equilibrium, resonance, retro), reagent/condition
252
+ text with sub/superscripts, row wrapping, titles and captions, and
253
+ quadratic-Bézier curly arrows (full head or fishhook) anchored on RDKit's atom
254
+ pixel coordinates. cairosvg turns the SVG into PNG/PDF.
255
+
256
+ ## Layout of the code
257
+
258
+ ```
259
+ src/curlyarrows/
260
+ __init__.py draw(), draw_svg(), parse() – public API (v1 inputs and v2 objects)
261
+ graph.py v2: Atom / Bond / Molecule (inference, RDKit bridge, layout)
262
+ arrows.py v2: Arrow, SceneGeom, candidate routing + scoring, arrow SVG
263
+ scene.py v2: Frame / Mechanism
264
+ spec.py Species / Reaction / Scheme / Mechanism / MechStep / CurlyArrow
265
+ parse.py natural language → spec
266
+ resolve.py name → SMILES (dictionary, OPSIN, PubChem, CIR, cache)
267
+ names.py the built-in dictionary
268
+ guide.py GUIDE prompt text + JSON schemas + tool definitions
269
+ style.py Style dataclass
270
+ render/
271
+ rdkit_backend.py molecule → SVG body + atom pixel coords
272
+ svg.py Block compositor, arrows, text, curly arrows
273
+ layout.py v1 reactions, schemes, mechanisms
274
+ graph_backend.py v2 molecule -> SVG using the graph's own coordinates
275
+ scene_layout.py v2 frames and mechanisms
276
+ raster.py SVG → PNG/PDF
277
+ indigo_backend.py optional Indigo renderer
278
+ cli.py `curlyarrows` command
279
+ mcp_server.py `curlyarrows serve`
280
+ examples/gallery.py v1 examples; examples/v2_gallery.py v2 examples
281
+ tools/route_debug.py visualise arrow routing candidates
282
+ tests/ pytest
283
+ ```
284
+
285
+ ## Known limits
286
+
287
+ * Prose parsing is heuristic. Agents should prefer the frames spec; the parser is for convenience.
288
+ * Routing is heuristic: four arrows on a four-membered ring or three around one atom are legible only
289
+ after the automatic scale-up, and a lone pair attacking an atom one bond length away is a short
290
+ S-curve. `side`/`bend` hints, a `rotate` on the species, or a larger `bond_length` fix the rest.
291
+ * Arrows anchor on atom stand-off circles and bond midpoints; a specific lone pair or π-face is not chosen.
292
+ * Routing is pure Python at roughly 0.1 s per arrow, fine for mechanisms, slow for hundreds of arrows.
293
+ * Stereochemistry wedges come from RDKit; relative (cis/trans) descriptors in
294
+ names are only honoured when the resolver returns stereo-marked SMILES.
@@ -0,0 +1,270 @@
1
+ # curlyarrows
2
+
3
+ Draw molecules, reaction schemes and **arrow-pushing mechanisms** from a JSON
4
+ spec an LLM can write directly: molecules as explicit atom/bond graphs (or
5
+ SMILES), and curly arrows between any two atoms or bonds, routed automatically
6
+ around bonds and labels. Natural language and reaction SMILES still work for
7
+ plain schemes.
8
+
9
+ ```json
10
+ {"title": "SN2",
11
+ "frames": [
12
+ {"species": [{"atoms": [{"id": "O1", "element": "O", "charge": -1, "hydrogens": 1}], "bonds": []},
13
+ "[CH3:2][Br:3]"],
14
+ "arrows": ["O1 -> 2", "2-3 -> 3"],
15
+ "caption": "backside attack"},
16
+ {"species": ["[CH3:2][OH:1]", "[Br-:3]"]}
17
+ ]}
18
+ ```
19
+
20
+ ![SN2](examples/v2_sn2.png)
21
+ ![E2](examples/v2_e2.png)
22
+ ![hydration](examples/v2_hydration.png)
23
+ ![Grignard](examples/v2_grignard.png)
24
+
25
+ ## Install
26
+
27
+ ```bash
28
+ pip install curlyarrows # library + CLI (rdkit, cairosvg, requests)
29
+ pip install "curlyarrows[mcp]" # + the MCP server
30
+ ```
31
+
32
+ From a checkout: `uv sync` (or `uv sync --all-extras` for the MCP server, the Indigo backend
33
+ and OPSIN, which needs Java).
34
+
35
+ On a minimal Linux (Docker `python:*-slim`, some sandboxes) RDKit's drawing module needs four
36
+ system libraries: `apt-get install libx11-6 libxext6 libxrender1 libexpat1`. PNG/PDF output
37
+ additionally needs the cairo library (`libcairo2`); without it SVG still works and
38
+ `curlyarrows.png_available()` tells you which case you are in.
39
+
40
+ ### Using it from claude.ai (Skill)
41
+
42
+ `dist/curlyarrows-skill.zip` (built by `uv run python tools/build_skill.py`) is a claude.ai
43
+ Skill: upload it under Settings > Capabilities > Skills. It teaches Claude the frames spec,
44
+ tells it to `pip install curlyarrows` in its sandbox, and ships three worked examples. The
45
+ sandbox needs network access set to allow package managers (the default).
46
+
47
+ ## Use
48
+
49
+ ```python
50
+ import curlyarrows
51
+
52
+ curlyarrows.draw("ethanol + acetic acid -(cat. H2SO4)-> ethyl acetate + water", "ester.png")
53
+ curlyarrows.draw("aspirin", "aspirin.svg")
54
+ svg = curlyarrows.draw("c1ccccc1Br.OB(O)c1ccccc1>[Pd]>c1ccc(-c2ccccc2)cc1") # returns SVG text
55
+
56
+ curlyarrows.draw({
57
+ "title": "Acid-catalysed hydration",
58
+ "steps": [
59
+ {"species": ["[CH2:1]=[CH:2][CH3:3]", "[H:5][OH2+:4]"], "arrows": ["1-2 -> 5", "5-4 -> 4"]},
60
+ {"species": ["[CH3:1][CH+:2][CH3:3]", "[OH2:4]"], "arrows": ["4 -> 2"]},
61
+ ]}, "hydration.png")
62
+
63
+ spec = curlyarrows.parse("cyclohexanone + NaBH4 in MeOH -> cyclohexanol") # inspect / edit before drawing
64
+ spec.to_dict()
65
+ ```
66
+
67
+ CLI:
68
+
69
+ ```bash
70
+ curlyarrows "benzene -[Br2, FeBr3]-> bromobenzene" -o out.png
71
+ curlyarrows mech.json -o mech.svg
72
+ curlyarrows "..." --json # show the parsed spec instead of drawing
73
+ curlyarrows resolve "sodium borohydride" "sertraline"
74
+ curlyarrows guide # the prompt text that teaches an LLM the spec
75
+ curlyarrows tools # Claude tool definitions (JSON)
76
+ curlyarrows serve # MCP server
77
+ ```
78
+
79
+ ### For agents (MCP)
80
+
81
+ `.mcp.json` in this repo registers the server for Claude Code. Tools:
82
+ `draw_chemistry`, `draw_mechanism`, `resolve_name`, `parse_chemistry`.
83
+ Each draw tool returns the output path, the parsed spec and the PNG as an
84
+ image block, so the agent can look at what it drew.
85
+
86
+ ```bash
87
+ claude mcp add curlyarrows -- uv run --project /path/to/chem_draw curlyarrows serve
88
+ ```
89
+
90
+ For direct tool use with the Claude API: `curlyarrows.guide.anthropic_tools()`
91
+ (or `openai_tools()`), and `curlyarrows.GUIDE` is a system-prompt snippet.
92
+
93
+ ## Input forms
94
+
95
+ | Input | Example | Becomes |
96
+ |---|---|---|
97
+ | name / formula / SMILES | `ibuprofen`, `NaBH4`, `CC(=O)O`, `smiles:C1CC1` | `Species` |
98
+ | reaction SMILES | `A.B>agents>C.D` | `Reaction` |
99
+ | prose | `A + B with X in Y at 0 °C gives C` | `Reaction` |
100
+ | chained prose | `A -> B [reagents] -> C [reagents]`, `A -[X]-> B`, `A <=> B` | `Scheme` |
101
+ | JSON | `{"reactants":..}`, `{"steps":[..]}` | `Reaction` / `Scheme` / `Mechanism` |
102
+
103
+ Prose rules: reactants are split on `+`, `and`, commas (not inside `1,2-`);
104
+ `with/using X` goes above the arrow, `in X` / `at X` / temperatures go below;
105
+ reagent-like species (bases, reductants, oxidants, catalysts) listed as
106
+ reactants are moved onto the arrow when a real substrate remains. Anything
107
+ that cannot be resolved raises `ResolveError` naming the token, so an agent can
108
+ retry with `smiles:...`.
109
+
110
+ Name resolution order: built-in dictionary (~1100 solvents, reagents, ions,
111
+ common organics) → the text is SMILES → OPSIN (if installed + Java) → PubChem →
112
+ NCI CIR. Network hits are cached in `~/.cache/curlyarrows/`. `offline=True`
113
+ disables the network.
114
+
115
+ ## The v2 model: graphs, frames, arrows
116
+
117
+ ```python
118
+ from curlyarrows import Molecule, Frame, Mechanism, draw
119
+
120
+ hydroxide = Molecule(name="hydroxide")
121
+ hydroxide.add_atom("O1", "O", charge=-1, hydrogens=1)
122
+
123
+ mebr = Molecule.from_smiles("[CH3:2][Br:3]") # atom maps become ids "2", "3"
124
+
125
+ mech = Mechanism(title="SN2", frames=[
126
+ Frame(species=[hydroxide, mebr], arrows=["O1 -> 2", "2-3 -> 3"], caption="backside attack"),
127
+ Frame(species=["[CH3:2][OH:1]", "[Br-:3]"]),
128
+ ])
129
+ draw(mech, "sn2.png")
130
+ ```
131
+
132
+ **Molecule** (`curlyarrows.graph`): `add_atom(id, element, charge=, radical=, hydrogens=, lone_pairs=, label=)`,
133
+ `add_bond(a, b, order=1|2|3|1.5, stereo=)`, `from_smiles(smiles, ids="map"|"index")`,
134
+ `from_dict`/`to_dict`, `layout(rotate=, flip_x=, flip_y=)`, `set_coords({...})`.
135
+ Hydrogen counts and lone pairs are inferred when omitted and never second-guessed
136
+ when given, odd valences are allowed (mechanisms need them), and an unknown
137
+ element string like `"Nu"` or `"Ph"` becomes a placeholder atom drawn with that text.
138
+
139
+ **Arrow** (`curlyarrows.arrows`): `"O1 -> C1"` (two electrons), `"C1-Br ~> Br"` (one electron, fishhook).
140
+ Endpoints are atom ids or `a-b` bonds; a bond that does not exist yet is a forming bond.
141
+ Dict form `{"from", "to", "electrons", "side", "bend", "label"}` forces the side or curvature.
142
+
143
+ **Routing**: every arrow gets a family of cubic Bézier candidates (both sides, several bends,
144
+ several departure and arrival angles, the lone-pair free side of the atom). Each is sampled and
145
+ scored for bond crossings, grazing other atoms' labels, overlapping captions, and crossing
146
+ earlier arrows, plus small shape penalties. Lowest score wins. Short arrows (lone pair to the
147
+ adjacent bond, bond onto its own atom) get a minimum arc length so they read as arrows, two
148
+ arrows leaving the same bond take opposite sides, and arrows sharing a target get staggered tips.
149
+ `tools/route_debug.py scene.json out.svg` draws all candidates for a scene to debug a route.
150
+
151
+ After the greedy pass, two refinement sweeps re-route each arrow against all the others and keep
152
+ a change only if it helps that arrow without hurting another, which undoes most bad choices the
153
+ order forced. If the best route in some frame still grazes a label or crosses a bond (four
154
+ arrows on a four-membered ring, say), the mechanism is re-rendered once with 1.45× longer bonds
155
+ and the cleaner version is kept (`Style(auto_scale=False)` or `"style": {"auto_scale": false}` to disable,
156
+ `"style": {"bond_length": 45}` to force a size).
157
+
158
+ **Frame / Mechanism** (`curlyarrows.scene`): a frame is species side by side with `+` and routed
159
+ arrows; `caption`, `next_arrow` (forward, equilibrium, resonance, retro, none), `next_label`,
160
+ `plus=False`. Ids must be unique within a frame, or qualify them as `"0:O1"`.
161
+ Species can be `Molecule`, a molecule dict, `{"smiles": ..., "rotate": 30}`, or a SMILES string.
162
+ An intramolecular attack (`"N1 -> C7"` inside one species) shapes the layout: the molecule is
163
+ drawn as if that bond existed, so the nucleophile sits next to its target and the arrow is short,
164
+ the way a chemist draws a ring closure (`forming_layout=False` on the frame turns it off;
165
+ `"forming": [["N1", "C7"]]` on a molecule adds pairs by hand).
166
+
167
+ Errors are written for an agent: an unknown endpoint lists the ids available in that frame.
168
+
169
+ ### v1 spec (still supported)
170
+ The older atom-map-only mechanism spec (`{"steps": [{"species": [...], "arrows": ["1 -> 2-3"]}]}`)
171
+ and the natural-language / reaction-SMILES paths are unchanged; the v1 mechanism class now lives at
172
+ `curlyarrows.spec.Mechanism`.
173
+
174
+ `examples/v2_staudinger.py` is a seven-frame Bertozzi–Staudinger ligation written cold against the
175
+ frames spec (placeholder Ph/R/Me groups, a four-membered ring with four arrows, an intramolecular
176
+ acyl transfer); `examples/v2_staudinger.png` is its output.
177
+
178
+ ## Styling
179
+
180
+ `curlyarrows.Style(bond_length=30, atom_colors=True, curly_color="#7b1fa2", curly_opacity=0.6,
181
+ max_row_width=1100, show_map_numbers=False, ...)` and pass `style=` to any draw
182
+ call, or put the overrides in the mechanism dict as `"style": {...}`. Curly arrows are purple (a colour RDKit does not use for any element) and 60 % opaque by default,
183
+ so they read against black bonds and coloured heteroatoms without hiding anything they cross. `--bw`, `--bond-length`, `--max-width`, `--show-maps`, `--arrow-color`, `--arrow-opacity` on the CLI.
184
+
185
+ ## The landscape: ways to draw chemistry programmatically
186
+
187
+ What exists, what it is good for, and what this package uses.
188
+
189
+ **2D depiction engines (structure → image)**
190
+
191
+ | Tool | Language | Notes |
192
+ |---|---|---|
193
+ | **RDKit** `rdMolDraw2D` | Python/C++ | Used here. SVG/PNG, atom pixel coords available (`GetDrawCoords`), reactions via `DrawReaction`, highlights, no curly arrows natively |
194
+ | **Indigo** (EPAM) | Python/Java/.NET | Optional backend here (`curlyarrows.render.indigo_backend`). Very good automatic reaction layout, SVG/PNG/PDF, no curly arrows |
195
+ | **CDK / CDK Depict** | Java (web service) | `https://www.simolecule.com/cdkdepict/depict/bow/svg?smi=...` renders molecules and reactions incl. reaction SMILES with agents; excellent abbreviation handling. Needs Java or the hosted service |
196
+ | **Open Babel** `obabel -osvg/-opng` | C++/CLI | Quick depictions, many formats; style is dated |
197
+ | **OpenChemLib** | Java/JS | Actelion's toolkit; used by many web tools |
198
+ | **Ketcher** (EPAM) | JS | Full web editor; headless depiction via its API, molfile/rxnfile in/out |
199
+ | **ChemDoodle Web / Kekule.js / JSME / Marvin JS** | JS | Browser editors and renderers |
200
+ | **mol2chemfig + chemfig (LaTeX)** | TeX | Publication-quality; `chemfig` natively supports curly arrows (`\chemmove`) and `\schemestart ... \arrow` schemes. Needs a TeX install |
201
+ | **ChemDraw (PerkinElmer/Revvity)** | desktop | Industry standard; CDXML is readable (`rdkit` can read CDXML), scriptable only via its own JS/Apple events |
202
+ | **Marvin (ChemAxon)** `molconvert` | Java | High quality, commercial |
203
+ | **PyMOL / 3D tools** | – | 3D only; not for schemes |
204
+
205
+ **Name ↔ structure**
206
+
207
+ | Tool | Notes |
208
+ |---|---|
209
+ | OPSIN | Open-source IUPAC parser (Java); `py2opsin` wraps it. Best for systematic names |
210
+ | PubChem PUG-REST | Trivial names, drugs, abbreviations; network |
211
+ | NCI/CADD CIR | Fallback resolver; network |
212
+ | STOUT / ChemBERTa-style models | ML name→SMILES; not used |
213
+ | RDKit `MolFromSmiles` | SMILES/SMARTS/InChI/molfile/CDXML parsing |
214
+
215
+ **Reaction / mechanism representations**
216
+
217
+ | Representation | Notes |
218
+ |---|---|
219
+ | Reaction SMILES `A.B>agents>C` | Universal, used by RDKit/Indigo/CDK; atom maps `[C:1]` carry identity across steps |
220
+ | RXN / RDF / CML / CDXML | File formats for schemes; RDKit reads RXN and CDXML |
221
+ | Atom-mapped SMILES + arrow list | What this package uses for curly arrows: `{"species": [...], "arrows": ["1 -> 2-3"]}` |
222
+ | chemfig `\chemmove` | TeX way to draw electron-pushing arrows |
223
+ | Hand-drawn SVG overlays | What `curlyarrows.render.svg.curly_arrow` does over RDKit atom coordinates |
224
+
225
+ **Where this package sits:** RDKit for molecule depiction (fonts are converted
226
+ to paths, so SVGs are self-contained), its own SVG compositor for `+` signs,
227
+ reaction arrows (forward, equilibrium, resonance, retro), reagent/condition
228
+ text with sub/superscripts, row wrapping, titles and captions, and
229
+ quadratic-Bézier curly arrows (full head or fishhook) anchored on RDKit's atom
230
+ pixel coordinates. cairosvg turns the SVG into PNG/PDF.
231
+
232
+ ## Layout of the code
233
+
234
+ ```
235
+ src/curlyarrows/
236
+ __init__.py draw(), draw_svg(), parse() – public API (v1 inputs and v2 objects)
237
+ graph.py v2: Atom / Bond / Molecule (inference, RDKit bridge, layout)
238
+ arrows.py v2: Arrow, SceneGeom, candidate routing + scoring, arrow SVG
239
+ scene.py v2: Frame / Mechanism
240
+ spec.py Species / Reaction / Scheme / Mechanism / MechStep / CurlyArrow
241
+ parse.py natural language → spec
242
+ resolve.py name → SMILES (dictionary, OPSIN, PubChem, CIR, cache)
243
+ names.py the built-in dictionary
244
+ guide.py GUIDE prompt text + JSON schemas + tool definitions
245
+ style.py Style dataclass
246
+ render/
247
+ rdkit_backend.py molecule → SVG body + atom pixel coords
248
+ svg.py Block compositor, arrows, text, curly arrows
249
+ layout.py v1 reactions, schemes, mechanisms
250
+ graph_backend.py v2 molecule -> SVG using the graph's own coordinates
251
+ scene_layout.py v2 frames and mechanisms
252
+ raster.py SVG → PNG/PDF
253
+ indigo_backend.py optional Indigo renderer
254
+ cli.py `curlyarrows` command
255
+ mcp_server.py `curlyarrows serve`
256
+ examples/gallery.py v1 examples; examples/v2_gallery.py v2 examples
257
+ tools/route_debug.py visualise arrow routing candidates
258
+ tests/ pytest
259
+ ```
260
+
261
+ ## Known limits
262
+
263
+ * Prose parsing is heuristic. Agents should prefer the frames spec; the parser is for convenience.
264
+ * Routing is heuristic: four arrows on a four-membered ring or three around one atom are legible only
265
+ after the automatic scale-up, and a lone pair attacking an atom one bond length away is a short
266
+ S-curve. `side`/`bend` hints, a `rotate` on the species, or a larger `bond_length` fix the rest.
267
+ * Arrows anchor on atom stand-off circles and bond midpoints; a specific lone pair or π-face is not chosen.
268
+ * Routing is pure Python at roughly 0.1 s per arrow, fine for mechanisms, slow for hundreds of arrows.
269
+ * Stereochemistry wedges come from RDKit; relative (cis/trans) descriptors in
270
+ names are only honoured when the resolver returns stereo-marked SMILES.
@@ -0,0 +1,45 @@
1
+ [project]
2
+ name = "curlyarrows"
3
+ version = "0.2.0"
4
+ description = "Arrow-pushing mechanisms, reactions and molecules from a JSON spec an LLM can write: explicit atom/bond graphs, routed curly arrows, RDKit rendering"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.10"
8
+ keywords = [
9
+ "chemistry",
10
+ "arrow pushing",
11
+ "mechanism",
12
+ "rdkit",
13
+ "llm",
14
+ "mcp",
15
+ ]
16
+ classifiers = [
17
+ "Programming Language :: Python :: 3",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Topic :: Scientific/Engineering :: Chemistry",
20
+ "Intended Audience :: Science/Research",
21
+ ]
22
+ dependencies = [
23
+ "cairosvg>=2.9.1",
24
+ "pillow>=12.3.0",
25
+ "rdkit>=2026.3.6",
26
+ "requests>=2.34.2",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/dyang/curlyarrows"
31
+
32
+ [project.scripts]
33
+ curlyarrows = "curlyarrows.cli:main"
34
+
35
+ [project.optional-dependencies]
36
+ indigo = ["epam-indigo>=1.46.0"]
37
+ mcp = ["mcp>=1.0"]
38
+ opsin = ["py2opsin>=1.2.0"]
39
+
40
+ [build-system]
41
+ requires = ["uv_build>=0.12.9,<0.13.0"]
42
+ build-backend = "uv_build"
43
+
44
+ [dependency-groups]
45
+ dev = ["pytest>=9.1.1"]
@@ -0,0 +1,46 @@
1
+ [project]
2
+ name = "curlyarrows"
3
+ version = "0.2.0"
4
+ description = "Arrow-pushing mechanisms, reactions and molecules from a JSON spec an LLM can write: explicit atom/bond graphs, routed curly arrows, RDKit rendering"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.10"
8
+ keywords = ["chemistry", "arrow pushing", "mechanism", "rdkit", "llm", "mcp"]
9
+ classifiers = [
10
+ "Programming Language :: Python :: 3",
11
+ "License :: OSI Approved :: MIT License",
12
+ "Topic :: Scientific/Engineering :: Chemistry",
13
+ "Intended Audience :: Science/Research",
14
+ ]
15
+ dependencies = [
16
+ "cairosvg>=2.9.1",
17
+ "pillow>=12.3.0",
18
+ "rdkit>=2026.3.6",
19
+ "requests>=2.34.2",
20
+ ]
21
+
22
+ [project.urls]
23
+ Homepage = "https://github.com/dyang/curlyarrows"
24
+
25
+ [project.scripts]
26
+ curlyarrows = "curlyarrows.cli:main"
27
+
28
+ [project.optional-dependencies]
29
+ indigo = [
30
+ "epam-indigo>=1.46.0",
31
+ ]
32
+ mcp = [
33
+ "mcp>=1.0",
34
+ ]
35
+ opsin = [
36
+ "py2opsin>=1.2.0",
37
+ ]
38
+
39
+ [build-system]
40
+ requires = ["uv_build>=0.12.9,<0.13.0"]
41
+ build-backend = "uv_build"
42
+
43
+ [dependency-groups]
44
+ dev = [
45
+ "pytest>=9.1.1",
46
+ ]