Task: Support ring-bond stereo flags in CXSMILES (rdkit/rdkit#5595)

org: rdkit
repo: rdkit
pr_number: 5595
issue_title: Support ring-bond stereo flags in CXSMILES
issue_body: This adds `c:`, `t:`, and `ctu:` to the CXSMILES readers and writers. These only apply to ring bonds.

It also:
- updates the documentation to include the newly supported CXSMILES features and to document where stereo information comes from.
- Fixes #5585 
resolved_issues: number: 5585
title: incorrect _smilesBondOutputOrder for ring closures
body: **Describe the bug**
Here's the demo:
```
In [26]: m = Chem.MolFromSmiles("OC1CCCN1")

In [27]: Chem.MolToSmiles(m)
Out[27]: 'OC1CCCN1'

In [28]: m.GetProp("_smilesBondOutputOrder")
Out[28]: '[0,1,5,2,3,4,]'

In [29]: m.GetProp("_smilesAtomOutputOrder")
Out[29]: '[0,1,2,3,4,5,]'
```
Given that the output order is the same as the input order, the `_smilesBondOutputOrder` should be:
`[0,1,2,3,4,5]`

**Configuration (please complete the following information):**
 - RDKit version: all
 - OS: all


Reference solution:
fix_patch: diff --git a/Code/GraphMol/FindStereo.cpp b/Code/GraphMol/FindStereo.cpp
index 0ecadfb5a79..1d3caf24d9c 100644
--- a/Code/GraphMol/FindStereo.cpp
+++ b/Code/GraphMol/FindStereo.cpp
@@ -339,8 +339,9 @@ bool isBondPotentialStereoBond(const Bond *bond) {
     // check rings
     const auto ri = bond->getOwningMol().getRingInfo();
     for (const auto &bring : ri->bondRings()) {
-      if (bring.size() < 8 && std::find(bring.begin(), bring.end(),
-                                        bond->getIdx()) != bring.end()) {
+      if (bring.size() < minRingSizeForDoubleBondStereo &&
+          std::find(bring.begin(), bring.end(), bond->getIdx()) !=
+              bring.end()) {
         return false;
       }
     }
diff --git a/Code/GraphMol/SmilesParse/CXSmilesOps.cpp b/Code/GraphMol/SmilesParse/CXSmilesOps.cpp
index a75626b3edb..0cc249f347b 100644
--- a/Code/GraphMol/SmilesParse/CXSmilesOps.cpp
+++ b/Code/GraphMol/SmilesParse/CXSmilesOps.cpp
@@ -20,6 +20,7 @@
 #include "SmilesParse.h"
 #include "SmilesParseOps.h"
 #include <GraphMol/MolEnumerator/LinkNode.h>
+#include <GraphMol/Chirality.h>
 
 namespace SmilesParseOps {
 using namespace RDKit;
@@ -980,15 +981,14 @@ bool parse_variable_attachments(Iterator &first, Iterator last,
         return false;
       }
       if (VALID_ATIDX(aidx)) {
-        others.push_back(
-            (boost::format("%d") % (aidx - startAtomIdx + 1)).str());
+        others.push_back(std::to_string(aidx - startAtomIdx + 1));
       }
       if (first < last && *first == '.') {
         ++first;
       }
     }
     if (VALID_ATIDX(at1idx)) {
-      std::string endPts = (boost::format("(%d") % others.size()).str();
+      std::string endPts = "(" + std::to_string(others.size());
       for (auto idx : others) {
         endPts += " " + idx;
       }
@@ -1062,49 +1062,102 @@ bool parse_wedged_bonds(Iterator &first, Iterator last, RDKit::RWMol &mol,
       return false;
     }
 
-    if (!VALID_ATIDX(atomIdx)) {
-      BOOST_LOG(rdWarningLog)
-          << "bad atom index, " << atomIdx << ", in w block" << std::endl;
-      return false;
-    }
-    if (!VALID_BNDIDX(bondIdx)) {
-      BOOST_LOG(rdWarningLog)
-          << "bad bond index, " << bondIdx << ", in w block" << std::endl;
-      return false;
-    }
-    auto atom = mol.getAtomWithIdx(atomIdx + startAtomIdx);
-    auto bond = mol.getBondWithIdx(bondIdx + startBondIdx);
+    if (VALID_ATIDX(atomIdx) && VALID_BNDIDX(bondIdx)) {
+      auto atom = mol.getAtomWithIdx(atomIdx - startAtomIdx);
+      auto bond = mol.getBondWithIdx(bondIdx - startBondIdx);
 
-    // we can't set wedging twice:
-    if (bond->hasProp(common_properties::_MolFileBondCfg)) {
-      BOOST_LOG(rdWarningLog)
-          << "w block attempts to set wedging on bond " << bond->getIdx()
-          << " more than once." << std::endl;
-      return false;
-    }
-
-    // first things first, the atom needs to be the start atom of the bond for
-    // any of this to make sense
-    if (atom->getIdx() != bond->getBeginAtomIdx()) {
-      if (atom->getIdx() != bond->getEndAtomIdx()) {
+      // we can't set wedging twice:
+      if (bond->hasProp(common_properties::_MolFileBondCfg)) {
         BOOST_LOG(rdWarningLog)
-            << "atom " << atomIdx << " is not associated with bond " << bondIdx
-            << " in w block" << std::endl;
+            << "w block attempts to set wedging on bond " << bond->getIdx()
+            << " more than once." << std::endl;
         return false;
       }
-      auto eidx = bond->getBeginAtomIdx();
-      bond->setBeginAtomIdx(atom->getIdx());
-      bond->setEndAtomIdx(eidx);
+
+      // first things first, the atom needs to be the start atom of the bond for
+      // any of this to make sense
+      if (atom->getIdx() != bond->getBeginAtomIdx()) {
+        if (atom->getIdx() != bond->getEndAtomIdx()) {
+          BOOST_LOG(rdWarningLog)
+              << "atom " << atomIdx << " is not associated with bond "
+              << bondIdx << " in w block" << std::endl;
+          return false;
+        }
+        auto eidx = bond->getBeginAtomIdx();
+        bond->setBeginAtomIdx(atom->getIdx());
+        bond->setEndAtomIdx(eidx);
+      }
+      bond->setProp(common_properties::_MolFileBondCfg, cfg);
+      bond->setBondDir(state);
+      if (cfg == 2 && bond->getBondType() == Bond::BondType::SINGLE) {
+        bond->getBeginAtom()->setChiralTag(Atom::ChiralType::CHI_UNSPECIFIED);
+        mol.setProp(detail::_needsDetectBondStereo, 1);
+      }
+      if ((cfg == 1 || cfg == 3) &&
+          bond->getBondType() == Bond::BondType::SINGLE) {
+        mol.setProp(detail::_needsDetectAtomStereo, 1);
+      }
     }
-    bond->setProp(common_properties::_MolFileBondCfg, cfg);
-    bond->setBondDir(state);
-    if (cfg == 2 && bond->getBondType() == Bond::BondType::SINGLE) {
-      bond->getBeginAtom()->setChiralTag(Atom::ChiralType::CHI_UNSPECIFIED);
-      mol.setProp(detail::_needsDetectBondStereo, 1);
+    if (first < last && *first == ',') {
+      ++first;
     }
-    if ((cfg == 1 || cfg == 3) &&
-        bond->getBondType() == Bond::BondType::SINGLE) {
-      mol.setProp(detail::_needsDetectAtomStereo, 1);
+  }
+  return true;
+}
+
+template <typename Iterator>
+bool parse_doublebond_stereo(Iterator &first, Iterator last, RDKit::RWMol &mol,
+                             unsigned int, unsigned int startBondIdx,
+                             Bond::BondStereo stereo) {
+  // these look like: C1CCCC/C=C/CCC1 |ctu:5|
+  // also c and t for cis or trans
+  //
+  while (first < last && *first != ':') {
+    ++first;
+  }
+  if (first >= last || *first != ':') {
+    return false;
+  }
+  ++first;
+
+  while (first < last && *first >= '0' && *first <= '9') {
+    unsigned int bondIdx;
+    if (!read_int(first, last, bondIdx)) {
+      return false;
+    }
+    if (VALID_BNDIDX(bondIdx)) {
+      auto bond = mol.getBondWithIdx(bondIdx - startBondIdx);
+
+      // the cis/trans/unknown marker is relative to the lowest numbered atom
+      // connected to the lowest numbered double bond atom and the
+      // highest-numbered atom connected to the highest-numbered double bond
+      // atom find those
+      auto begAtom = bond->getBeginAtom();
+      auto endAtom = bond->getEndAtom();
+      if (begAtom->getIdx() > endAtom->getIdx()) {
+        std::swap(begAtom, endAtom);
+      }
+      if (begAtom->getDegree() > 1 && endAtom->getDegree() > 1) {
+        unsigned int begControl = mol.getNumAtoms();
+        for (auto nbr : mol.atomNeighbors(begAtom)) {
+          if (nbr == endAtom) {
+            continue;
+          }
+          begControl = std::min(nbr->getIdx(), begControl);
+        }
+        unsigned int endControl = 0;
+        for (auto nbr : mol.atomNeighbors(endAtom)) {
+          if (nbr == begAtom) {
+            continue;
+          }
+          endControl = std::max(nbr->getIdx(), endControl);
+        }
+        if (begAtom != bond->getBeginAtom()) {
+          std::swap(begControl, endControl);
+        }
+        bond->setStereoAtoms(begControl, endControl);
+        bond->setStereo(stereo);
+      }
     }
     if (first < last && *first == ',') {
       ++first;
@@ -1378,6 +1431,22 @@ bool parse_it(Iterator &first, Iterator last, RDKit::RWMol &mol,
       if (!parse_wedged_bonds(first, last, mol, startAtomIdx, startBondIdx)) {
         return false;
       }
+    } else if (*first == 'c' && first + 2 < last && first[1] == 't' &&
+               first[2] == 'u') {
+      if (!parse_doublebond_stereo(first, last, mol, startAtomIdx, startBondIdx,
+                                   Bond::BondStereo::STEREOANY)) {
+        return false;
+      }
+    } else if (*first == 'c') {
+      if (!parse_doublebond_stereo(first, last, mol, startAtomIdx, startBondIdx,
+                                   Bond::BondStereo::STEREOCIS)) {
+        return false;
+      }
+    } else if (*first == 't') {
+      if (!parse_doublebond_stereo(first, last, mol, startAtomIdx, startBondIdx,
+                                   Bond::BondStereo::STEREOTRANS)) {
+        return false;
+      }
     } else {
       ++first;
     }
@@ -1938,6 +2007,97 @@ std::string get_bond_config_block(const ROMol &mol,
   return w + wU + wD;
 }
 
+std::string get_ringbond_cistrans_block(
+    const ROMol &mol, const std::vector<unsigned int> &atomOrder,
+    const std::vector<unsigned int> &bondOrder) {
+  if (!mol.getRingInfo()->isInitialized()) {
+    return "";
+  }
+
+  const auto rinfo = mol.getRingInfo();
+  std::string c = "", t = "", ctu = "";
+  for (unsigned int i = 0; i < bondOrder.size(); ++i) {
+    auto idx = bondOrder[i];
+    if (!rinfo->numBondRings(idx) ||
+        rinfo->minBondRingSize(idx) <
+            Chirality::minRingSizeForDoubleBondStereo) {
+      // we only do ring bonds of a minimum size
+      continue;
+    }
+    const auto bond = mol.getBondWithIdx(idx);
+    if (bond->getBondType() != Bond::BondType::DOUBLE &&
+        bond->getBondType() != Bond::BondType::AROMATIC) {
+      continue;
+    }
+    Bond::BondStereo bstereo = bond->getStereo();
+    if (bstereo != Bond::BondStereo::STEREOANY &&
+        bstereo != Bond::BondStereo::STEREOCIS &&
+        bstereo != Bond::BondStereo::STEREOTRANS) {
+      continue;
+    }
+
+    auto label = std::to_string(i);
+
+    if (bstereo == Bond::BondStereo::STEREOANY) {
+      // this one's easy because we don't care about the atom order.
+      if (ctu.empty()) {
+        ctu += "ctu:";
+      } else {
+        ctu += ",";
+      }
+      ctu += label;
+    } else {
+      Atom *begAtom = bond->getBeginAtom();
+      Atom *endAtom = bond->getEndAtom();
+      bool needSwap = false;
+      if (begAtom->getDegree() > 2) {
+        unsigned int o1 = atomOrder[bond->getStereoAtoms()[0]];
+        for (const auto nbr : mol.atomNeighbors(begAtom)) {
+          if (nbr == endAtom ||
+              nbr->getIdx() ==
+                  static_cast<unsigned>(bond->getStereoAtoms()[0])) {
+            continue;
+          }
+          if (atomOrder[nbr->getIdx() < o1]) {
+            // this neighbor came first, we need to swap:
+            needSwap = !needSwap;
+          }
+        }
+      }
+      if (endAtom->getDegree() > 2) {
+        unsigned int o1 = atomOrder[bond->getStereoAtoms()[1]];
+        for (const auto nbr : mol.atomNeighbors(endAtom)) {
+          if (nbr == begAtom ||
+              nbr->getIdx() ==
+                  static_cast<unsigned>(bond->getStereoAtoms()[1])) {
+            continue;
+          }
+          if (atomOrder[nbr->getIdx() < o1]) {
+            // this neighbor came first, we need to swap:
+            needSwap = !needSwap;
+          }
+        }
+      }
+      if (bstereo == Bond::BondStereo::STEREOCIS || needSwap) {
+        if (c.empty()) {
+          c += "c:";
+        } else {
+          c += ",";
+        }
+        c += label;
+      } else {
+        if (t.empty()) {
+          t += "t:";
+        } else {
+          t += ",";
+        }
+        t += label;
+      }
+    }
+  }
+  return c + t + ctu;
+}
+
 std::string get_linknodes_block(const ROMol &mol,
                                 const std::vector<unsigned int> &atomOrder) {
   bool strict = false;
@@ -2046,6 +2206,9 @@ std::string getCXExtensions(const ROMol &mol, std::uint32_t flags) {
     const auto cfgblock =
         get_bond_config_block(mol, atomOrder, bondOrder, includeCoords);
     appendToCXExtension(cfgblock, res);
+    const auto cistransblock =
+        get_ringbond_cistrans_block(mol, atomOrder, bondOrder);
+    appendToCXExtension(cistransblock, res);
   }
 
   if (flags & SmilesWrite::CXSmilesFields::CX_LINKNODES) {
diff --git a/Code/GraphMol/SmilesParse/SmilesWrite.cpp b/Code/GraphMol/SmilesParse/SmilesWrite.cpp
index c670a37afc9..21e5e0b2bb9 100644
--- a/Code/GraphMol/SmilesParse/SmilesWrite.cpp
+++ b/Code/GraphMol/SmilesParse/SmilesWrite.cpp
@@ -360,7 +360,7 @@ std::string FragmentSmilesConstruct(
           ringClosureMap.erase(rclosure);
         }
         ringClosuresToErase.clear();
-        // std::cout<<"\t\tAtom: "<<mSE.obj.atom->getIdx()<<std::endl;
+        // std::cout << "\t\tAtom: " << mSE.obj.atom->getIdx() << std::endl;
         if (!atomSymbols) {
           res << GetAtomSmiles(mSE.obj.atom, params.doKekule, bond,
                                params.allHsExplicit, params.doIsomericSmiles);
@@ -371,7 +371,7 @@ std::string FragmentSmilesConstruct(
         break;
       case Canon::MOL_STACK_BOND:
         bond = mSE.obj.bond;
-        // std::cout<<"\t\tBond: "<<bond->getIdx()<<std::endl;
+        // std::cout << "\t\tBond: " << bond->getIdx() << std::endl;
         if (!bondSymbols) {
           res << GetBondSmiles(bond, mSE.number, params.doKekule,
                                params.allBondsExplicit);
@@ -382,7 +382,7 @@ std::string FragmentSmilesConstruct(
         break;
       case Canon::MOL_STACK_RING:
         ringIdx = mSE.number;
-        // std::cout<<"\t\tRing: "<<ringIdx;
+        // std::cout << "\t\tRing: " << ringIdx << std::endl;
         if (ringClosureMap.count(ringIdx)) {
           // the index is already in the map ->
           //   we're closing a ring, so grab
@@ -453,17 +453,19 @@ std::string MolToSmiles(const ROMol &mol, const SmilesWriteParams &params) {
       MolOps::getMolFrags(mol, false, nullptr, &fragsMolAtomMapping, false);
   // we got the mapping between fragments and atoms; repeat that for bonds
   std::vector<std::vector<int>> fragsMolBondMapping;
+  boost::dynamic_bitset<> atsPresent(mol.getNumAtoms());
+  std::vector<int> bondsInFrag;
+  bondsInFrag.reserve(mol.getNumBonds());
   for (const auto &atsInFrag : fragsMolAtomMapping) {
-    std::vector<int> bondsInFrag;
+    atsPresent.reset();
+    bondsInFrag.clear();
     for (auto aidx : atsInFrag) {
-      const auto atm = mol.getAtomWithIdx(aidx);
-      for (const auto bndIter :
-           boost::make_iterator_range(mol.getAtomBonds(atm))) {
-        const auto bnd = mol[bndIter];
-        if (std::find(bondsInFrag.begin(), bondsInFrag.end(), bnd->getIdx()) ==
-            bondsInFrag.end()) {
-          bondsInFrag.push_back(bnd->getIdx());
-        }
+      atsPresent.set(aidx);
+    }
+    for (const auto bnd : mol.bonds()) {
+      if (atsPresent[bnd->getBeginAtomIdx()] &&
+          atsPresent[bnd->getEndAtomIdx()]) {
+        bondsInFrag.push_back(bnd->getIdx());
       }
     }
     fragsMolBondMapping.push_back(bondsInFrag);
@@ -623,7 +625,7 @@ std::string MolToSmiles(const ROMol &mol, const SmilesWriteParams &params) {
   mol.setProp(common_properties::_smilesBondOutputOrder, flattenedBondOrdering,
               true);
   return result;
-}  // end of MolToSmiles()
+}
 
 std::string MolToCXSmiles(const ROMol &mol, const SmilesWriteParams &params,
                           std::uint32_t flags) {
diff --git a/Docs/Book/RDKit_Book.rst b/Docs/Book/RDKit_Book.rst
index 1a62184318e..bea560adedc 100644
--- a/Docs/Book/RDKit_Book.rst
+++ b/Docs/Book/RDKit_Book.rst
@@ -255,10 +255,13 @@ The features which are parsed include:
 - radicals ``^``
 - enhanced stereo (these are converted into ``StereoGroups``)
 - linknodes ``LN``
-- multi-center attachments ``m``
+- variable/multi-center attachments ``m``
 - ring bond count specifications ``rb``
 - non-hydrogen substitution count specifications ``s``
 - unsaturation specification ``u``
+- wedged bonds (only when atomic coordinates are present): ``wU``, ``wD``
+- wiggly bonds ``w``
+- double bond stereo (only for ring bonds) ``c``, ``t``, ``ctu``
 - SGroup Data ``SgD``
 - polymer SGroups ``Sg``
 - SGroup Hierarchy ``SgH``
@@ -273,7 +276,10 @@ The features which are written by :py:func:`rdkit.Chem.rdmolfiles.MolToCXSmiles`
 - atomic properties
 - radicals
 - enhanced stereo
-- linknodes 
+- linknodes
+- wedged bonds (only when atomic coordinates are also written) 
+- wiggly bonds
+- double bond stereo (only for ring bonds)
 - SGroup Data
 - polymer SGroups
 - SGroup Hierarchy
@@ -531,14 +537,15 @@ Stereochemistry
 Types of stereochemistry supported
 ----------------------------------
 
-The RDKit currently supports tetrahedral atomic stereochemistry and cis/trans
-stereochemistry at double bonds. We plan to add support for additional types of
-stereochemistry in the future.
+The RDKit currently fully supports tetrahedral atomic stereochemistry and
+cis/trans stereochemistry at double bonds. There is partial support for
+non-tetrahedral stereochemistry, see the section :ref:`Support for non-tetrahedral atomic stereochemistry`.
 
 Identification of potential stereoatoms/stereobonds
 ---------------------------------------------------
 
-As of the 2020.09 release the RDKit has two different ways of identifying potential stereoatoms/stereobonds:
+As of the 2020.09 release the RDKit has two different ways of identifying
+potential stereoatoms/stereobonds:
 
    1. The legacy approach: ``AssignStereochemistry()``.
       This approach does a reasonable job of recognizing potential
@@ -655,6 +662,54 @@ Brief description of the ``findPotentialStereo()`` algorithm
       ranked atoms attached to either end [#eitherend]_ to the results
    10. Return the results
 
+Sources of information about stereochemistry
+--------------------------------------------
+
+From SMILES
+^^^^^^^^^^^
+
+Atomic stereochemistry can be specified using ``@``, ``@@``, ``@SP``, etc.
+Potential stereocenters with no information provided are
+``ChiralType::CHI_UNSPECIFIED``.
+
+Double-bond stereochemistry is specfied using ``/`` and ``\`` to indicate the
+directionality of the neighboring single bonds. Double bonds with no stereo
+information provided are ``BondStereo::STEREONONE``. 
+
+
+From Mol
+^^^^^^^^
+
+Atomic stereochemistry can be specified using wedged bonds if 2D coordinates are
+present. If 3D coordinates are present, they are used to set the stereochemistry
+for stereogenic atoms. Wiggly bonds (``CFG=2`` in V3000 mol blocks) set the
+chiral tag of stereogenic start atom to ``ChiralType::CHI_UNSPECIFIED``.
+
+Double-bond stereochemistry is automatically set using the atomic coordinates;
+this is true for both 2D and 3D coordinates. If a stereogenic double bound is
+crossed (``CFG=2`` in V3000 mol blocks) or has an adjacent wiggly single bond
+(``CFG=2`` in V3000 mol blocks), then it will be ``BondStereo::STEREOANY``.
+
+
+From CXSMILES
+^^^^^^^^^^^^^
+
+An initial stereochemistry assignment is done following the SMILES rules (see above).
+
+A ``w:`` (wiggly bond) specification will set the stereochemistry of the start
+atom to ``ChiralType::CHI_UNSPECIFIED`` and double bonds to
+``BondStereo::STEREOANY``. Stereochemistry of ring bonds can be set using ``t``,
+``c``, or ``ctu``.
+
+If 2D coordinates are present in the CXSMILES, atomic stereo can be set using
+```wU``` or ```wD``` to create wedged bonds.
+
+If 3D coordinates are present in the CXSMILES, they are used to set the
+stereochemistry for stereogenic atoms and bonds. This supersedes other
+specifications in the CXSMILES except for ``ctu`` and ``w``.
+
+
+
 Support for non-tetrahedral atomic stereochemistry
 ==================================================
 
diff --git a/Docs/Book/conf.py b/Docs/Book/conf.py
index 053d07a6428..ba08c6a03a3 100644
--- a/Docs/Book/conf.py
+++ b/Docs/Book/conf.py
@@ -27,7 +27,9 @@
 
 # Add any Sphinx extension module names here, as strings. They can be extensions
 # coming with Sphinx (named 'sphinx.ext.*') or your custom ones.
-extensions = ['sphinx.ext.autodoc', 'sphinx.ext.doctest', 'recommonmark']  # , 'extapi']
+extensions = [
+  'sphinx.ext.autodoc', 'sphinx.ext.doctest', 'recommonmark', 'sphinx.ext.autosectionlabel'
+]  # , 'extapi']
 #autosummary_generate = True
 doctest_test_doctest_blocks = ""