Metadata-Version: 2.4
Name: curlyarrows
Version: 0.2.3
Summary: Arrow-pushing mechanisms, reactions and molecules from a JSON spec an LLM can write: explicit atom/bond graphs, routed curly arrows, RDKit rendering
Keywords: chemistry,arrow pushing,mechanism,rdkit,llm,mcp
License-Expression: MIT
Classifier: Programming Language :: Python :: 3
Classifier: License :: OSI Approved :: MIT License
Classifier: Topic :: Scientific/Engineering :: Chemistry
Classifier: Intended Audience :: Science/Research
Requires-Dist: cairosvg>=2.9.1
Requires-Dist: pillow>=12.3.0
Requires-Dist: rdkit>=2026.3.6
Requires-Dist: requests>=2.34.2
Requires-Dist: epam-indigo>=1.46.0 ; extra == 'indigo'
Requires-Dist: mcp>=1.0 ; extra == 'mcp'
Requires-Dist: py2opsin>=1.2.0 ; extra == 'opsin'
Requires-Python: >=3.10
Project-URL: Homepage, https://github.com/dyang/curlyarrows
Provides-Extra: indigo
Provides-Extra: mcp
Provides-Extra: opsin
Description-Content-Type: text/markdown

# curlyarrows

Draw molecules, reaction schemes and **arrow-pushing mechanisms** from a JSON
spec an LLM can write directly: molecules as explicit atom/bond graphs (or
SMILES), and curly arrows between any two atoms or bonds, routed automatically
around bonds and labels. Natural language and reaction SMILES still work for
plain schemes.

```json
{"title": "SN2",
 "frames": [
   {"species": [{"atoms": [{"id": "O1", "element": "O", "charge": -1, "hydrogens": 1}], "bonds": []},
                "[CH3:2][Br:3]"],
    "arrows": ["O1 -> 2", "2-3 -> 3"],
    "caption": "backside attack"},
   {"species": ["[CH3:2][OH:1]", "[Br-:3]"]}
 ]}
```

![SN2](examples/v2_sn2.png)
![E2](examples/v2_e2.png)
![hydration](examples/v2_hydration.png)
![Grignard](examples/v2_grignard.png)

## Install

```bash
pip install curlyarrows                 # library + CLI (rdkit, cairosvg, requests)
pip install "curlyarrows[mcp]"          # + the MCP server
```

From a checkout: `uv sync` (or `uv sync --all-extras` for the MCP server, the Indigo backend
and OPSIN, which needs Java).

On a minimal Linux (Docker `python:*-slim`, some sandboxes) RDKit's drawing module needs four
system libraries: `apt-get install libx11-6 libxext6 libxrender1 libexpat1`. PNG/PDF output
additionally needs the cairo library (`libcairo2`); without it SVG still works and
`curlyarrows.png_available()` tells you which case you are in.

### Using it from claude.ai (Skill)

`dist/curlyarrows-skill.zip` (built by `uv run python tools/build_skill.py`) is a claude.ai
Skill: upload it under Settings > Capabilities > Skills. It teaches Claude the frames spec,
tells it to `pip install curlyarrows` in its sandbox, and ships three worked examples. The
sandbox needs network access set to allow package managers (the default).

## Use

```python
import curlyarrows

curlyarrows.draw("ethanol + acetic acid -(cat. H2SO4)-> ethyl acetate + water", "ester.png")
curlyarrows.draw("aspirin", "aspirin.svg")
svg = curlyarrows.draw("c1ccccc1Br.OB(O)c1ccccc1>[Pd]>c1ccc(-c2ccccc2)cc1")   # returns SVG text

curlyarrows.draw({
  "title": "Acid-catalysed hydration",
  "steps": [
    {"species": ["[CH2:1]=[CH:2][CH3:3]", "[H:5][OH2+:4]"], "arrows": ["1-2 -> 5", "5-4 -> 4"]},
    {"species": ["[CH3:1][CH+:2][CH3:3]", "[OH2:4]"],       "arrows": ["4 -> 2"]},
  ]}, "hydration.png")

spec = curlyarrows.parse("cyclohexanone + NaBH4 in MeOH -> cyclohexanol")   # inspect / edit before drawing
spec.to_dict()
```

CLI:

```bash
curlyarrows "benzene -[Br2, FeBr3]-> bromobenzene" -o out.png
curlyarrows mech.json -o mech.svg
curlyarrows "..." --json            # show the parsed spec instead of drawing
curlyarrows resolve "sodium borohydride" "sertraline"
curlyarrows guide                   # the prompt text that teaches an LLM the spec
curlyarrows tools                   # Claude tool definitions (JSON)
curlyarrows serve                   # MCP server
```

### For agents (MCP)

`.mcp.json` in this repo registers the server for Claude Code. Tools:
`draw_chemistry`, `draw_mechanism`, `resolve_name`, `parse_chemistry`.
Each draw tool returns the output path, the parsed spec and the PNG as an
image block, so the agent can look at what it drew.

```bash
claude mcp add curlyarrows -- uv run --project /path/to/chem_draw curlyarrows serve
```

For direct tool use with the Claude API: `curlyarrows.guide.anthropic_tools()`
(or `openai_tools()`), and `curlyarrows.GUIDE` is a system-prompt snippet.

## Input forms

| Input | Example | Becomes |
|---|---|---|
| name / formula / SMILES | `ibuprofen`, `NaBH4`, `CC(=O)O`, `smiles:C1CC1` | `Species` |
| reaction SMILES | `A.B>agents>C.D` | `Reaction` |
| prose | `A + B with X in Y at 0 °C gives C` | `Reaction` |
| chained prose | `A -> B [reagents] -> C [reagents]`, `A -[X]-> B`, `A <=> B` | `Scheme` |
| JSON | `{"reactants":..}`, `{"steps":[..]}` | `Reaction` / `Scheme` / `Mechanism` |

Prose rules: reactants are split on `+`, `and`, commas (not inside `1,2-`);
`with/using X` goes above the arrow, `in X` / `at X` / temperatures go below;
reagent-like species (bases, reductants, oxidants, catalysts) listed as
reactants are moved onto the arrow when a real substrate remains. Anything
that cannot be resolved raises `ResolveError` naming the token, so an agent can
retry with `smiles:...`.

Name resolution order: built-in dictionary (~1100 solvents, reagents, ions,
common organics) → the text is SMILES → OPSIN (if installed + Java) → PubChem →
NCI CIR. Network hits are cached in `~/.cache/curlyarrows/`. `offline=True`
disables the network.

## The v2 model: graphs, frames, arrows

```python
from curlyarrows import Molecule, Frame, Mechanism, draw

hydroxide = Molecule(name="hydroxide")
hydroxide.add_atom("O1", "O", charge=-1, hydrogens=1)

mebr = Molecule.from_smiles("[CH3:2][Br:3]")          # atom maps become ids "2", "3"

mech = Mechanism(title="SN2", frames=[
    Frame(species=[hydroxide, mebr], arrows=["O1 -> 2", "2-3 -> 3"], caption="backside attack"),
    Frame(species=["[CH3:2][OH:1]", "[Br-:3]"]),
])
draw(mech, "sn2.png")
```

**Molecule** (`curlyarrows.graph`): `add_atom(id, element, charge=, radical=, hydrogens=, lone_pairs=, label=)`,
`add_bond(a, b, order=1|2|3|1.5, stereo=)`, `from_smiles(smiles, ids="map"|"index")`,
`from_dict`/`to_dict`, `layout(rotate=, flip_x=, flip_y=)`, `set_coords({...})`.
Hydrogen counts and lone pairs are inferred when omitted and never second-guessed
when given, odd valences are allowed (mechanisms need them), and an unknown
element string like `"Nu"` or `"Ph"` becomes a placeholder atom drawn with that text.

**Arrow** (`curlyarrows.arrows`): `"O1 -> C1"` (two electrons), `"C1-Br ~> Br"` (one electron, fishhook).
Endpoints are atom ids or `a-b` bonds; a bond that does not exist yet is a forming bond.
Dict form `{"from", "to", "electrons", "side", "bend", "label"}` forces the side or curvature.

**Routing**: every arrow gets a family of cubic Bézier candidates (both sides, several bends,
several departure and arrival angles, the lone-pair free side of the atom). Each is sampled and
scored for bond crossings, grazing other atoms' labels, overlapping captions, and crossing
earlier arrows, plus small shape penalties. Lowest score wins. Short arrows (lone pair to the
adjacent bond, bond onto its own atom) get a minimum arc length so they read as arrows, two
arrows leaving the same bond take opposite sides, and arrows sharing a target get staggered tips.
`tools/route_debug.py scene.json out.svg` draws all candidates for a scene to debug a route.

After the greedy pass, two refinement sweeps re-route each arrow against all the others and keep
a change only if it helps that arrow without hurting another, which undoes most bad choices the
order forced. If the best route in some frame still grazes a label or crosses a bond (four
arrows on a four-membered ring, say), the mechanism is re-rendered once with 1.45× longer bonds
and the cleaner version is kept (`Style(auto_scale=False)` or `"style": {"auto_scale": false}` to disable,
`"style": {"bond_length": 45}` to force a size).

**Frame / Mechanism** (`curlyarrows.scene`): a frame is species side by side with `+` and routed
arrows; `caption`, `next_arrow` (forward, equilibrium, resonance, retro, none), `next_label`,
`plus=False`. Ids must be unique within a frame, or qualify them as `"0:O1"`.
Species can be `Molecule`, a molecule dict, `{"smiles": ..., "rotate": 30}`, or a SMILES string.
An intramolecular attack (`"N1 -> C7"` inside one species) shapes the layout: the molecule is
drawn as if that bond existed, so the nucleophile sits next to its target and the arrow is short,
the way a chemist draws a ring closure (`forming_layout=False` on the frame turns it off;
`"forming": [["N1", "C7"]]` on a molecule adds pairs by hand).

Errors are written for an agent: an unknown endpoint lists the ids available in that frame.

### v1 spec (still supported)
The older atom-map-only mechanism spec (`{"steps": [{"species": [...], "arrows": ["1 -> 2-3"]}]}`)
and the natural-language / reaction-SMILES paths are unchanged; the v1 mechanism class now lives at
`curlyarrows.spec.Mechanism`.

`examples/v2_staudinger.py` is a seven-frame Bertozzi–Staudinger ligation written cold against the
frames spec (placeholder Ph/R/Me groups, a four-membered ring with four arrows, an intramolecular
acyl transfer); `examples/v2_staudinger.png` is its output.

## Styling

`curlyarrows.Style(bond_length=30, atom_colors=True, curly_color="#7b1fa2", curly_opacity=0.6,
max_row_width=1100, show_map_numbers=False, ...)` and pass `style=` to any draw
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,
so they read against black bonds and coloured heteroatoms without hiding anything they cross. `"background": null` (or `--transparent`) drops the white background for web pages and slides.
`"theme": "dark"` (or `--theme dark`) draws light strokes and text on a dark background for dark-mode
viewers; `"theme": "auto"` makes an SVG flip to dark by itself via a `prefers-color-scheme` media query
(a PNG cannot adapt, so it stays light).
`--bw`, `--bond-length`, `--max-width`, `--show-maps`, `--arrow-color`, `--arrow-opacity`, `--transparent` on the CLI.

## The landscape: ways to draw chemistry programmatically

What exists, what it is good for, and what this package uses.

**2D depiction engines (structure → image)**

| Tool | Language | Notes |
|---|---|---|
| **RDKit** `rdMolDraw2D` | Python/C++ | Used here. SVG/PNG, atom pixel coords available (`GetDrawCoords`), reactions via `DrawReaction`, highlights, no curly arrows natively |
| **Indigo** (EPAM) | Python/Java/.NET | Optional backend here (`curlyarrows.render.indigo_backend`). Very good automatic reaction layout, SVG/PNG/PDF, no curly arrows |
| **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 |
| **Open Babel** `obabel -osvg/-opng` | C++/CLI | Quick depictions, many formats; style is dated |
| **OpenChemLib** | Java/JS | Actelion's toolkit; used by many web tools |
| **Ketcher** (EPAM) | JS | Full web editor; headless depiction via its API, molfile/rxnfile in/out |
| **ChemDoodle Web / Kekule.js / JSME / Marvin JS** | JS | Browser editors and renderers |
| **mol2chemfig + chemfig (LaTeX)** | TeX | Publication-quality; `chemfig` natively supports curly arrows (`\chemmove`) and `\schemestart ... \arrow` schemes. Needs a TeX install |
| **ChemDraw (PerkinElmer/Revvity)** | desktop | Industry standard; CDXML is readable (`rdkit` can read CDXML), scriptable only via its own JS/Apple events |
| **Marvin (ChemAxon)** `molconvert` | Java | High quality, commercial |
| **PyMOL / 3D tools** | – | 3D only; not for schemes |

**Name ↔ structure**

| Tool | Notes |
|---|---|
| OPSIN | Open-source IUPAC parser (Java); `py2opsin` wraps it. Best for systematic names |
| PubChem PUG-REST | Trivial names, drugs, abbreviations; network |
| NCI/CADD CIR | Fallback resolver; network |
| STOUT / ChemBERTa-style models | ML name→SMILES; not used |
| RDKit `MolFromSmiles` | SMILES/SMARTS/InChI/molfile/CDXML parsing |

**Reaction / mechanism representations**

| Representation | Notes |
|---|---|
| Reaction SMILES `A.B>agents>C` | Universal, used by RDKit/Indigo/CDK; atom maps `[C:1]` carry identity across steps |
| RXN / RDF / CML / CDXML | File formats for schemes; RDKit reads RXN and CDXML |
| Atom-mapped SMILES + arrow list | What this package uses for curly arrows: `{"species": [...], "arrows": ["1 -> 2-3"]}` |
| chemfig `\chemmove` | TeX way to draw electron-pushing arrows |
| Hand-drawn SVG overlays | What `curlyarrows.render.svg.curly_arrow` does over RDKit atom coordinates |

**Where this package sits:** RDKit for molecule depiction (fonts are converted
to paths, so SVGs are self-contained), its own SVG compositor for `+` signs,
reaction arrows (forward, equilibrium, resonance, retro), reagent/condition
text with sub/superscripts, row wrapping, titles and captions, and
quadratic-Bézier curly arrows (full head or fishhook) anchored on RDKit's atom
pixel coordinates. cairosvg turns the SVG into PNG/PDF.

## Layout of the code

```
src/curlyarrows/
  __init__.py         draw(), draw_svg(), parse() – public API (v1 inputs and v2 objects)
  graph.py            v2: Atom / Bond / Molecule (inference, RDKit bridge, layout)
  arrows.py           v2: Arrow, SceneGeom, candidate routing + scoring, arrow SVG
  scene.py            v2: Frame / Mechanism
  spec.py             Species / Reaction / Scheme / Mechanism / MechStep / CurlyArrow
  parse.py            natural language → spec
  resolve.py          name → SMILES (dictionary, OPSIN, PubChem, CIR, cache)
  names.py            the built-in dictionary
  guide.py            GUIDE prompt text + JSON schemas + tool definitions
  style.py            Style dataclass
  render/
    rdkit_backend.py  molecule → SVG body + atom pixel coords
    svg.py            Block compositor, arrows, text, curly arrows
    layout.py         v1 reactions, schemes, mechanisms
    graph_backend.py  v2 molecule -> SVG using the graph's own coordinates
    scene_layout.py   v2 frames and mechanisms
    raster.py         SVG → PNG/PDF
    indigo_backend.py optional Indigo renderer
  cli.py              `curlyarrows` command
  mcp_server.py       `curlyarrows serve`
examples/gallery.py     v1 examples;  examples/v2_gallery.py  v2 examples
tools/route_debug.py    visualise arrow routing candidates
tests/                pytest
```

## Known limits

* Prose parsing is heuristic. Agents should prefer the frames spec; the parser is for convenience.
* Routing is heuristic: four arrows on a four-membered ring or three around one atom are legible only
  after the automatic scale-up, and a lone pair attacking an atom one bond length away is a short
  S-curve. `side`/`bend` hints, a `rotate` on the species, or a larger `bond_length` fix the rest.
* Arrows anchor on atom stand-off circles and bond midpoints; a specific lone pair or π-face is not chosen.
* Routing is pure Python at roughly 0.1 s per arrow, fine for mechanisms, slow for hundreds of arrows.
* Stereochemistry wedges come from RDKit; relative (cis/trans) descriptors in
  names are only honoured when the resolver returns stereo-marked SMILES.
