Repository: UC-Davis-molecular-computing/scadnano-python-package (language: python)

Issue:
Rewrite doc test
doc test failed even though docs compiled just fine locally.

I suspect it is because the [action](https://github.com/ammaraskar/sphinx-action) we are using depends on an earlier version of Sphinx (2.4.4)

When I run
```
pip install -Iv sphinx==2.4.4
```
to installed that version, and run `make html` I get build error:
```
Running Sphinx v2.4.4

Extension error:
Event 'html-collect-pages' already present
Makefile:20: recipe for target 'html' failed
make: *** [html] Error 2
```

The action does not seem reliable as it has been more than a year since last update on the repo and it does not have a versioning to ensure stability going into the future. Probably time to revisit the doc check github action so that it doesn't rely on their action.

In any case, I'm sure the action is failing due to a bug in their github action and not our docs

_Originally posted by @UnHumbleBen in https://github.com/UC-Davis-molecular-computing/scadnano-python-package/issues/175#issuecomment-840373419_
ensure grid fields are of type Grid, not str or None
(Running the dev branch)

When reading, modifying, then writing a design, I found that the "grid" key in the scadnano file becomes mangled.

It appears that the problem is:
1. The grid value in the scadnano file is a string, without any "Grid." prefix.
2. When read with `Design.from_scadnano_file`, the grid value *remains* a string, rather than being interpreted as a Grid Enum.
3. When written, `str(grid)` is written with the first 5 characters removed, to remove "Grid.".  But as a string, instead of a Grid, it doesn't have the "Grid." prefix, resulting in either a blank string (with "none") or "e" (with "square"), which then crashes scadnano itself.

I'm a bit confused as to why it appears this is apparently not being caught by type checking, though it might have to do with the underlying Enum representation being a string.

I quickly wrote a modification to the writer, in the [fix-grid branch on my fork](https://github.com/UC-Davis-molecular-computing/scadnano-python-package/compare/dev...cgevans:fix-grid?expand=1), but I don't think this is actually the correct way to fix the problem.
add optional parameter suppress_indent to method Design.write_scadnano_file
On a large (~3000 strand) scadnano design, writing out a json/scadnano file becomes extremely slow as a result of `_SuppressableIndentEncoder` (assuming `suppress_indent=True`), taking up the vast majority of the time, and increasing the time to create the json string from around 1 second to over 20 seconds.  For example:

```
         5086971 function calls (4773439 primitive calls) in 21.794 seconds

   Ordered by: internal time

   ncalls  tottime  percall  cumtime  percall filename:lineno(function)
    23838   20.359    0.001   20.359    0.001 {method 'replace' of 'str' objects}
        1    0.197    0.197   20.866   20.866 scadnano.py:141(encode)
  1062464    0.164    0.000    0.554    0.000 scadnano.py:4141(<genexpr>)
  1825253    0.152    0.000    0.242    0.000 {built-in method builtins.isinstance}
    16600    0.149    0.000    0.153    0.000 scadnano.py:1474(to_json_serializable)
   201088    0.144    0.000    0.320    0.000 scadnano.py:2922(<listcomp>)
       64    0.089    0.001    0.643    0.010 {built-in method builtins.max}
   201088    0.070    0.000    0.390    0.000 scadnano.py:2920(bound_domains)
215316/119964    0.064    0.000    0.298    0.000 encoder.py:413(_iterencode)
    23839    0.050    0.000    0.050    0.000 encoder.py:204(iterencode)
   330260    0.048    0.000    0.048    0.000 {built-in method _abc._abc_instancecheck}
170638/119948    0.045    0.000    0.262    0.000 encoder.py:277(_iterencode_list)
239778/119964    0.045    0.000    0.279    0.000 encoder.py:333(_iterencode_dict)
   330260    0.043    0.000    0.090    0.000 abc.py:96(__instancecheck__)
  23839/1    0.031    0.000    0.311    0.311 encoder.py:182(encode)
```


Discussion / hints:

Fix patch:
diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml
index 21fd2c0e..bb6f08d1 100644
--- a/.github/workflows/docs-check.yml
+++ b/.github/workflows/docs-check.yml
@@ -5,9 +5,17 @@ jobs:
   docs:
     runs-on: ubuntu-latest
     steps:
-    - uses: actions/checkout@v1
-    - uses: ammaraskar/sphinx-action@master
+    - uses: actions/checkout@v2.3.4
+    - name: Set up Python
+      uses: actions/setup-python@v2
       with:
-        docs-folder: "doc/"
-        pre-build-command: "pip3 install sphinx_rtd_theme"
-        build-command: "sphinx-build -T -E -W -b html . _build"
+          python-version: '3.x'
+    - name: Install Sphinx
+      run: |
+        python -m pip install --upgrade pip
+        pip install sphinx sphinx_rtd_theme
+    - name: Move to docs folder and build
+      run: |
+        cd doc
+        pwd
+        sphinx-build -T -E -W -b html . _build
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index af187696..b4608d2a 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -3,7 +3,7 @@ name: "release"
 on:
   push:
     branches:
-      - "master"
+      - "main"
 
 jobs:
   release:
diff --git a/doc/conf.py b/doc/conf.py
index 1fe33f92..c95e88c2 100644
--- a/doc/conf.py
+++ b/doc/conf.py
@@ -59,7 +59,14 @@ def extract_version(filename: str):
 # 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']
+extensions = [
+    'sphinx.ext.autodoc',
+    'sphinx.ext.viewcode',
+    # 'sphinx.ext.napoleon',
+]
+
+# next line puts type of each function parameter next to description of parameter
+autodoc_typehints = "description"
 
 # Add any paths that contain templates here, relative to this directory.
 templates_path = ['_templates']
diff --git a/examples/1_staple_1_helix_origami_position_nondefault.py b/examples/1_staple_1_helix_origami_position_nondefault.py
index 962241c5..d5b55598 100644
--- a/examples/1_staple_1_helix_origami_position_nondefault.py
+++ b/examples/1_staple_1_helix_origami_position_nondefault.py
@@ -3,14 +3,16 @@
 
 def create_design() -> sc.Design:
     length = 9
+    pitch90_group = sc.HelixGroup(pitch=90)
     helices = [
-        sc.Helix(max_offset=length, major_ticks=[2, 5], position=sc.Position3D(x=1, y=2, z=3), pitch=90)]
+        sc.Helix(max_offset=length, major_ticks=[2, 5], position=sc.Position3D(x=1, y=2, z=3), group='pitch90')]
     stap_ss = sc.Domain(0, True, 0, length)
     scaf_ss = sc.Domain(0, False, 0, length)
     stap = sc.Strand([stap_ss])
     scaf = sc.Strand([scaf_ss], color=sc.default_scaffold_color)
     strands = [stap, scaf]
-    design = sc.Design(helices=helices, strands=strands, grid=sc.Grid.none)
+    design = sc.Design(helices=helices, groups={
+                       'pitch90': pitch90_group}, strands=strands)
     design.assign_dna(scaf, 'AACT' * (length // 4))
 
     return design
diff --git a/examples/4_helix_grid_none.py b/examples/4_helix_grid_none.py
index 7508ce83..b946eb50 100644
--- a/examples/4_helix_grid_none.py
+++ b/examples/4_helix_grid_none.py
@@ -4,10 +4,10 @@
 def create_design() -> sc.Design:
     length = 10
     helices = [
-        sc.Helix(max_offset=length, position=sc.Position3D(x=0, y=0, z=2.5), pitch=0, roll=0, yaw=0),
-        sc.Helix(max_offset=length, position=sc.Position3D(x=3, y=3, z=0), pitch=0, roll=0, yaw=0),
-        sc.Helix(max_offset=length, position=sc.Position3D(x=8, y=-3, z=0), pitch=0, roll=0, yaw=0),
-        sc.Helix(max_offset=length, position=sc.Position3D(x=11, y=1, z=0), pitch=0, roll=0, yaw=0),
+        sc.Helix(max_offset=length, position=sc.Position3D(x=0, y=0, z=2.5), roll=0),
+        sc.Helix(max_offset=length, position=sc.Position3D(x=3, y=3, z=0), roll=0),
+        sc.Helix(max_offset=length, position=sc.Position3D(x=8, y=-3, z=0), roll=0),
+        sc.Helix(max_offset=length, position=sc.Position3D(x=11, y=1, z=0), roll=0),
     ]
     stap_ss = sc.Domain(0, True, 0, length)
     scaf_ss = sc.Domain(0, False, 0, length)
diff --git a/examples/output_designs/1_staple_1_helix_origami_position_nondefault.sc b/examples/output_designs/1_staple_1_helix_origami_position_nondefault.sc
index b2367567..bddd964f 100644
--- a/examples/output_designs/1_staple_1_helix_origami_position_nondefault.sc
+++ b/examples/output_designs/1_staple_1_helix_origami_position_nondefault.sc
@@ -1,10 +1,16 @@
 {
-  "version": "0.15.0",
-  "grid": "none",
+  "version": "0.16.0",
+  "groups": {
+    "pitch90": {
+      "position": {"x": 0, "y": 0, "z": 0},
+      "pitch": 90,
+      "grid": "none"
+    }
+  },
   "helices": [
     {
+      "group": "pitch90",
       "position": {"x": 1, "y": 2, "z": 3},
-      "pitch": 90,
       "major_ticks": [2, 5]
     }
   ],
diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py
index 25641192..cd4cb996 100644
--- a/scadnano/scadnano.py
+++ b/scadnano/scadnano.py
@@ -1,6088 +1,6334 @@
-"""
-The :mod:`scadnano` Python module is a library for describing synthetic DNA nanostructures
-(e.g., DNA origami).
-Installation instructions are at the
-`GitHub repository <https://github.com/UC-Davis-molecular-computing/scadnano-python-package#installation>`_.
-
-The scadnano project is developed and maintained by the UC Davis Molecular Computing group.
-Note that `cadnano <https://cadnano.org>`_ is a separate project,
-developed and maintained by the `Douglas lab <https://bionano.ucsf.edu/>`_ at UCSF.
-
-This module is used to write Python scripts creating files readable
-by `scadnano <https://scadnano.org/>`_, a web application useful for displaying
-and manually editing synthetic DNA nanostructures.
-The purpose of this module is to help automate some of the task of creating DNA designs,
-as well as making large-scale changes to them that are easier to describe programmatically than
-to do by hand in scadnano.
-
-If you find scadnano useful in a scientific project, please cite its associated paper:
-
- | scadnano: A browser-based, scriptable tool for designing DNA nanostructures.
- | David Doty, Benjamin L Lee, and Tristan Stérin.
- | DNA 2020: *Proceedings of the 26th International Conference on DNA Computing and Molecular Programming*
- | [ `paper <https://doi.org/10.4230/LIPIcs.DNA.2020.9>`_ | `BibTeX <https://web.cs.ucdavis.edu/~doty/papers/scadnano.bib>`_ ]
-
-This document describes the API for the scadnano Python package,
-see the `repository <https://github.com/UC-Davis-molecular-computing/scadnano-python-package>`_
-for additional documentation, such as installation instructions.
-There is separate documentation for the
-`scadnano web interface <https://github.com/UC-Davis-molecular-computing/scadnano>`_.
-
-This library uses typing hints from the Python typing library.
-(https://docs.python.org/3/library/typing.html)
-Each function and method indicate intended types of the parameters.
-However, due to Python's design, these types are not enforced at runtime.
-It is suggested to use a static analysis tool such as that provided by an IDE such as PyCharm
-(https://www.jetbrains.com/pycharm/)
-to see warnings when the typing rules are violated. 
-Such warnings probably indicate an erroneous usage.
-
-Most of the classes in this module are Python dataclasses
-(https://docs.python.org/3/library/dataclasses.html)
-whose fields show up in the documentation.
-Their types are listed in parentheses after the name of the class;
-for example :any:`Color` has ``int`` fields :py:data:`Color.r`, :py:data:`Color.g`, :py:data:`Color.b`.
-In general it is safe to read these fields directly, but not to write to them directly.
-Setter methods (named ``set_<fieldname>``) are provided for fields where it makes sense to set it to another
-value than it had originally.
-However, due to Python naming conventions for dataclass fields and property setters,
-it is not straightforward to enforce that the fields cannot be written, 
-so the user must take care not to set them.
-"""
-
-# needed to use forward annotations: https://docs.python.org/3/whatsnew/3.7.html#whatsnew37-pep563
-# commented out for now to support Py3.6, which does not support this feature
-# from __future__ import annotations
-
-__version__ = "0.15.1"  # version line; WARNING: do not remove or change this line or comment
-
-import dataclasses
-from abc import abstractmethod, ABC, ABCMeta
-import json
-import enum
-import itertools
-import re
-from builtins import ValueError
-from dataclasses import dataclass, field, InitVar, replace
-from typing import Tuple, List, Iterable, Set, Dict, Union, Optional, FrozenSet, Type, cast, Any, \
-    TypeVar, Generic, Callable
-from collections import defaultdict, OrderedDict, Counter
-import sys
-import os.path
-
-default_scadnano_file_extension = 'sc'
-
-VStrands = Dict[int, Dict[str, Any]]
-
-
-def _pairwise(iterable: Iterable) -> Iterable:
-    """s -> (s0,s1), (s1,s2), (s2, s3), ..."""
-    a, b = itertools.tee(iterable)
-    next(b, None)
-    return zip(a, b)
-
-
-# for putting evaluated expressions in docstrings
-# https://stackoverflow.com/questions/10307696/how-to-put-a-variable-into-python-docstring
-def _docstring_parameter(*sub: Any, **kwargs: Any) -> Any:
-    def dec(obj: Any) -> Any:
-        obj.__doc__ = obj.__doc__.format(*sub, **kwargs)
-        return obj
-
-    return dec
-
-
-##############################################################################
-# JSON serialization
-# There are external libraries to handle JSON
-# in Python, but I want this to be a simple, single-file library, so we just
-# implement what we need below.
-
-
-class _JSONSerializable(ABC):
-
-    @abstractmethod
-    def to_json_serializable(self, suppress_indent: bool = True, **kwargs: Any) -> Any:
-        raise NotImplementedError()
-
-
-def _json_encode(obj: _JSONSerializable, suppress_indent: bool = True) -> str:
-    encoder = _SuppressableIndentEncoder if suppress_indent else json.JSONEncoder
-    serializable = obj.to_json_serializable(suppress_indent=suppress_indent)
-    return json.dumps(serializable, cls=encoder, indent=2)
-
-
-class NoIndent:
-    # Value wrapper. Placing a value in this will stop it from being indented when converting to JSON
-    # using _SuppressableIndentEncoder
-
-    def __init__(self, value: Any) -> None:
-        self.value = value
-
-
-class _SuppressableIndentEncoder(json.JSONEncoder):
-    def __init__(self, *args: Any, **kwargs: Any) -> None:
-        self.unique_id = 0
-        super(_SuppressableIndentEncoder, self).__init__(*args, **kwargs)
-        self.kwargs = dict(kwargs)
-        del self.kwargs['indent']
-        self._replacement_map: Dict[int, Any] = {}
-
-    def default(self, obj: Any) -> Any:
-        if isinstance(obj, NoIndent):
-            # key = uuid.uuid1().hex # this caused problems with Brython.
-            key = self.unique_id
-            self.unique_id += 1
-            self._replacement_map[key] = json.dumps(obj.value, **self.kwargs)
-            return f"@@{key}@@"
-        else:
-            return super().default(obj)
-
-    def encode(self, obj: Any) -> Any:
-        result = super().encode(obj)
-        for k, v in self._replacement_map.items():
-            result = result.replace(f'"@@{k}@@"', v)
-        return result
-
-
-#
-# END JSON serialization
-##############################################################################
-
-
-##############################################################################
-# Colors
-# As with JSON serialization, there are external libraries to handle colors
-# in Python, but I want this to be a simple, single-file library, so we just
-# implement what we need below.
-
-@dataclass
-class Color(_JSONSerializable):
-    r: Optional[int] = None
-    """
-    Red component: 0-255.
-    
-    Optional if :py:data:`Color.hex` is given."""
-
-    g: Optional[int] = None
-    """Green component: 0-255.
-    
-    Optional if :py:data:`Color.hex` is given."""
-
-    b: Optional[int] = None
-    """Blue component: 0-255.
-    
-    Optional if :py:data:`Color.hex` is given."""
-
-    hex_string: InitVar[str] = None
-    """Hex color preceded by # sign, e.g., "#ff0000" is red.
-    
-    Optional if :py:data:`Color.r`, :py:data:`Color.g`, :py:data:`Color.b` are all given."""
-
-    def __post_init__(self, hex_string: str) -> None:
-        if hex_string is None:
-            assert (self.r is not None and self.g is not None and self.b is not None)
-        else:
-            assert (self.r is None and self.g is None and self.b is None)
-            hex_string = hex_string.lstrip('#')
-            self.r = int(hex_string[0:2], 16)
-            self.g = int(hex_string[2:4], 16)
-            self.b = int(hex_string[4:6], 16)
-
-    def to_json_serializable(self, suppress_indent: bool = True, **kwargs: Any) -> str:
-        # Return object representing this Color that is JSON serializable.
-        # return NoIndent(self.__dict__) if suppress_indent else self.__dict__
-        return f'#{self.r:02x}{self.g:02x}{self.b:02x}'
-
-    def to_cadnano_v2_int_hex(self) -> int:
-        return int(f'{self.r:02x}{self.g:02x}{self.b:02x}', 16)
-
-    @classmethod
-    def from_cadnano_v2_int_hex(cls, hex_int: int) -> 'Color':
-        hex_str = "0x{:06x}".format(hex_int)
-        return Color(hex_string=hex_str[2:])
-
-
-# https://medium.com/@rjurney/kellys-22-colours-of-maximum-contrast-58edb70c90d1
-_kelly_colors = [  # 'F2F3F4', #almost white so it's no good
-    '222222', 'F3C300', '875692', 'F38400', 'A1CAF1', 'BE0032', 'C2B280', '848482',
-    '008856', 'E68FAC', '0067A5', 'F99379', '604E97', 'F6A600', 'B3446C', 'DCD300', '882D17',
-    '8DB600', '654522', 'E25822', '2B3D26']
-
-
-class ColorCycler:
-    """
-    Calling ``next(color_cycler)`` on a ColorCycler named ``color_cycler``
-    returns a the next :any:`Color` from a fixed size list,
-    cycling after reaching the end of the list.
-
-    To choose new colors, set ``color_cycler.colors`` to a new list of :any:`Color`'s.
-    """
-
-    # These are copied from cadnano:
-    # https://github.com/sdouglas/cadnano2/blob/master/views/styles.py#L97
-    _colors: List[Color] = [Color(50, 184, 108),
-                            Color(204, 0, 0),
-                            Color(247, 67, 8),
-                            Color(247, 147, 30),
-                            Color(170, 170, 0),
-                            Color(87, 187, 0),
-                            Color(0, 114, 0),
-                            Color(3, 182, 162),
-                            # Color(23, 0, 222), # don't like this because it looks too much like scaffold
-                            Color(50, 0, 150),  # this one is better contrast with scaffold
-                            Color(184, 5, 108),
-                            Color(51, 51, 51),
-                            Color(115, 0, 222),
-                            Color(136, 136, 136)]
-    """List of colors to cycle through."""
-
-    # _colors = [Color(hex_string=kelly_color) for kelly_color in _kelly_colors]
-    # """List of colors to cycle through."""
-
-    def __init__(self) -> None:
-        self._current_color_idx = 0
-        # random order
-        order = [3, 11, 0, 12, 8, 1, 10, 6, 5, 9, 4, 7, 2]
-        # order = range(len(self._colors))
-        colors_shuffled: List[Color] = list(self._colors)
-        for i, color in zip(order, self._colors):
-            colors_shuffled[i] = color
-        self._colors: List[Color] = colors_shuffled
-
-    def __iter__(self) -> 'ColorCycler':
-        # need to make ColorCycler an iterator
-        return self
-
-    def __next__(self) -> Color:
-        color = self.current_color()
-        self._current_color_idx = (self._current_color_idx + 1) % len(self._colors)
-        return color
-
-    def current_color(self) -> Color:
-        return self._colors[self._current_color_idx]
-
-    def __hash__(self) -> int:
-        return hash(self.current_color())
-
-    def __eq__(self, other: Any) -> bool:
-        if not isinstance(other, ColorCycler):
-            return False
-        return self._current_color_idx == other._current_color_idx
-
-    def __str__(self) -> str:
-        return repr(self)
-
-    def __repr__(self) -> str:
-        return f'ColorCycler({self.current_color()})'
-
-    @property
-    def colors(self) -> List[Color]:
-        """The colors that are cycled through when calling ``next()`` on some :any:`ColorCycler`."""
-        return list(self._colors)
-
-    @colors.setter
-    def colors(self, newcolors: Iterable[Color]) -> None:
-        self._colors = list(newcolors)
-        self._current_color_idx = 0
-
-
-default_scaffold_color = Color(0, 102, 204)
-"""Default color for scaffold strand(s)."""
-
-default_strand_color = Color(0, 0, 0)
-"""Default color for non-scaffold strand(s)."""
-
-default_cadnano_strand_color = Color(hex_string='#BFBFBF')
-
-
-#
-# END Colors
-##############################################################################
-
-
-@enum.unique
-class Grid(str, enum.Enum):
-    """
-    Represents default patterns for laying out helices in the side view.
-    Each :any:`Grid` except :py:data:`Grid.none` has an interpretation of a "grid position",
-    which is a 2D integer coordinate (`h`, `v`).
-    (scadnano also allows a 3rd coordinate (`h`, `v`, `b`) specifying a "base offset" at which to position
-    the start of the :any:`Helix`, which is not relevant for the side view but will eventually be
-    supported to adjust the main view.)
-    """
-
-    square = "square"
-    """
-    Square lattice. 
-    Increasing `h` moves right and increasing `v` moves down. 
-    (i.e., "computer screen coordinates" rather than Cartesian coordinates where positive `y` is up.)
-    """
-
-    hex = "hex"
-    """
-    Hexagonal lattice. Uses the *"odd-q horizontal layout"* coordinate system described here: 
-    https://www.redblobgames.com/grids/hexagons/. 
-    Incrementing `v` moves down.
-    Incrementing `h` moves down and to the right if `h` is even, 
-    and moves up and to the right if `h` is odd.
-    """
-
-    honeycomb = "honeycomb"
-    """
-    Honeycomb lattice. This consists of all the hex lattice positions except where 
-    honeycomb lattice disallows grid positions (`h`, `v`) with 
-    `v` even and `h` a multiple of 3 or
-    `v` odd and `h` = 1 + a multiple of 3.  
-    
-    However, we use the same convention as cadnano for encoding hex coordinates see `misc/cadnano-format-specs/v2.txt`.
-    That convention is different from simply excluding coordinates from the hex lattice.
-
-    """
-
-    none = "none"
-    """No fixed grid."""
-
-
-# convenience names for users
-square = Grid.square
-hexagonal = Grid.hex  # should not use identifier "hex" because that's a Python built-in function
-honeycomb = Grid.honeycomb
-
-##########################################################################
-# constants
-
-default_idt_scale = "25nm"
-default_idt_purification = "STD"
-
-
-def default_major_tick_distance(grid: Grid) -> int:
-    return 7 if grid in (Grid.hex, Grid.honeycomb) else 8
-
-
-default_pitch: float = 0.0
-default_roll: float = 0.0
-default_yaw: float = 0.0
-
-default_group_name = 'default_group'
-
-# XXX: code below related to SVG positions is not currently needed in the scripting library,
-# but I want to make sure these conventions are documented somewhere, so they are just commented out for now.
-#
-# base_width_svg: float = 10.0
-# """Width of a single base in the SVG main view of scadnano."""
-#
-# base_height_svg: float = 10.0
-# """Height of a single base in the SVG main view of scadnano."""
-#
-# distance_between_helices_nm: float = 2.5
-# """Distance between centers of helices in nanometers.
-# See :py:data:`distance_between_helices_svg` for explanation of this value."""
-#
-# base_width_nm: float = 0.34
-# """Width of a single DNA base in nanometers."""
-#
-# distance_between_helices_svg: float = base_width_svg * distance_between_helices_nm / base_width_nm
-# """Distance between tops of two consecutive helices (using default positioning rules).
-#
-# This is set to (:const:`base_width_svg` * 2.5/0.34) based on the following calculation,
-# to attempt to make the DNA structure appear to scale in 2D drawings:
-# The width of one base pair of double-stranded DNA bp is 0.34 nm. In a DNA origami,
-# AFM images let us estimate that the average distance between adjacent double helices is 2.5 nm.
-# (A DNA double-helix is only 2 nm wide, but the helices electrostatically repel each other so the spacing
-# in a DNA origami or an other DNA nanostructure with many parallel DNA helices---e.g., single-stranded tile
-# lattices---is larger than 2 nm.)
-# Thus the distance between the helices is 2.5/0.34 ~ 7.5 times the width of a single DNA base.
-# """
-
-DNA_base_wildcard: str = '?'
-"""Symbol to insert when a DNA sequence has been assigned to a strand through complementarity, but
-some regions of the strand are not bound to the strand that was just assigned. Also used in case the
-DNA sequence assigned to a strand is too short; the sequence is padded with :any:`DNA_base_wildcard` to 
-make its length the same as the length of the strand."""
-
-
-def _rotate_string(string: str, rotation: int) -> str:
-    rotation = rotation % len(string)
-    return string[rotation:] + string[:rotation]
-
-
-class M13Variant(enum.Enum):
-    """Variants of M13mp18 viral genome. "Standard" variant is p7249. Other variants are longer."""
-
-    p7249 = "p7249"
-    """"Standard" variant of M13mp18; 7249 bases long, available from, for example
-    
-    https://www.tilibit.com/collections/scaffold-dna/products/single-stranded-scaffold-dna-type-p7249
-    
-    https://www.neb.com/products/n4040-m13mp18-single-stranded-dna
-    
-    http://www.bayoubiolabs.com/biochemicat/vectors/pUCM13/
-    """
-
-    p7560 = "p7560"
-    """Variant of M13mp18 that is 7560 bases long. Available from, for example
-    
-    https://www.tilibit.com/collections/scaffold-dna/products/single-stranded-scaffold-dna-type-p7560
-    """
-
-    p8064 = "p8064"
-    """Variant of M13mp18 that is 8064 bases long. Available from, for example
-    
-    https://www.tilibit.com/collections/scaffold-dna/products/single-stranded-scaffold-dna-type-p8064
-    """
-
-
-def m13(rotation: int = 5587, variant: M13Variant = M13Variant.p7249) -> str:
-    """
-    The M13mp18 DNA sequence (commonly called simply M13).
-    
-    By default, starts from cyclic rotation 5587 
-    (with 0-based indexing;  commonly this is called rotation 5588, which assumes that indexing begins at 1), 
-    as defined in
-    `GenBank <https://www.ncbi.nlm.nih.gov/nuccore/X02513.1>`_.
-    
-    By default, returns the "standard" variant of consisting of 7249 bases, sold by companies such as  
-    `Tilibit <https://cdn.shopify.com/s/files/1/1299/5863/files/Product_Sheet_single-stranded_scaffold_DNA_type_7249_M1-10.pdf?14656642867652657391>`_.
-    and
-    `New England Biolabs <https://www.neb.com/~/media/nebus/page%20images/tools%20and%20resources/interactive%20tools/dna%20sequences%20and%20maps/m13mp18_map.pdf>`_
-    
-    The actual M13 DNA strand itself is circular, 
-    so assigning this sequence to the scaffold :any:`Strand` in a :any:`Design`
-    means that the "5' end" of the scaffold :any:`Strand` 
-    (which is a fiction since the actual circular DNA strand has no endpoint) 
-    will have the sequence starting at position 5587 starting at the displayed 5' in scadnano,
-    assigned until the displayed 3' end. 
-    Assuming the displayed scaffold :any:`Strand` has length :math:`n < 7249`, then a loopout of length 
-    :math:`7249 - n` consisting of the undisplayed bases will be present in the actual DNA structure.
-    For a more detailed discussion of why this particular rotation of M13 is chosen,
-    see 
-    `Supplementary Note S8 <http://www.dna.caltech.edu/Papers/DNAorigami-supp1.linux.pdf>`_ 
-    in
-    [`Folding DNA to create nanoscale shapes and patterns. Paul W. K. Rothemund, Nature 440:297-302 (2006) <http://www.nature.com/nature/journal/v440/n7082/abs/nature04586.html>`_].
-    
-    :param rotation: rotation of circular strand. Valid values are 0 through length-1.
-    :param variant: variant of M13 strand to use
-    :return: M13 strand sequence
-    """  # noqa (suppress PEP warning)
-    seq = _m13_variants[variant]
-    return _rotate_string(seq, rotation)
-
-
-_7249 = re.sub(r'\s', '', '''
-AATGCTACTACTATTAGTAGAATTGATGCCACCTTTTCAGCTCGCGCCCCAAATGAAAATATAGCTAAACAGGTTATTGACCATTTGCGAAATGTATCTA
-ATGGTCAAACTAAATCTACTCGTTCGCAGAATTGGGAATCAACTGTTATATGGAATGAAACTTCCAGACACCGTACTTTAGTTGCATATTTAAAACATGT
-TGAGCTACAGCATTATATTCAGCAATTAAGCTCTAAGCCATCCGCAAAAATGACCTCTTATCAAAAGGAGCAATTAAAGGTACTCTCTAATCCTGACCTG
-TTGGAGTTTGCTTCCGGTCTGGTTCGCTTTGAAGCTCGAATTAAAACGCGATATTTGAAGTCTTTCGGGCTTCCTCTTAATCTTTTTGATGCAATCCGCT
-TTGCTTCTGACTATAATAGTCAGGGTAAAGACCTGATTTTTGATTTATGGTCATTCTCGTTTTCTGAACTGTTTAAAGCATTTGAGGGGGATTCAATGAA
-TATTTATGACGATTCCGCAGTATTGGACGCTATCCAGTCTAAACATTTTACTATTACCCCCTCTGGCAAAACTTCTTTTGCAAAAGCCTCTCGCTATTTT
-GGTTTTTATCGTCGTCTGGTAAACGAGGGTTATGATAGTGTTGCTCTTACTATGCCTCGTAATTCCTTTTGGCGTTATGTATCTGCATTAGTTGAATGTG
-GTATTCCTAAATCTCAACTGATGAATCTTTCTACCTGTAATAATGTTGTTCCGTTAGTTCGTTTTATTAACGTAGATTTTTCTTCCCAACGTCCTGACTG
-GTATAATGAGCCAGTTCTTAAAATCGCATAAGGTAATTCACAATGATTAAAGTTGAAATTAAACCATCTCAAGCCCAATTTACTACTCGTTCTGGTGTTT
-CTCGTCAGGGCAAGCCTTATTCACTGAATGAGCAGCTTTGTTACGTTGATTTGGGTAATGAATATCCGGTTCTTGTCAAGATTACTCTTGATGAAGGTCA
-GCCAGCCTATGCGCCTGGTCTGTACACCGTTCATCTGTCCTCTTTCAAAGTTGGTCAGTTCGGTTCCCTTATGATTGACCGTCTGCGCCTCGTTCCGGCT
-AAGTAACATGGAGCAGGTCGCGGATTTCGACACAATTTATCAGGCGATGATACAAATCTCCGTTGTACTTTGTTTCGCGCTTGGTATAATCGCTGGGGGT
-CAAAGATGAGTGTTTTAGTGTATTCTTTTGCCTCTTTCGTTTTAGGTTGGTGCCTTCGTAGTGGCATTACGTATTTTACCCGTTTAATGGAAACTTCCTC
-ATGAAAAAGTCTTTAGTCCTCAAAGCCTCTGTAGCCGTTGCTACCCTCGTTCCGATGCTGTCTTTCGCTGCTGAGGGTGACGATCCCGCAAAAGCGGCCT
-TTAACTCCCTGCAAGCCTCAGCGACCGAATATATCGGTTATGCGTGGGCGATGGTTGTTGTCATTGTCGGCGCAACTATCGGTATCAAGCTGTTTAAGAA
-ATTCACCTCGAAAGCAAGCTGATAAACCGATACAATTAAAGGCTCCTTTTGGAGCCTTTTTTTTGGAGATTTTCAACGTGAAAAAATTATTATTCGCAAT
-TCCTTTAGTTGTTCCTTTCTATTCTCACTCCGCTGAAACTGTTGAAAGTTGTTTAGCAAAATCCCATACAGAAAATTCATTTACTAACGTCTGGAAAGAC
-GACAAAACTTTAGATCGTTACGCTAACTATGAGGGCTGTCTGTGGAATGCTACAGGCGTTGTAGTTTGTACTGGTGACGAAACTCAGTGTTACGGTACAT
-GGGTTCCTATTGGGCTTGCTATCCCTGAAAATGAGGGTGGTGGCTCTGAGGGTGGCGGTTCTGAGGGTGGCGGTTCTGAGGGTGGCGGTACTAAACCTCC
-TGAGTACGGTGATACACCTATTCCGGGCTATACTTATATCAACCCTCTCGACGGCACTTATCCGCCTGGTACTGAGCAAAACCCCGCTAATCCTAATCCT
-TCTCTTGAGGAGTCTCAGCCTCTTAATACTTTCATGTTTCAGAATAATAGGTTCCGAAATAGGCAGGGGGCATTAACTGTTTATACGGGCACTGTTACTC
-AAGGCACTGACCCCGTTAAAACTTATTACCAGTACACTCCTGTATCATCAAAAGCCATGTATGACGCTTACTGGAACGGTAAATTCAGAGACTGCGCTTT
-CCATTCTGGCTTTAATGAGGATTTATTTGTTTGTGAATATCAAGGCCAATCGTCTGACCTGCCTCAACCTCCTGTCAATGCTGGCGGCGGCTCTGGTGGT
-GGTTCTGGTGGCGGCTCTGAGGGTGGTGGCTCTGAGGGTGGCGGTTCTGAGGGTGGCGGCTCTGAGGGAGGCGGTTCCGGTGGTGGCTCTGGTTCCGGTG
-ATTTTGATTATGAAAAGATGGCAAACGCTAATAAGGGGGCTATGACCGAAAATGCCGATGAAAACGCGCTACAGTCTGACGCTAAAGGCAAACTTGATTC
-TGTCGCTACTGATTACGGTGCTGCTATCGATGGTTTCATTGGTGACGTTTCCGGCCTTGCTAATGGTAATGGTGCTACTGGTGATTTTGCTGGCTCTAAT
-TCCCAAATGGCTCAAGTCGGTGACGGTGATAATTCACCTTTAATGAATAATTTCCGTCAATATTTACCTTCCCTCCCTCAATCGGTTGAATGTCGCCCTT
-TTGTCTTTGGCGCTGGTAAACCATATGAATTTTCTATTGATTGTGACAAAATAAACTTATTCCGTGGTGTCTTTGCGTTTCTTTTATATGTTGCCACCTT
-TATGTATGTATTTTCTACGTTTGCTAACATACTGCGTAATAAGGAGTCTTAATCATGCCAGTTCTTTTGGGTATTCCGTTATTATTGCGTTTCCTCGGTT
-TCCTTCTGGTAACTTTGTTCGGCTATCTGCTTACTTTTCTTAAAAAGGGCTTCGGTAAGATAGCTATTGCTATTTCATTGTTTCTTGCTCTTATTATTGG
-GCTTAACTCAATTCTTGTGGGTTATCTCTCTGATATTAGCGCTCAATTACCCTCTGACTTTGTTCAGGGTGTTCAGTTAATTCTCCCGTCTAATGCGCTT
-CCCTGTTTTTATGTTATTCTCTCTGTAAAGGCTGCTATTTTCATTTTTGACGTTAAACAAAAAATCGTTTCTTATTTGGATTGGGATAAATAATATGGCT
-GTTTATTTTGTAACTGGCAAATTAGGCTCTGGAAAGACGCTCGTTAGCGTTGGTAAGATTCAGGATAAAATTGTAGCTGGGTGCAAAATAGCAACTAATC
-TTGATTTAAGGCTTCAAAACCTCCCGCAAGTCGGGAGGTTCGCTAAAACGCCTCGCGTTCTTAGAATACCGGATAAGCCTTCTATATCTGATTTGCTTGC
-TATTGGGCGCGGTAATGATTCCTACGATGAAAATAAAAACGGCTTGCTTGTTCTCGATGAGTGCGGTACTTGGTTTAATACCCGTTCTTGGAATGATAAG
-GAAAGACAGCCGATTATTGATTGGTTTCTACATGCTCGTAAATTAGGATGGGATATTATTTTTCTTGTTCAGGACTTATCTATTGTTGATAAACAGGCGC
-GTTCTGCATTAGCTGAACATGTTGTTTATTGTCGTCGTCTGGACAGAATTACTTTACCTTTTGTCGGTACTTTATATTCTCTTATTACTGGCTCGAAAAT
-GCCTCTGCCTAAATTACATGTTGGCGTTGTTAAATATGGCGATTCTCAATTAAGCCCTACTGTTGAGCGTTGGCTTTATACTGGTAAGAATTTGTATAAC
-GCATATGATACTAAACAGGCTTTTTCTAGTAATTATGATTCCGGTGTTTATTCTTATTTAACGCCTTATTTATCACACGGTCGGTATTTCAAACCATTAA
-ATTTAGGTCAGAAGATGAAATTAACTAAAATATATTTGAAAAAGTTTTCTCGCGTTCTTTGTCTTGCGATTGGATTTGCATCAGCATTTACATATAGTTA
-TATAACCCAACCTAAGCCGGAGGTTAAAAAGGTAGTCTCTCAGACCTATGATTTTGATAAATTCACTATTGACTCTTCTCAGCGTCTTAATCTAAGCTAT
-CGCTATGTTTTCAAGGATTCTAAGGGAAAATTAATTAATAGCGACGATTTACAGAAGCAAGGTTATTCACTCACATATATTGATTTATGTACTGTTTCCA
-TTAAAAAAGGTAATTCAAATGAAATTGTTAAATGTAATTAATTTTGTTTTCTTGATGTTTGTTTCATCATCTTCTTTTGCTCAGGTAATTGAAATGAATA
-ATTCGCCTCTGCGCGATTTTGTAACTTGGTATTCAAAGCAATCAGGCGAATCCGTTATTGTTTCTCCCGATGTAAAAGGTACTGTTACTGTATATTCATC
-TGACGTTAAACCTGAAAATCTACGCAATTTCTTTATTTCTGTTTTACGTGCAAATAATTTTGATATGGTAGGTTCTAACCCTTCCATTATTCAGAAGTAT
-AATCCAAACAATCAGGATTATATTGATGAATTGCCATCATCTGATAATCAGGAATATGATGATAATTCCGCTCCTTCTGGTGGTTTCTTTGTTCCGCAAA
-ATGATAATGTTACTCAAACTTTTAAAATTAATAACGTTCGGGCAAAGGATTTAATACGAGTTGTCGAATTGTTTGTAAAGTCTAATACTTCTAAATCCTC
-AAATGTATTATCTATTGACGGCTCTAATCTATTAGTTGTTAGTGCTCCTAAAGATATTTTAGATAACCTTCCTCAATTCCTTTCAACTGTTGATTTGCCA
-ACTGACCAGATATTGATTGAGGGTTTGATATTTGAGGTTCAGCAAGGTGATGCTTTAGATTTTTCATTTGCTGCTGGCTCTCAGCGTGGCACTGTTGCAG
-GCGGTGTTAATACTGACCGCCTCACCTCTGTTTTATCTTCTGCTGGTGGTTCGTTCGGTATTTTTAATGGCGATGTTTTAGGGCTATCAGTTCGCGCATT
-AAAGACTAATAGCCATTCAAAAATATTGTCTGTGCCACGTATTCTTACGCTTTCAGGTCAGAAGGGTTCTATCTCTGTTGGCCAGAATGTCCCTTTTATT
-ACTGGTCGTGTGACTGGTGAATCTGCCAATGTAAATAATCCATTTCAGACGATTGAGCGTCAAAATGTAGGTATTTCCATGAGCGTTTTTCCTGTTGCAA
-TGGCTGGCGGTAATATTGTTCTGGATATTACCAGCAAGGCCGATAGTTTGAGTTCTTCTACTCAGGCAAGTGATGTTATTACTAATCAAAGAAGTATTGC
-TACAACGGTTAATTTGCGTGATGGACAGACTCTTTTACTCGGTGGCCTCACTGATTATAAAAACACTTCTCAGGATTCTGGCGTACCGTTCCTGTCTAAA
-ATCCCTTTAATCGGCCTCCTGTTTAGCTCCCGCTCTGATTCTAACGAGGAAAGCACGTTATACGTGCTCGTCAAAGCAACCATAGTACGCGCCCTGTAGC
-GGCGCATTAAGCGCGGCGGGTGTGGTGGTTACGCGCAGCGTGACCGCTACACTTGCCAGCGCCCTAGCGCCCGCTCCTTTCGCTTTCTTCCCTTCCTTTC
-TCGCCACGTTCGCCGGCTTTCCCCGTCAAGCTCTAAATCGGGGGCTCCCTTTAGGGTTCCGATTTAGTGCTTTACGGCACCTCGACCCCAAAAAACTTGA
-TTTGGGTGATGGTTCACGTAGTGGGCCATCGCCCTGATAGACGGTTTTTCGCCCTTTGACGTTGGAGTCCACGTTCTTTAATAGTGGACTCTTGTTCCAA
-ACTGGAACAACACTCAACCCTATCTCGGGCTATTCTTTTGATTTATAAGGGATTTTGCCGATTTCGGAACCACCATCAAACAGGATTTTCGCCTGCTGGG
-GCAAACCAGCGTGGACCGCTTGCTGCAACTCTCTCAGGGCCAGGCGGTGAAGGGCAATCAGCTGTTGCCCGTCTCACTGGTGAAAAGAAAAACCACCCTG
-GCGCCCAATACGCAAACCGCCTCTCCCCGCGCGTTGGCCGATTCATTAATGCAGCTGGCACGACAGGTTTCCCGACTGGAAAGCGGGCAGTGAGCGCAAC
-GCAATTAATGTGAGTTAGCTCACTCATTAGGCACCCCAGGCTTTACACTTTATGCTTCCGGCTCGTATGTTGTGTGGAATTGTGAGCGGATAACAATTTC
-ACACAGGAAACAGCTATGACCATGATTACGAATTCGAGCTCGGTACCCGGGGATCCTCTAGAGTCGACCTGCAGGCATGCAAGCTTGGCACTGGCCGTCG
-TTTTACAACGTCGTGACTGGGAAAACCCTGGCGTTACCCAACTTAATCGCCTTGCAGCACATCCCCCTTTCGCCAGCTGGCGTAATAGCGAAGAGGCCCG
-CACCGATCGCCCTTCCCAACAGTTGCGCAGCCTGAATGGCGAATGGCGCTTTGCCTGGTTTCCGGCACCAGAAGCGGTGCCGGAAAGCTGGCTGGAGTGC
-GATCTTCCTGAGGCCGATACTGTCGTCGTCCCCTCAAACTGGCAGATGCACGGTTACGATGCGCCCATCTACACCAACGTGACCTATCCCATTACGGTCA
-ATCCGCCGTTTGTTCCCACGGAGAATCCGACGGGTTGTTACTCGCTCACATTTAATGTTGATGAAAGCTGGCTACAGGAAGGCCAGACGCGAATTATTTT
-TGATGGCGTTCCTATTGGTTAAAAAATGAGCTGATTTAACAAAAATTTAATGCGAATTTTAACAAAATATTAACGTTTACAATTTAAATATTTGCTTATA
-CAATCTTCCTGTTTTTGGGGCTTTTCTGATTATCAACCGGGGTACATATGATTGACATGCTAGTTTTACGATTACCGTTCATCGATTCTCTTGTTTGCTC
-CAGACTCTCAGGCAATGACCTGATAGCCTTTGTAGATCTCTCAAAAATAGCTACCCTCTCCGGCATTAATTTATCAGCTAGAACGGTTGAATATCATATT
-GATGGTGATTTGACTGTCTCCGGCCTTTCTCACCCTTTTGAATCTTTACCTACACATTACTCAGGCATTGCATTTAAAATATATGAGGGTTCTAAAAATT
-TTTATCCTTGCGTTGAAATAAAGGCTTCTCCCGCAAAAGTATTACAGGGTCATAATGTTTTTGGTACAACCGATTTAGCTTTATGCTCTGAGGCTTTATT
-GCTTAATTTTGCTAATTCTTTGCCTTGCCTGTATGATTTATTGGATGTT
-''')
-
-_7560 = re.sub(r'\s', '', '''
-AGCTTGGCACTGGCCGTCGTTTTACAACGTCGTGACTGGGAAAACCCTGGCGTTACCCAACTTAATCGCCTTGCAGCACATCCCCCTTTCGCCAGCTGGC 
-GTAATAGCGAAGAGGCCCGCACCGATCGCCCTTCCCAACAGTTGCGCAGCCTGAATGGCGAATGGCGCTTTGCCTGGTTTCCGGCACCAGAAGCGGTGCC
-GGAAAGCTGGCTGGAGTGCGATCTTCCTGAGGCCGATACTGTCGTCGTCCCCTCAAACTGGCAGATGCACGGTTACGATGCGCCCATCTACACCAACGTG
-ACCTATCCCATTACGGTCAATCCGCCGTTTGTTCCCACGGAGAATCCGACGGGTTGTTACTCGCTCACATTTAATGTTGATGAAAGCTGGCTACAGGAAG
-GCCAGACGCGAATTATTTTTGATGGCGTTCCTATTGGTTAAAAAATGAGCTGATTTAACAAAAATTTAATGCGAATTTTAACAAAATATTAACGTTTACA
-ATTTAAATATTTGCTTATACAATCTTCCTGTTTTTGGGGCTTTTCTGATTATCAACCGGGGTACATATGATTGACATGCTAGTTTTACGATTACCGTTCA
-TCGATTCTCTTGTTTGCTCCAGACTCTCAGGCAATGACCTGATAGCCTTTGTAGATCTCTCAAAAATAGCTACCCTCTCCGGCATTAATTTATCAGCTAG
-AACGGTTGAATATCATATTGATGGTGATTTGACTGTCTCCGGCCTTTCTCACCCTTTTGAATCTTTACCTACACATTACTCAGGCATTGCATTTAAAATA
-TATGAGGGTTCTAAAAATTTTTATCCTTGCGTTGAAATAAAGGCTTCTCCCGCAAAAGTATTACAGGGTCATAATGTTTTTGGTACAACCGATTTAGCTT
-TATGCTCTGAGGCTTTATTGCTTAATTTTGCTAATTCTTTGCCTTGCCTGTATGATTTATTGGATGTTAATGCTACTACTATTAGTAGAATTGATGCCAC
-CTTTTCAGCTCGCGCCCCAAATGAAAATATAGCTAAACAGGTTATTGACCATTTGCGAAATGTATCTAATGGTCAAACTAAATCTACTCGTTCGCAGAAT
-TGGGAATCAACTGTTATATGGAATGAAACTTCCAGACACCGTACTTTAGTTGCATATTTAAAACATGTTGAGCTACAGCATTATATTCAGCAATTAAGCT
-CTAAGCCATCCGCAAAAATGACCTCTTATCAAAAGGAGCAATTAAAGGTACTCTCTAATCCTGACCTGTTGGAGTTTGCTTCCGGTCTGGTTCGCTTTGA
-AGCTCGAATTAAAACGCGATATTTGAAGTCTTTCGGGCTTCCTCTTAATCTTTTTGATGCAATCCGCTTTGCTTCTGACTATAATAGTCAGGGTAAAGAC
-CTGATTTTTGATTTATGGTCATTCTCGTTTTCTGAACTGTTTAAAGCATTTGAGGGGGATTCAATGAATATTTATGACGATTCCGCAGTATTGGACGCTA
-TCCAGTCTAAACATTTTACTATTACCCCCTCTGGCAAAACTTCTTTTGCAAAAGCCTCTCGCTATTTTGGTTTTTATCGTCGTCTGGTAAACGAGGGTTA
-TGATAGTGTTGCTCTTACTATGCCTCGTAATTCCTTTTGGCGTTATGTATCTGCATTAGTTGAATGTGGTATTCCTAAATCTCAACTGATGAATCTTTCT
-ACCTGTAATAATGTTGTTCCGTTAGTTCGTTTTATTAACGTAGATTTTTCTTCCCAACGTCCTGACTGGTATAATGAGCCAGTTCTTAAAATCGCATAAG
-GTAATTCACAATGATTAAAGTTGAAATTAAACCATCTCAAGCCCAATTTACTACTCGTTCTGGTGTTTCTCGTCAGGGCAAGCCTTATTCACTGAATGAG
-CAGCTTTGTTACGTTGATTTGGGTAATGAATATCCGGTTCTTGTCAAGATTACTCTTGATGAAGGTCAGCCAGCCTATGCGCCTGGTCTGTACACCGTTC
-ATCTGTCCTCTTTCAAAGTTGGTCAGTTCGGTTCCCTTATGATTGACCGTCTGCGCCTCGTTCCGGCTAAGTAACATGGAGCAGGTCGCGGATTTCGACA
-CAATTTATCAGGCGATGATACAAATCTCCGTTGTACTTTGTTTCGCGCTTGGTATAATCGCTGGGGGTCAAAGATGAGTGTTTTAGTGTATTCTTTTGCC
-TCTTTCGTTTTAGGTTGGTGCCTTCGTAGTGGCATTACGTATTTTACCCGTTTAATGGAAACTTCCTCATGAAAAAGTCTTTAGTCCTCAAAGCCTCTGT
-AGCCGTTGCTACCCTCGTTCCGATGCTGTCTTTCGCTGCTGAGGGTGACGATCCCGCAAAAGCGGCCTTTAACTCCCTGCAAGCCTCAGCGACCGAATAT
-ATCGGTTATGCGTGGGCGATGGTTGTTGTCATTGTCGGCGCAACTATCGGTATCAAGCTGTTTAAGAAATTCACCTCGAAAGCAAGCTGATAAACCGATA
-CAATTAAAGGCTCCTTTTGGAGCCTTTTTTTTGGAGATTTTCAACGTGAAAAAATTATTATTCGCAATTCCTTTAGTTGTTCCTTTCTATTCTCACTCCG
-CTGAAACTGTTGAAAGTTGTTTAGCAAAATCCCATACAGAAAATTCATTTACTAACGTCTGGAAAGACGACAAAACTTTAGATCGTTACGCTAACTATGA
-GGGCTGTCTGTGGAATGCTACAGGCGTTGTAGTTTGTACTGGTGACGAAACTCAGTGTTACGGTACATGGGTTCCTATTGGGCTTGCTATCCCTGAAAAT
-GAGGGTGGTGGCTCTGAGGGTGGCGGTTCTGAGGGTGGCGGTTCTGAGGGTGGCGGTACTAAACCTCCTGAGTACGGTGATACACCTATTCCGGGCTATA
-CTTATATCAACCCTCTCGACGGCACTTATCCGCCTGGTACTGAGCAAAACCCCGCTAATCCTAATCCTTCTCTTGAGGAGTCTCAGCCTCTTAATACTTT
-CATGTTTCAGAATAATAGGTTCCGAAATAGGCAGGGGGCATTAACTGTTTATACGGGCACTGTTACTCAAGGCACTGACCCCGTTAAAACTTATTACCAG
-TACACTCCTGTATCATCAAAAGCCATGTATGACGCTTACTGGAACGGTAAATTCAGAGACTGCGCTTTCCATTCTGGCTTTAATGAGGATTTATTTGTTT
-GTGAATATCAAGGCCAATCGTCTGACCTGCCTCAACCTCCTGTCAATGCTGGCGGCGGCTCTGGTGGTGGTTCTGGTGGCGGCTCTGAGGGTGGTGGCTC
-TGAGGGTGGCGGTTCTGAGGGTGGCGGCTCTGAGGGAGGCGGTTCCGGTGGTGGCTCTGGTTCCGGTGATTTTGATTATGAAAAGATGGCAAACGCTAAT
-AAGGGGGCTATGACCGAAAATGCCGATGAAAACGCGCTACAGTCTGACGCTAAAGGCAAACTTGATTCTGTCGCTACTGATTACGGTGCTGCTATCGATG
-GTTTCATTGGTGACGTTTCCGGCCTTGCTAATGGTAATGGTGCTACTGGTGATTTTGCTGGCTCTAATTCCCAAATGGCTCAAGTCGGTGACGGTGATAA
-TTCACCTTTAATGAATAATTTCCGTCAATATTTACCTTCCCTCCCTCAATCGGTTGAATGTCGCCCTTTTGTCTTTGGCGCTGGTAAACCATATGAATTT
-TCTATTGATTGTGACAAAATAAACTTATTCCGTGGTGTCTTTGCGTTTCTTTTATATGTTGCCACCTTTATGTATGTATTTTCTACGTTTGCTAACATAC
-TGCGTAATAAGGAGTCTTAATCATGCCAGTTCTTTTGGGTATTCCGTTATTATTGCGTTTCCTCGGTTTCCTTCTGGTAACTTTGTTCGGCTATCTGCTT
-ACTTTTCTTAAAAAGGGCTTCGGTAAGATAGCTATTGCTATTTCATTGTTTCTTGCTCTTATTATTGGGCTTAACTCAATTCTTGTGGGTTATCTCTCTG
-ATATTAGCGCTCAATTACCCTCTGACTTTGTTCAGGGTGTTCAGTTAATTCTCCCGTCTAATGCGCTTCCCTGTTTTTATGTTATTCTCTCTGTAAAGGC
-TGCTATTTTCATTTTTGACGTTAAACAAAAAATCGTTTCTTATTTGGATTGGGATAAATAATATGGCTGTTTATTTTGTAACTGGCAAATTAGGCTCTGG
-AAAGACGCTCGTTAGCGTTGGTAAGATTCAGGATAAAATTGTAGCTGGGTGCAAAATAGCAACTAATCTTGATTTAAGGCTTCAAAACCTCCCGCAAGTC
-GGGAGGTTCGCTAAAACGCCTCGCGTTCTTAGAATACCGGATAAGCCTTCTATATCTGATTTGCTTGCTATTGGGCGCGGTAATGATTCCTACGATGAAA
-ATAAAAACGGCTTGCTTGTTCTCGATGAGTGCGGTACTTGGTTTAATACCCGTTCTTGGAATGATAAGGAAAGACAGCCGATTATTGATTGGTTTCTACA
-TGCTCGTAAATTAGGATGGGATATTATTTTTCTTGTTCAGGACTTATCTATTGTTGATAAACAGGCGCGTTCTGCATTAGCTGAACATGTTGTTTATTGT
-CGTCGTCTGGACAGAATTACTTTACCTTTTGTCGGTACTTTATATTCTCTTATTACTGGCTCGAAAATGCCTCTGCCTAAATTACATGTTGGCGTTGTTA
-AATATGGCGATTCTCAATTAAGCCCTACTGTTGAGCGTTGGCTTTATACTGGTAAGAATTTGTATAACGCATATGATACTAAACAGGCTTTTTCTAGTAA
-TTATGATTCCGGTGTTTATTCTTATTTAACGCCTTATTTATCACACGGTCGGTATTTCAAACCATTAAATTTAGGTCAGAAGATGAAATTAACTAAAATA
-TATTTGAAAAAGTTTTCTCGCGTTCTTTGTCTTGCGATTGGATTTGCATCAGCATTTACATATAGTTATATAACCCAACCTAAGCCGGAGGTTAAAAAGG
-TAGTCTCTCAGACCTATGATTTTGATAAATTCACTATTGACTCTTCTCAGCGTCTTAATCTAAGCTATCGCTATGTTTTCAAGGATTCTAAGGGAAAATT
-AATTAATAGCGACGATTTACAGAAGCAAGGTTATTCACTCACATATATTGATTTATGTACTGTTTCCATTAAAAAAGGTAATTCAAATGAAATTGTTAAA
-TGTAATTAATTTTGTTTTCTTGATGTTTGTTTCATCATCTTCTTTTGCTCAGGTAATTGAAATGAATAATTCGCCTCTGCGCGATTTTGTAACTTGGTAT
-TCAAAGCAATCAGGCGAATCCGTTATTGTTTCTCCCGATGTAAAAGGTACTGTTACTGTATATTCATCTGACGTTAAACCTGAAAATCTACGCAATTTCT
-TTATTTCTGTTTTACGTGCAAATAATTTTGATATGGTAGGTTCTAACCCTTCCATTATTCAGAAGTATAATCCAAACAATCAGGATTATATTGATGAATT
-GCCATCATCTGATAATCAGGAATATGATGATAATTCCGCTCCTTCTGGTGGTTTCTTTGTTCCGCAAAATGATAATGTTACTCAAACTTTTAAAATTAAT
-AACGTTCGGGCAAAGGATTTAATACGAGTTGTCGAATTGTTTGTAAAGTCTAATACTTCTAAATCCTCAAATGTATTATCTATTGACGGCTCTAATCTAT
-TAGTTGTTAGTGCTCCTAAAGATATTTTAGATAACCTTCCTCAATTCCTTTCAACTGTTGATTTGCCAACTGACCAGATATTGATTGAGGGTTTGATATT
-TGAGGTTCAGCAAGGTGATGCTTTAGATTTTTCATTTGCTGCTGGCTCTCAGCGTGGCACTGTTGCAGGCGGTGTTAATACTGACCGCCTCACCTCTGTT
-TTATCTTCTGCTGGTGGTTCGTTCGGTATTTTTAATGGCGATGTTTTAGGGCTATCAGTTCGCGCATTAAAGACTAATAGCCATTCAAAAATATTGTCTG
-TGCCACGTATTCTTACGCTTTCAGGTCAGAAGGGTTCTATCTCTGTTGGCCAGAATGTCCCTTTTATTACTGGTCGTGTGACTGGTGAATCTGCCAATGT
-AAATAATCCATTTCAGACGATTGAGCGTCAAAATGTAGGTATTTCCATGAGCGTTTTTCCTGTTGCAATGGCTGGCGGTAATATTGTTCTGGATATTACC
-AGCAAGGCCGATAGTTTGAGTTCTTCTACTCAGGCAAGTGATGTTATTACTAATCAAAGAAGTATTGCTACAACGGTTAATTTGCGTGATGGACAGACTC
-TTTTACTCGGTGGCCTCACTGATTATAAAAACACTTCTCAGGATTCTGGCGTACCGTTCCTGTCTAAAATCCCTTTAATCGGCCTCCTGTTTAGCTCCCG
-CTCTGATTCTAACGAGGAAAGCACGTTATACGTGCTCGTCAAAGCAACCATAGTACGCGCCCTGTAGCGGCGCATTAAGCGCGGCGGGTGTGGTGGTTAC
-GCGCAGCGTGACCGCTACACTTGCCAGCGCCCTAGCGCCCGCTCCTTTCGCTTTCTTCCCTTCCTTTCTCGCCACGTTCGCCGGCTTTCCCCGTCAAGCT
-CTAAATCGGGGGCTCCCTTTAGGGTTCCGATTTAGTGCTTTACGGCACCTCGACCCCAAAAAACTTGATTTGGGTGATGGTTCACGTAGTGGGCCATCGC
-CCTGATAGACGGTTTTTCGCCCTTTGACGTTGGAGTCCACGTTCTTTAATAGTGGACTCTTGTTCCAAACTGGAACAACACTCAACCCTATCTCGGGCTA
-TTCTTTTGATTTATAAGGGATTTTGCCGATTTCGGAACCACCATCAAACAGGATTTTCGCCTGCTGGGGCAAACCAGCGTGGACCGCTTGCTGCAACTCT
-CTCAGGGCCAGGCGGTGAAGGGCAATCAGCTGTTGCCCGTCTCACTGGTGAAAAGAAAAACCACCCTGGCGCCCAATACGCAAACCGCCTCTCCCCGCGC
-GTTGGCCGATTCATTAATGCAGCTGGCACGACAGGTTTCCCGACTGGAAAGCGGGCAGTGAGCGCAACGCAATTAATGTGAGTTAGCTCACTCATTAGGC
-ACCCCAGGCTTTACACTTTATGCTTCCGGCTCGTATGTTGTGTGGAATTGTGAGCGGATAACAATTTCACACAGGAAACAGCTATGACCATGATTACGAA
-TTCGAGCTCGGTACCCGGGGATCCTCCGTCTTTATCGAGGTAACAAGCACCACGTAGCTTAAGCCCTGTTTACTCATTACACCAACCAGGAGGTCAGAGT
-TCGGAGAAATGATTTATGTGAAATGCGTCAGCCGATTCAAGGCCCCTATATTCGTGCCCACCGACGAGTTGCTTACAGATGGCAGGGCCGCACTGTCGGT
-ATCATAGAGTCACTCCAGGGCGAGCGTAAATAGATTAGAAGCGGGGTTATTTTGGCGGGACATTGTCATAAGGTTGACAATTCAGCACTAAGGACACTTA
-AGTCGTGCGCATGAATTCACAACCACTTAGAAGAACATCCACCCTGGCTTCTCCTGAGAA
-''')
-
-_8064 = re.sub(r'\s', '', '''
-GGCAATGACCTGATAGCCTTTGTAGATCTCTCAAAAATAGCTACCCTCTCCGGCATTAATTTATCAGCTAGAACGGTTGAATATCATATTGATGGTGATT
-TGACTGTCTCCGGCCTTTCTCACCCTTTTGAATCTTTACCTACACATTACTCAGGCATTGCATTTAAAATATATGAGGGTTCTAAAAATTTTTATCCTTG
-CGTTGAAATAAAGGCTTCTCCCGCAAAAGTATTACAGGGTCATAATGTTTTTGGTACAACCGATTTAGCTTTATGCTCTGAGGCTTTATTGCTTAATTTT
-GCTAATTCTTTGCCTTGCCTGTATGATTTATTGGATGTTAATGCTACTACTATTAGTAGAATTGATGCCACCTTTTCAGCTCGCGCCCCAAATGAAAATA
-TAGCTAAACAGGTTATTGACCATTTGCGAAATGTATCTAATGGTCAAACTAAATCTACTCGTTCGCAGAATTGGGAATCAACTGTTATATGGAATGAAAC
-TTCCAGACACCGTACTTTAGTTGCATATTTAAAACATGTTGAGCTACAGCATTATATTCAGCAATTAAGCTCTAAGCCATCCGCAAAAATGACCTCTTAT
-CAAAAGGAGCAATTAAAGGTACTCTCTAATCCTGACCTGTTGGAGTTTGCTTCCGGTCTGGTTCGCTTTGAAGCTCGAATTAAAACGCGATATTTGAAGT
-CTTTCGGGCTTCCTCTTAATCTTTTTGATGCAATCCGCTTTGCTTCTGACTATAATAGTCAGGGTAAAGACCTGATTTTTGATTTATGGTCATTCTCGTT
-TTCTGAACTGTTTAAAGCATTTGAGGGGGATTCAATGAATATTTATGACGATTCCGCAGTATTGGACGCTATCCAGTCTAAACATTTTACTATTACCCCC
-TCTGGCAAAACTTCTTTTGCAAAAGCCTCTCGCTATTTTGGTTTTTATCGTCGTCTGGTAAACGAGGGTTATGATAGTGTTGCTCTTACTATGCCTCGTA
-ATTCCTTTTGGCGTTATGTATCTGCATTAGTTGAATGTGGTATTCCTAAATCTCAACTGATGAATCTTTCTACCTGTAATAATGTTGTTCCGTTAGTTCG
-TTTTATTAACGTAGATTTTTCTTCCCAACGTCCTGACTGGTATAATGAGCCAGTTCTTAAAATCGCATAAGGTAATTCACAATGATTAAAGTTGAAATTA
-AACCATCTCAAGCCCAATTTACTACTCGTTCTGGTGTTTCTCGTCAGGGCAAGCCTTATTCACTGAATGAGCAGCTTTGTTACGTTGATTTGGGTAATGA
-ATATCCGGTTCTTGTCAAGATTACTCTTGATGAAGGTCAGCCAGCCTATGCGCCTGGTCTGTACACCGTTCATCTGTCCTCTTTCAAAGTTGGTCAGTTC
-GGTTCCCTTATGATTGACCGTCTGCGCCTCGTTCCGGCTAAGTAACATGGAGCAGGTCGCGGATTTCGACACAATTTATCAGGCGATGATACAAATCTCC
-GTTGTACTTTGTTTCGCGCTTGGTATAATCGCTGGGGGTCAAAGATGAGTGTTTTAGTGTATTCTTTTGCCTCTTTCGTTTTAGGTTGGTGCCTTCGTAG
-TGGCATTACGTATTTTACCCGTTTAATGGAAACTTCCTCATGAAAAAGTCTTTAGTCCTCAAAGCCTCTGTAGCCGTTGCTACCCTCGTTCCGATGCTGT
-CTTTCGCTGCTGAGGGTGACGATCCCGCAAAAGCGGCCTTTAACTCCCTGCAAGCCTCAGCGACCGAATATATCGGTTATGCGTGGGCGATGGTTGTTGT
-CATTGTCGGCGCAACTATCGGTATCAAGCTGTTTAAGAAATTCACCTCGAAAGCAAGCTGATAAACCGATACAATTAAAGGCTCCTTTTGGAGCCTTTTT
-TTTGGAGATTTTCAACGTGAAAAAATTATTATTCGCAATTCCTTTAGTTGTTCCTTTCTATTCTCACTCCGCTGAAACTGTTGAAAGTTGTTTAGCAAAA
-TCCCATACAGAAAATTCATTTACTAACGTCTGGAAAGACGACAAAACTTTAGATCGTTACGCTAACTATGAGGGCTGTCTGTGGAATGCTACAGGCGTTG
-TAGTTTGTACTGGTGACGAAACTCAGTGTTACGGTACATGGGTTCCTATTGGGCTTGCTATCCCTGAAAATGAGGGTGGTGGCTCTGAGGGTGGCGGTTC
-TGAGGGTGGCGGTTCTGAGGGTGGCGGTACTAAACCTCCTGAGTACGGTGATACACCTATTCCGGGCTATACTTATATCAACCCTCTCGACGGCACTTAT
-CCGCCTGGTACTGAGCAAAACCCCGCTAATCCTAATCCTTCTCTTGAGGAGTCTCAGCCTCTTAATACTTTCATGTTTCAGAATAATAGGTTCCGAAATA
-GGCAGGGGGCATTAACTGTTTATACGGGCACTGTTACTCAAGGCACTGACCCCGTTAAAACTTATTACCAGTACACTCCTGTATCATCAAAAGCCATGTA
-TGACGCTTACTGGAACGGTAAATTCAGAGACTGCGCTTTCCATTCTGGCTTTAATGAGGATTTATTTGTTTGTGAATATCAAGGCCAATCGTCTGACCTG
-CCTCAACCTCCTGTCAATGCTGGCGGCGGCTCTGGTGGTGGTTCTGGTGGCGGCTCTGAGGGTGGTGGCTCTGAGGGTGGCGGTTCTGAGGGTGGCGGCT
-CTGAGGGAGGCGGTTCCGGTGGTGGCTCTGGTTCCGGTGATTTTGATTATGAAAAGATGGCAAACGCTAATAAGGGGGCTATGACCGAAAATGCCGATGA
-AAACGCGCTACAGTCTGACGCTAAAGGCAAACTTGATTCTGTCGCTACTGATTACGGTGCTGCTATCGATGGTTTCATTGGTGACGTTTCCGGCCTTGCT
-AATGGTAATGGTGCTACTGGTGATTTTGCTGGCTCTAATTCCCAAATGGCTCAAGTCGGTGACGGTGATAATTCACCTTTAATGAATAATTTCCGTCAAT
-ATTTACCTTCCCTCCCTCAATCGGTTGAATGTCGCCCTTTTGTCTTTGGCGCTGGTAAACCATATGAATTTTCTATTGATTGTGACAAAATAAACTTATT
-CCGTGGTGTCTTTGCGTTTCTTTTATATGTTGCCACCTTTATGTATGTATTTTCTACGTTTGCTAACATACTGCGTAATAAGGAGTCTTAATCATGCCAG
-TTCTTTTGGGTATTCCGTTATTATTGCGTTTCCTCGGTTTCCTTCTGGTAACTTTGTTCGGCTATCTGCTTACTTTTCTTAAAAAGGGCTTCGGTAAGAT
-AGCTATTGCTATTTCATTGTTTCTTGCTCTTATTATTGGGCTTAACTCAATTCTTGTGGGTTATCTCTCTGATATTAGCGCTCAATTACCCTCTGACTTT
-GTTCAGGGTGTTCAGTTAATTCTCCCGTCTAATGCGCTTCCCTGTTTTTATGTTATTCTCTCTGTAAAGGCTGCTATTTTCATTTTTGACGTTAAACAAA
-AAATCGTTTCTTATTTGGATTGGGATAAATAATATGGCTGTTTATTTTGTAACTGGCAAATTAGGCTCTGGAAAGACGCTCGTTAGCGTTGGTAAGATTC
-AGGATAAAATTGTAGCTGGGTGCAAAATAGCAACTAATCTTGATTTAAGGCTTCAAAACCTCCCGCAAGTCGGGAGGTTCGCTAAAACGCCTCGCGTTCT
-TAGAATACCGGATAAGCCTTCTATATCTGATTTGCTTGCTATTGGGCGCGGTAATGATTCCTACGATGAAAATAAAAACGGCTTGCTTGTTCTCGATGAG
-TGCGGTACTTGGTTTAATACCCGTTCTTGGAATGATAAGGAAAGACAGCCGATTATTGATTGGTTTCTACATGCTCGTAAATTAGGATGGGATATTATTT
-TTCTTGTTCAGGACTTATCTATTGTTGATAAACAGGCGCGTTCTGCATTAGCTGAACATGTTGTTTATTGTCGTCGTCTGGACAGAATTACTTTACCTTT
-TGTCGGTACTTTATATTCTCTTATTACTGGCTCGAAAATGCCTCTGCCTAAATTACATGTTGGCGTTGTTAAATATGGCGATTCTCAATTAAGCCCTACT
-GTTGAGCGTTGGCTTTATACTGGTAAGAATTTGTATAACGCATATGATACTAAACAGGCTTTTTCTAGTAATTATGATTCCGGTGTTTATTCTTATTTAA
-CGCCTTATTTATCACACGGTCGGTATTTCAAACCATTAAATTTAGGTCAGAAGATGAAATTAACTAAAATATATTTGAAAAAGTTTTCTCGCGTTCTTTG
-TCTTGCGATTGGATTTGCATCAGCATTTACATATAGTTATATAACCCAACCTAAGCCGGAGGTTAAAAAGGTAGTCTCTCAGACCTATGATTTTGATAAA
-TTCACTATTGACTCTTCTCAGCGTCTTAATCTAAGCTATCGCTATGTTTTCAAGGATTCTAAGGGAAAATTAATTAATAGCGACGATTTACAGAAGCAAG
-GTTATTCACTCACATATATTGATTTATGTACTGTTTCCATTAAAAAAGGTAATTCAAATGAAATTGTTAAATGTAATTAATTTTGTTTTCTTGATGTTTG
-TTTCATCATCTTCTTTTGCTCAGGTAATTGAAATGAATAATTCGCCTCTGCGCGATTTTGTAACTTGGTATTCAAAGCAATCAGGCGAATCCGTTATTGT
-TTCTCCCGATGTAAAAGGTACTGTTACTGTATATTCATCTGACGTTAAACCTGAAAATCTACGCAATTTCTTTATTTCTGTTTTACGTGCAAATAATTTT
-GATATGGTAGGTTCTAACCCTTCCATTATTCAGAAGTATAATCCAAACAATCAGGATTATATTGATGAATTGCCATCATCTGATAATCAGGAATATGATG
-ATAATTCCGCTCCTTCTGGTGGTTTCTTTGTTCCGCAAAATGATAATGTTACTCAAACTTTTAAAATTAATAACGTTCGGGCAAAGGATTTAATACGAGT
-TGTCGAATTGTTTGTAAAGTCTAATACTTCTAAATCCTCAAATGTATTATCTATTGACGGCTCTAATCTATTAGTTGTTAGTGCTCCTAAAGATATTTTA
-GATAACCTTCCTCAATTCCTTTCAACTGTTGATTTGCCAACTGACCAGATATTGATTGAGGGTTTGATATTTGAGGTTCAGCAAGGTGATGCTTTAGATT
-TTTCATTTGCTGCTGGCTCTCAGCGTGGCACTGTTGCAGGCGGTGTTAATACTGACCGCCTCACCTCTGTTTTATCTTCTGCTGGTGGTTCGTTCGGTAT
-TTTTAATGGCGATGTTTTAGGGCTATCAGTTCGCGCATTAAAGACTAATAGCCATTCAAAAATATTGTCTGTGCCACGTATTCTTACGCTTTCAGGTCAG
-AAGGGTTCTATCTCTGTTGGCCAGAATGTCCCTTTTATTACTGGTCGTGTGACTGGTGAATCTGCCAATGTAAATAATCCATTTCAGACGATTGAGCGTC
-AAAATGTAGGTATTTCCATGAGCGTTTTTCCTGTTGCAATGGCTGGCGGTAATATTGTTCTGGATATTACCAGCAAGGCCGATAGTTTGAGTTCTTCTAC
-TCAGGCAAGTGATGTTATTACTAATCAAAGAAGTATTGCTACAACGGTTAATTTGCGTGATGGACAGACTCTTTTACTCGGTGGCCTCACTGATTATAAA
-AACACTTCTCAGGATTCTGGCGTACCGTTCCTGTCTAAAATCCCTTTAATCGGCCTCCTGTTTAGCTCCCGCTCTGATTCTAACGAGGAAAGCACGTTAT
-ACGTGCTCGTCAAAGCAACCATAGTACGCGCCCTGTAGCGGCGCATTAAGCGCGGCGGGTGTGGTGGTTACGCGCAGCGTGACCGCTACACTTGCCAGCG
-CCCTAGCGCCCGCTCCTTTCGCTTTCTTCCCTTCCTTTCTCGCCACGTTCGCCGGCTTTCCCCGTCAAGCTCTAAATCGGGGGCTCCCTTTAGGGTTCCG
-ATTTAGTGCTTTACGGCACCTCGACCCCAAAAAACTTGATTTGGGTGATGGTTCACGTAGTGGGCCATCGCCCTGATAGACGGTTTTTCGCCCTTTGACG
-TTGGAGTCCACGTTCTTTAATAGTGGACTCTTGTTCCAAACTGGAACAACACTCAACCCTATCTCGGGCTATTCTTTTGATTTATAAGGGATTTTGCCGA
-TTTCGGAACCACCATCAAACAGGATTTTCGCCTGCTGGGGCAAACCAGCGTGGACCGCTTGCTGCAACTCTCTCAGGGCCAGGCGGTGAAGGGCAATCAG
-CTGTTGCCCGTCTCACTGGTGAAAAGAAAAACCACCCTGGCGCCCAATACGCAAACCGCCTCTCCCCGCGCGTTGGCCGATTCATTAATGCAGCTGGCAC
-GACAGGTTTCCCGACTGGAAAGCGGGCAGTGAGCGCAACGCAATTAATGTGAGTTAGCTCACTCATTAGGCACCCCAGGCTTTACACTTTATGCTTCCGG
-CTCGTATGTTGTGTGGAATTGTGAGCGGATAACAATTTCACACAGGAAACAGCTATGACCATGATTACGAATTCGAGCTCGGTACCCGGGGATCCTCAAC
-TGTGAGGAGGCTCACGGACGCGAAGAACAGGCACGCGTGCTGGCAGAAACCCCCGGTATGACCGTGAAAACGGCCCGCCGCATTCTGGCCGCAGCACCAC
-AGAGTGCACAGGCGCGCAGTGACACTGCGCTGGATCGTCTGATGCAGGGGGCACCGGCACCGCTGGCTGCAGGTAACCCGGCATCTGATGCCGTTAACGA
-TTTGCTGAACACACCAGTGTAAGGGATGTTTATGACGAGCAAAGAAACCTTTACCCATTACCAGCCGCAGGGCAACAGTGACCCGGCTCATACCGCAACC
-GCGCCCGGCGGATTGAGTGCGAAAGCGCCTGCAATGACCCCGCTGATGCTGGACACCTCCAGCCGTAAGCTGGTTGCGTGGGATGGCACCACCGACGGTG
-CTGCCGTTGGCATTCTTGCGGTTGCTGCTGACCAGACCAGCACCACGCTGACGTTCTACAAGTCCGGCACGTTCCGTTATGAGGATGTGCTCTGGCCGGA
-GGCTGCCAGCGACGAGACGAAAAAACGGACCGCGTTTGCCGGAACGGCAATCAGCATCGTTTAACTTTACCCTTCATCACTAAAGGCCGCCTGTGCGGCT
-TTTTTTACGGGATTTTTTTATGTCGATGTACACAACCGCCCAACTGCTGGCGGCAAATGAGCAGAAATTTAAGTTTGATCCGCTGTTTCTGCGTCTCTTT
-TTCCGTGAGAGCTATCCCTTCACCACGGAGAAAGTCTATCTCTCACAAATTCCGGGACTGGTAAACATGGCGCTGTACGTTTCGCCGATTGTTTCCGGTG
-AGGTTATCCGTTCCCGTGGCGGCTCCACCTCTGAAAGCTTGGCACTGGCCGTCGTTTTACAACGTCGTGACTGGGAAAACCCTGGCGTTACCCAACTTAA
-TCGCCTTGCAGCACATCCCCCTTTCGCCAGCTGGCGTAATAGCGAAGAGGCCCGCACCGATCGCCCTTCCCAACAGTTGCGCAGCCTGAATGGCGAATGG
-CGCTTTGCCTGGTTTCCGGCACCAGAAGCGGTGCCGGAAAGCTGGCTGGAGTGCGATCTTCCTGAGGCCGATACTGTCGTCGTCCCCTCAAACTGGCAGA
-TGCACGGTTACGATGCGCCCATCTACACCAACGTGACCTATCCCATTACGGTCAATCCGCCGTTTGTTCCCACGGAGAATCCGACGGGTTGTTACTCGCT
-CACATTTAATGTTGATGAAAGCTGGCTACAGGAAGGCCAGACGCGAATTATTTTTGATGGCGTTCCTATTGGTTAAAAAATGAGCTGATTTAACAAAAAT
-TTAATGCGAATTTTAACAAAATATTAACGTTTACAATTTAAATATTTGCTTATACAATCTTCCTGTTTTTGGGGCTTTTCTGATTATCAACCGGGGTACA
-TATGATTGACATGCTAGTTTTACGATTACCGTTCATCGATTCTCTTGTTTGCTCCAGACTCTCA
-''')
-
-_m13_variants = {
-    M13Variant.p7249: _7249,
-    M13Variant.p7560: _7560,
-    M13Variant.p8064: _8064,
-}
-
-##################
-# keys
-
-# Design keys
-version_key = 'version'
-grid_key = 'grid'
-helices_key = 'helices'
-strands_key = 'strands'
-scaffold_key = 'scaffold'
-helices_view_order_key = 'helices_view_order'
-is_origami_key = 'is_origami'
-design_modifications_key = 'modifications_in_design'
-geometry_key = 'geometry'
-groups_key = 'groups'
-
-# Geometry keys
-rise_per_base_pair_key = 'rise_per_base_pair'
-legacy_rise_per_base_pair_keys = ['z_step']
-helix_radius_key = 'helix_radius'
-bases_per_turn_key = 'bases_per_turn'
-minor_groove_angle_key = 'minor_groove_angle'
-inter_helix_gap_key = 'inter_helix_gap'
-
-# Helix keys
-idx_on_helix_key = 'idx'
-max_offset_key = 'max_offset'
-min_offset_key = 'min_offset'
-grid_position_key = 'grid_position'
-position_key = 'position'
-legacy_position_keys = ['origin']
-major_tick_distance_key = 'major_tick_distance'
-major_ticks_key = 'major_ticks'
-major_tick_start_key = 'major_tick_start'
-major_tick_periodic_distances_key = 'major_tick_periodic_distances'
-group_key = 'group'
-
-# Position keys
-position_x_key = 'x'
-position_y_key = 'y'
-position_z_key = 'z'
-pitch_key = 'pitch'
-roll_key = 'roll'
-yaw_key = 'yaw'
-position_origin_key = 'origin'
-
-# Strand keys
-strand_name_key = 'name'
-circular_key = 'circular'
-color_key = 'color'
-dna_sequence_key = 'sequence'
-legacy_dna_sequence_keys = ['dna_sequence']  # support legacy names for these ideas
-domains_key = 'domains'
-legacy_domains_keys = ['substrands']  # support legacy names for these ideas
-idt_key = 'idt'
-is_scaffold_key = 'is_scaffold'
-modification_5p_key = '5prime_modification'
-modification_3p_key = '3prime_modification'
-modifications_int_key = 'internal_modifications'
-strand_label_key = 'label'
-
-# Domain keys
-domain_name_key = 'name'
-helix_idx_key = 'helix'
-forward_key = 'forward'
-legacy_forward_keys = ['right']  # support legacy names for these ideas
-start_key = 'start'
-end_key = 'end'
-deletions_key = 'deletions'
-insertions_key = 'insertions'
-domain_label_key = 'label'
-
-# Loopout keys
-loopout_key = 'loopout'
-
-# Modification keys
-mod_location_key = 'location'
-mod_display_text_key = 'display_text'
-mod_id_key = 'id'
-mod_idt_text_key = 'idt_text'
-mod_font_size_key = 'font_size'
-mod_display_connector_key = 'display_connector'
-mod_allowed_bases_key = 'allowed_bases'
-
-# IDT keys
-idt_scale_key = 'scale'
-idt_purification_key = 'purification'
-idt_plate_key = 'plate'
-idt_well_key = 'well'
-# legacy; not written anymore as part of idt, but may be read from older versions of the JSON if
-# the Strand has no name but the IDT field does have a name
-idt_name_key = 'name'
-
-# end keys
-##################
-
-# end constants
-##########################################################################
-
-
-##########################################################################
-# modification abstract base classes
-
-_default_modification_id = "WARNING: no id assigned to modification"
-
-
-@dataclass(frozen=True, eq=True)
-class Modification(_JSONSerializable):
-    """Base class of modifications (to DNA sequences, e.g., biotin or Cy3).
-    Use :any:`Modification3Prime`, :any:`Modification5Prime`, or :any:`ModificationInternal`
-    to instantiate."""
-
-    display_text: str
-    """Short text to display in the web interface as an "icon"
-    visually representing the modification, e.g., ``'B'`` for biotin or ``'Cy3'`` for Cy3."""
-
-    id: str = _default_modification_id
-    """
-    Representation as a string; used to write in :any:`Strand` json representation,
-    while the full description of the modification is written under a global key in the :any:`Design`.
-    If not specified, but :py:data:`Modification.idt_text` is specified, then it will be set equal to that.
-    """
-
-    idt_text: Optional[str] = None
-    """IDT text string specifying this modification (e.g., '/5Biosg/' for 5' biotin). optional"""
-
-    def __post_init__(self) -> None:
-        if self.id == _default_modification_id and self.idt_text is not None:
-            object.__setattr__(self, 'id', self.idt_text)
-
-    def to_json_serializable(self, suppress_indent: bool = True, **kwargs: Any) -> Dict[str, Any]:
-        ret = {mod_display_text_key: self.display_text}
-        if self.idt_text is not None:
-            ret[mod_idt_text_key] = self.idt_text
-            ret[mod_display_connector_key] = False  # type: ignore
-        return ret
-
-    @staticmethod
-    def from_json(
-            json_map: Dict[str, Any]) -> 'Modification':  # remove quotes when Py3.6 support dropped
-        location = json_map[mod_location_key]
-        if location == "5'":
-            return Modification5Prime.from_json(json_map)
-        elif location == "3'":
-            return Modification3Prime.from_json(json_map)
-        elif location == "internal":
-            return ModificationInternal.from_json(json_map)
-        else:
-            raise IllegalDesignError(f'unknown Modification location "{location}"')
-
-
-@dataclass(frozen=True, eq=True)
-class Modification5Prime(Modification):
-    """5' modification of DNA sequence, e.g., biotin or Cy3."""
-
-    def to_json_serializable(self, suppress_indent: bool = True, **kwargs: Any) -> Dict[str, Any]:
-        ret = super().to_json_serializable(suppress_indent)
-        ret[mod_location_key] = "5'"
-        return ret
-
-    # remove quotes when Py3.6 support dropped
-    @staticmethod
-    def from_json(json_map: Dict[str, Any]) -> 'Modification5Prime':
-        display_text = json_map[mod_display_text_key]
-        location = json_map[mod_location_key]
-        assert location == "5'"
-        idt_text = json_map.get(mod_idt_text_key)
-        return Modification5Prime(display_text=display_text, idt_text=idt_text)
-
-
-@dataclass(frozen=True, eq=True)
-class Modification3Prime(Modification):
-    """3' modification of DNA sequence, e.g., biotin or Cy3."""
-
-    def to_json_serializable(self, suppress_indent: bool = True, **kwargs: Any) -> Dict[str, Any]:
-        ret = super().to_json_serializable(suppress_indent)
-        ret[mod_location_key] = "3'"
-        return ret
-
-    # remove quotes when Py3.6 support dropped
-    @staticmethod
-    def from_json(json_map: Dict[str, Any]) -> 'Modification3Prime':
-        display_text = json_map[mod_display_text_key]
-        location = json_map[mod_location_key]
-        assert location == "3'"
-        idt_text = json_map.get(mod_idt_text_key)
-        return Modification3Prime(display_text=display_text, idt_text=idt_text)
-
-
-@dataclass(frozen=True, eq=True)
-class ModificationInternal(Modification):
-    """Internal modification of DNA sequence, e.g., biotin or Cy3."""
-
-    allowed_bases: Optional[FrozenSet[str]] = None
-    """If None, then this is an internal modification that goes between bases. 
-    If instead it is a list of bases, then this is an internal modification that attaches to a base,
-    and this lists the allowed bases for this internal modification to be placed at. 
-    For example, internal biotins for IDT must be at a T. If any base is allowed, it should be
-    ``['A','C','G','T']``."""
-
-    def to_json_serializable(self, suppress_indent: bool = True, **kwargs: Any) -> Dict[str, Any]:
-        ret = super().to_json_serializable(suppress_indent)
-        ret[mod_location_key] = "internal"
-        if self.allowed_bases is not None:
-            ret[mod_allowed_bases_key] = NoIndent(
-                list(self.allowed_bases)) if suppress_indent else list(self.allowed_bases)
-        return ret
-
-    # remove quotes when Py3.6 support dropped
-    @staticmethod
-    def from_json(json_map: Dict[str, Any]) -> 'ModificationInternal':
-        display_text = json_map[mod_display_text_key]
-        location = json_map[mod_location_key]
-        assert location == "internal"
-        idt_text = json_map.get(mod_idt_text_key)
-        allowed_bases_list = json_map.get(mod_allowed_bases_key)
-        allowed_bases = frozenset(allowed_bases_list) if allowed_bases_list is not None else None
-        return ModificationInternal(display_text=display_text, idt_text=idt_text, allowed_bases=allowed_bases)
-
-
-# end modification abstract base classes
-##########################################################################
-
-
-@dataclass
-class Position3D(_JSONSerializable):
-    """
-    Position (x,y,z) in 3D space.
-    """
-
-    x: float = 0
-    """x-coordinate of position. 
-    Increasing `x` moves right in the side view and out of the screen in the main view."""
-
-    y: float = 0
-    """y-coordinate of position.
-    Increasing `y` moves down in the side and main views, i.e., "screen coordinates".
-    (though this can be rotated to Cartesian coordinates, where y goes up, 
-    by selecting "invert y/z axes" in the View menu of scadnano.)"""
-
-    z: float = 0
-    """z-coordinate of position.
-    Increasing `z` moves right in the main view and into the screen in the side view."""
-
-    def to_json_serializable(self, suppress_indent: bool = True, **kwargs: Any) -> Dict[str, Any]:
-        dct: Dict[str, Any] = dict(self.__dict__)
-        # return NoIndent(dct) if suppress_indent else dct
-        return dct
-
-    @staticmethod
-    def from_json(json_map: Dict[str, Any]) -> 'Position3D':  # remove quotes when Py3.6 support dropped
-        if position_origin_key in json_map:
-            origin_ = json_map[position_origin_key]
-            x = origin_[position_x_key]
-            y = origin_[position_y_key]
-            z = origin_[position_z_key]
-        else:
-            x = json_map[position_x_key]
-            y = json_map[position_y_key]
-            z = json_map[position_z_key]
-        return Position3D(x=x, y=y, z=z)
-
-
-origin: Position3D = Position3D(x=0, y=0, z=0)
-
-
-@dataclass
-class HelixGroup(_JSONSerializable):
-    """
-    Represents a set of properties to apply to a specific group of :any:`Helix`'s in the :any:`Design`.
-
-    A :any:`HelixGroup` is useful for grouping together helices that should all be in parallel,
-    as part of a design where different groups are not parallel. In particular, each :any:`HelixGroup`
-    can be given its own 3D position and pitch/yaw/roll orientation angles. Each :any:`HelixGroup` does
-    not actually *contain* its helices; they are associated through the field `Helix.group`, which is
-    a string representing a key in the dict ``groups`` specified in the constructor for :any:`Design`.
-
-    If there are :any:`HelixGroup`'s explicitly specified, then the field :py:data:`Design.grid` is ignored.
-    Each :any:`HelixGroup` has its own grid, and the fields :py:data:`Helix.position` or
-    :py:data:`Helix.grid_position` are considered relative to the origin of that :any:`HelixGroup`
-    (i.e., the value :py:data:`HelixGroup.position`). Although it is possible to assign a :any:`Helix`
-    in a :any:`HelixGroup` a non-zero :py:data:`Helix.pitch` or :py:data:`Helix.yaw`,
-    the most common use case is that all helices in a group are parallel, so they all have these angles
-    equal to 0 (since they are unrotated relative to each other along the pitch and yaw planes).
-    """
-
-    position: Position3D = origin
-    """The "origin" of this :any:`HelixGroup`."""
-
-    pitch: float = 0
-    """Same meaning as :py:data:`Helix.pitch`, applied to every :any:`Helix` in the group."""
-
-    yaw: float = 0
-    """Same meaning as :py:data:`Helix.yaw`, applied to every :any:`Helix` in the group."""
-
-    roll: float = 0
-    """Same meaning as :py:data:`Helix.roll`, applied to every :any:`Helix` in the group."""
-
-    helices_view_order: Optional[List[int]] = None
-    """Same meaning as :py:data:`Design.helices_view_order`, 
-    applied to only the :any:`Helix`'s in the group."""
-
-    grid: Grid = Grid.none
-    """Same meaning as :py:data:`Design.grid`, enforced only on the :any:`Helix`'s in the group."""
-
-    def to_json_serializable(self, suppress_indent: bool = True, **kwargs: Any) -> Dict[str, Any]:
-        dct: Dict[str, Any] = dict()
-
-        helix_idxs: List[int] = kwargs['helix_idxs']
-
-        pos = self.position.to_json_serializable(suppress_indent)
-        dct[position_key] = NoIndent(pos) if suppress_indent else pos
-
-        if not _is_close(self.pitch, default_pitch):
-            dct[pitch_key] = self.pitch
-        if not _is_close(self.roll, default_roll):
-            dct[roll_key] = self.roll
-        if not _is_close(self.yaw, default_yaw):
-            dct[yaw_key] = self.yaw
-
-        dct[grid_key] = str(self.grid)[5:]  # remove prefix 'Grid.'
-
-        default_helices_view_order = sorted(helix_idxs)
-        if self.helices_view_order != default_helices_view_order:
-            dct[helices_view_order_key] = NoIndent(self.helices_view_order)
-
-        return dct
-
-    def _assign_default_helices_view_order(self, helices_in_group: Dict[int, 'Helix']) -> None:
-        if self.helices_view_order is not None:
-            raise AssertionError('should not call _assign_default_helices_view_order if '
-                                 'HelixGroup.helices_view_order is not None, but it is '
-                                 f'{self.helices_view_order}')
-        helix_idxs = list(helices_in_group.keys())
-        self.helices_view_order = _check_helices_view_order_and_return(self.helices_view_order, helix_idxs)
-
-    @staticmethod
-    def from_json(json_map: dict, **kwargs: Any) -> 'HelixGroup':  # remove quotes when Py3.6 support dropped
-        grid = optional_field(Grid.none, json_map, grid_key)
-
-        num_helices: int = kwargs['num_helices']
-        helices_view_order = json_map.get(helices_view_order_key)
-        if helices_view_order is not None:
-            if len(helices_view_order) != num_helices:
-                raise IllegalDesignError(f'length of helices ({num_helices}) does not match '
-                                         f'length of helices_view_order ({len(helices_view_order)})')
-
-        position_map = mandatory_field(HelixGroup, json_map, position_key, *legacy_position_keys)
-        position = Position3D.from_json(position_map)
-
-        pitch = json_map.get(pitch_key, default_pitch)
-        roll = json_map.get(roll_key, default_roll)
-        yaw = json_map.get(yaw_key, default_yaw)
-
-        return HelixGroup(position=position,
-                          pitch=pitch,
-                          yaw=yaw,
-                          roll=roll,
-                          helices_view_order=helices_view_order,
-                          grid=grid)
-
-    def helices_view_order_inverse(self, idx: int) -> int:
-        """
-        Given a :py:data:`Helix.idx` in this :any:`HelixGroup`, return its view order.
-
-        :param idx: index of :any:`Helix` in this :any:`HelixGroup`
-        :return: view order of the :any:`Helix`
-        :raises ValueError: if `idx` is not the index of a :any:`Helix` in this :any:`HelixGroup`
-        """
-        if self.helices_view_order is None:
-            raise ValueError('cannot access helices_view_order_inverse until helices_view_order is set')
-        return self.helices_view_order.index(idx)
-
-
-# def in_browser() -> bool:
-#     """Test if this code is running in the browser.
-#
-#     Checks for existence of package "pyodide" used in pyodide. If present it is assumed the code is
-#     running in the browser."""
-#     try:
-#         import pyodide  # type: ignore
-#         return True
-#     except ImportError:
-#         return False
-
-
-@dataclass
-class Helix(_JSONSerializable):
-    """
-    Represents a "helix" where :any:`Domain`'s could go. Technically a :any:`Helix` can contain no
-    :any:`Domain`'s. More commonly, some partial regions of it may have only 1 or 0 :any:`Domain`'s.
-    So it is best thought of as a "potential" double-helix.
-
-    It has a 1-dimensional integer coordinate system given by "offsets", integers between
-    :py:data:`Helix.min_offset` (inclusive) and :py:data:`Helix.max_offset` (exclusive).
-    At any valid offset for this :any:`Helix`, at most two :any:`Domain`'s may share that offset
-    on this :any:`Helix`, and if there are exactly two, then one must have
-    :py:data:`Domain.forward` = ``true`` and the other must have
-    :py:data:`Domain.forward` = ``false``.
-
-    Once part of a :any:`Design`, a :any:`Helix` has an index (accessible  via :py:meth:`Helix.idx`
-    once the :any:`Design` is created)
-    representing its order in the list of all :any:`Helix`'s. This index is how a :any:`Domain` is
-    associated to the :any:`Helix` via the integer index :any:`Domain.helix`.
-    """
-
-    max_offset: Optional[int] = None  # type: ignore
-    """Maximum offset (exclusive) of :any:`Domain` that can be drawn on this :any:`Helix`. 
-    
-    Optional field.
-    If unspecified, it is calculated when the :any:`Design` is instantiated as 
-    the largest :any:`Domain.end` offset of any :any:`Domain` in the design.
-    """
-
-    min_offset: int = 0
-    """Minimum offset (inclusive) of :any:`Domain` that can be drawn on this :any:`Helix`. 
-    
-    Optional field. Default value 0.
-    """
-
-    major_tick_start: Optional[int] = None  # type: ignore
-    """Offset of first major tick when not specifying :py:data:`Helix.major_ticks`. 
-    Used in combination with either 
-    :py:data:`Helix.major_tick_distance` or 
-    :py:data:`Helix.major_tick_periodic_distances`.
-    
-    Optional field. 
-    If not specified, is initialized to value :py:data:`Helix.min_offset`."""
-
-    major_tick_distance: Optional[int] = None
-    """Distance between major ticks (bold) delimiting boundaries between bases. Major ticks will appear
-    in the visual interface at positions 
-
-    Optional field.
-    If 0 then no major ticks are drawn.
-    If not specified then the default value is assumed.
-    If the grid is :any:`Grid.square` then the default value is 8.
-    If the grid is :any:`Grid.hex` or :any:`Grid.honeycomb` then the default value is 7."""
-
-    major_tick_periodic_distances: Optional[List[int]] = None
-    """Periodic distances between major ticks. For example, setting 
-    :py:data:`Helix.major_tick_periodic_distances` = [2, 3] and 
-    :py:data:`Helix.major_tick_start` = 10 means that major ticks will appear at
-    12, 
-    15,
-    17,
-    20,
-    22, 
-    25,
-    27,
-    30, ...
-    
-    Optional field.
-    :py:data:`Helix.major_tick_distance` is equivalent to 
-    the setting :py:data:`Helix.major_tick_periodic_distances` = [:py:data:`Helix.major_tick_distance`].
-    """
-
-    major_ticks: Optional[List[int]] = None  # type: ignore
-    """If not ``None``, overrides :any:`Helix.major_tick_distance`
-    to specify a list of offsets at which to put major ticks."""
-
-    grid_position: Optional[Tuple[int, int]] = None  # type: ignore
-    """`(h,v)` position of this helix in the side view grid,
-    if :const:`Grid.square`, :const:`Grid.hex` , or :const:`Grid.honeycomb` is used
-    in the :any:`Design` containing this helix.
-    `h` and `v` are in units of "helices": incrementing `h` moves right one helix in the grid
-    and incrementing `v` moves down one helix in the grid. 
-    In the case of the hexagonal lattice, 
-    The convention is that incrementing `v` moves down and to the right if h is even, 
-    and moves down and to the left if `h` is odd.
-    This is the "odd-q" coordinate system here: 
-    https://www.redblobgames.com/grids/hexagons/)
-    However, the default y position in the main view for helices does not otherwise depend on grid_position.
-    The default is to list the y-coordinates in order by helix idx.
-    
-    Default is `h` = 0, `v` = index of :any:`Helix` in :py:data:`Design.helices`.
-    
-    In the case of the honeycomb lattice, we use the same convention as cadnano for encoding hex coordinates,
-    see `misc/cadnano-format-specs/v2.txt`.
-    That convention is different from simply excluding coordinates from the hex lattice.
-    """
-
-    position: Optional[Position3D] = None  # type: ignore
-    """Position (x,y,z) of this :any:`Helix` in 3D space.
-    
-    Must be None if :py:data:`Helix.grid_position` is specified."""
-
-    pitch: float = 0
-    """Angle in the main view plane; 0 means pointing to the right (min_offset on left, max_offset on right).
-    Rotation is clockwise in the main view.
-    See https://en.wikipedia.org/wiki/Aircraft_principal_axes
-    Units are degrees."""
-
-    roll: float = 0
-    """Angle around the center of the helix; 0 means pointing straight up in the side view.
-    Rotation is clockwise in the side view.
-    See https://en.wikipedia.org/wiki/Aircraft_principal_axes
-    Units are degrees."""
-
-    yaw: float = 0
-    """Third angle for orientation besides :py:data:`Helix.pitch` and :py:data:`Helix.roll`.
-    Not visually displayed in scadnano, but here to support more general 3D applications.
-    See https://en.wikipedia.org/wiki/Aircraft_principal_axes
-    Units are degrees."""
-
-    idx: Optional[int] = None
-    """Index of this :any:`Helix`.
-    
-    Optional if no other :any:`Helix` specifies a value for *idx*.
-    Default is the order of the :any:`Helix` is listed in constructor for :any:`Design`."""
-
-    group: str = default_group_name  # type: ignore
-    """Name of the :any:`HelixGroup` to which this :any:`Helix` belongs."""
-
-    # for optimization; list of domains on that Helix
-    _domains: List['Domain'] = field(default_factory=list)
-
-    def __post_init__(self) -> None:
-        if self.major_tick_start is None:  # type: ignore
-            self.major_tick_start = self.min_offset  # type: ignore
-        if self.grid_position is not None and self.position is not None:
-            raise IllegalDesignError('exactly one of grid_position or position must be specified, '
-                                     'but both are specified')
-        if self.major_ticks is not None and self.max_offset is not None and self.min_offset is not None:
-            for major_tick in self.major_ticks:
-                if major_tick > self.max_offset - self.min_offset:
-                    raise IllegalDesignError(f'major tick {major_tick} in list {self.major_ticks} is '
-                                             f'outside the range of available offsets since max_offset = '
-                                             f'{self.max_offset}')
-
-    def to_json_serializable(self, suppress_indent: bool = True, **kwargs: Any) -> Dict[str, Any]:
-        dct: Any = dict()
-
-        grid: Grid = kwargs['grid']
-
-        # if we have major ticks or position, it's harder to read Helix on one line,
-        # so don't wrap it in NoIndent, but still wrap longer sub-objects in them
-        # use_no_indent_helix: bool = not (self.major_ticks is not None or self.position is not None)
-        use_no_indent_helix: bool = not (self.major_ticks is not None)
-
-        if self.group != default_group_name:
-            dct[group_key] = self.group
-
-        if not self.min_offset_is_default():
-            dct[min_offset_key] = self.min_offset
-
-        if not self.major_tick_start_is_default():
-            dct[major_tick_start_key] = self.major_tick_start
-
-        dct[max_offset_key] = self.max_offset
-
-        if self.position is None:
-            if grid == Grid.none:
-                raise IllegalDesignError('cannot have Helix.position == None when grid is None')
-            dct[grid_position_key] = NoIndent(
-                self.grid_position) if suppress_indent and not use_no_indent_helix else self.grid_position
-        else:
-            if grid != Grid.none:
-                raise IllegalDesignError('cannot have Helix.position != None when grid is not None')
-            pos = self.position.to_json_serializable(suppress_indent)
-            dct[position_key] = NoIndent(pos) if suppress_indent and not use_no_indent_helix else pos
-
-        if not _is_close(self.pitch, default_pitch):
-            dct[pitch_key] = self.pitch
-        if not _is_close(self.roll, default_roll):
-            dct[roll_key] = self.roll
-        if not _is_close(self.yaw, default_yaw):
-            dct[yaw_key] = self.yaw
-
-        if not self.major_tick_distance_is_default(grid):
-            dct[major_tick_distance_key] = self.major_tick_distance
-
-        if not self.major_tick_periodic_distances_is_default():
-            dct[major_tick_periodic_distances_key] = NoIndent(
-                self.major_tick_periodic_distances) if suppress_indent and not use_no_indent_helix else \
-                self.major_tick_periodic_distances
-
-        if not self.major_ticks_is_default():
-            dct[major_ticks_key] = NoIndent(
-                self.major_ticks) if suppress_indent and not use_no_indent_helix else self.major_ticks
-
-        dct[idx_on_helix_key] = self.idx
-
-        return NoIndent(dct) if suppress_indent and use_no_indent_helix else dct
-
-    @staticmethod
-    def from_json(json_map: dict) -> 'Helix':  # remove quotes when Py3.6 support dropped
-        grid_position: Optional[Tuple[int, int]] = None
-        if grid_position_key in json_map:
-            gp_list = json_map[grid_position_key]
-            if len(gp_list) == 3:
-                gp_list = gp_list[:2]
-            if len(gp_list) != 2:
-                raise IllegalDesignError("list of grid_position coordinates must be length 2, "
-                                         f"but this is the list: {gp_list}")
-            grid_position = (gp_list[0], gp_list[1])
-
-        major_tick_distance = json_map.get(major_tick_distance_key)
-        major_ticks = json_map.get(major_ticks_key)
-        major_tick_start = json_map.get(major_tick_start_key)
-        major_tick_periodic_distances = json_map.get(major_tick_periodic_distances_key)
-        min_offset = optional_field(0, json_map, min_offset_key)
-        max_offset = json_map.get(max_offset_key)
-        idx = json_map.get(idx_on_helix_key)
-
-        position_map = optional_field(None, json_map, position_key, *legacy_position_keys)
-        position = Position3D.from_json(position_map) if position_map is not None else None
-
-        pitch = json_map.get(pitch_key, default_pitch)
-        roll = json_map.get(roll_key, default_roll)
-        yaw = json_map.get(yaw_key, default_yaw)
-
-        group = json_map.get(group_key, default_group_name)
-
-        return Helix(
-            major_tick_distance=major_tick_distance,
-            major_ticks=major_ticks,
-            major_tick_start=major_tick_start,
-            major_tick_periodic_distances=major_tick_periodic_distances,
-            grid_position=grid_position,
-            min_offset=min_offset,
-            max_offset=max_offset,
-            position=position,
-            pitch=pitch,
-            roll=roll,
-            yaw=yaw,
-            idx=idx,
-            group=group,
-        )
-
-    def default_grid_position(self) -> Tuple[int, int]:
-        if self.idx is None:
-            raise AssertionError('cannot call default_grid_position when idx is None')
-        return 0, self.idx
-
-    def calculate_major_ticks(self, grid: Grid) -> List[int]:
-        """
-        Calculates full list of major tick marks, whether using `default_major_tick_distance` (from
-        :any:`Design`), :py:data:`Helix.major_tick_distance`, or :py:data:`Helix.major_ticks`.
-        They are used in reverse order to determine precedence. (e.g., :py:data:`Helix.major_ticks`
-        overrides :py:data:`Helix.major_tick_distance`, which overrides
-        `default_major_tick_distance` from :any:`Design`.
-        """
-        if self.max_offset is None:
-            raise ValueError('cannot calculate major ticks if max_offset is not specified')
-        if self.major_tick_start is None:
-            raise AssertionError('major_tick_start should never be None')
-        if self.major_ticks is not None:
-            return self.major_ticks
-        elif self.major_tick_distance is not None:
-            return list(range(self.major_tick_start, self.max_offset + 1, self.major_tick_distance))
-        elif self.major_tick_periodic_distances is not None:
-            ticks = []
-            tick = self.major_tick_start
-            idx_period = 0
-            while tick <= self.max_offset:
-                ticks.append(tick)
-                tick += self.major_tick_periodic_distances[idx_period]
-                idx_period = (idx_period + 1) % len(self.major_tick_periodic_distances)
-            return ticks
-        else:
-            distance = default_major_tick_distance(grid)
-            return list(range(self.major_tick_start, self.max_offset + 1, distance))
-
-    @property
-    def domains(self) -> List['Domain']:
-        """
-        Return :any:`Domain`'s on this :any:`Helix`.
-        Assigned when a :any:`Design` is created using this :any:`Helix`.
-
-        :return: :any:`Domain`'s on this helix
-        """
-        return self._domains
-
-    def min_offset_is_default(self) -> bool:
-        return self.min_offset == 0
-
-    def major_tick_start_is_default(self) -> bool:
-        return self.major_tick_start == self.min_offset
-
-    def major_tick_distance_is_default(self, grid: Grid) -> bool:
-        return (self.major_tick_distance is None
-                or default_major_tick_distance(grid) == self.major_tick_distance)
-
-    def major_tick_periodic_distances_is_default(self) -> bool:
-        return self.major_tick_periodic_distances is None
-
-    def major_ticks_is_default(self) -> bool:
-        return self.major_ticks is None
-
-
-def _is_close(x1: float, x2: float) -> bool:
-    return abs(x1 - x2) < 0.00000001
-
-
-DomainLabel = TypeVar('DomainLabel')
-StrandLabel = TypeVar('StrandLabel')
-
-
-@dataclass
-class Domain(_JSONSerializable, Generic[DomainLabel]):
-    """
-    A maximal portion of a :any:`Strand` that is continguous on a single :any:`Helix`.
-    A :any:`Strand` contains a list of :any:`Domain`'s (and also potentially :any:`Loopout`'s).
-    """
-
-    helix: int
-    """index of the :any:`Helix` on which this :any:`Domain` resides."""
-
-    forward: bool
-    """Whether the strand "points" forward (i.e., its 3' end has a larger offset than its 5' end).
-    If :any:`Domain.forward` is ``True``, then 
-    :any:`Domain.start` is the 5' end of the :any:`Domain` and 
-    :any:`Domain.end` is the 3' end of the :any:`Domain`.
-    If :any:`Domain.forward` is ``False``, these roles are reversed."""
-
-    start: int
-    """
-    The smallest offset position of any base on this Domain
-    (3' end if :any:`Domain.forward` = ``False``,
-    5' end if :any:`Domain.forward` = ``True``).
-    """
-
-    end: int
-    """
-    1 plus the largest offset position of any base on this Domain
-    (5' end if :any:`Domain.forward` = ``False``,
-    3' end if :any:`Domain.forward` = ``True``).
-    Note that the set of base offsets occupied by this Domain is {start, start+1, ..., end-1},
-    i.e., inclusive for :py:data:`Strand.start` but exclusive for :py:data:`Strand.end`,
-    the same convention used in Python for slices of lists and strings.
-    (e.g., :samp:`"abcdef"[1:3] == "bc"`)
-    
-    Some methods (such as :py:meth:`Domain.dna_sequence_in`) use the convention of being inclusive on 
-    both ends and are marked with the word "INCLUSIVE".
-    (Such a convention is easier to reason about when there are insertions and deletions.)
-    """
-
-    deletions: List[int] = field(default_factory=list)
-    """List of positions of deletions on this Domain."""
-
-    insertions: List[Tuple[int, int]] = field(default_factory=list)
-    """List of (position,num_insertions) pairs on this Domain.
-    
-    This is the number of *extra* bases in addition to the base already at this position. 
-    The total number of bases at this offset is num_insertions+1."""
-
-    name: Optional[str] = None
-    """Optional name to give this :any:`Domain`. 
-    
-    This is used to interoperate with the dsd DNA sequence design package."""
-
-    label: Optional[DomainLabel] = None
-    """
-    Generic "label" object to associate to this :any:`Domain`.
-
-    Useful for associating extra information with the :any:`Domain` that will be serialized, for example,
-    for DNA sequence design. It must be an object (e.g., a dict or primitive type such as str or int) 
-    that is naturally JSON serializable. (Calling 
-    `json.dumps <https://docs.python.org/3/library/json.html#json.dumps>`_
-    on the object should succeed without having to specify a custom encoder.)
-    """
-
-    # not serialized; for efficiency
-    # remove quotes when Py3.6 support dropped
-    _parent_strand: Optional['Strand'] = field(init=False, repr=False, compare=False, default=None)
-
-    def __post_init__(self) -> None:
-        self._check_start_end()
-
-    def to_json_serializable(self, suppress_indent: bool = True,
-                             **kwargs: Any) -> Union[NoIndent, Dict[str, Any]]:
-        dct: Dict[str, Any] = OrderedDict()
-        if self.name is not None:
-            dct[domain_name_key] = self.name
-        dct[helix_idx_key] = self.helix
-        dct[forward_key] = self.forward
-        dct[start_key] = self.start
-        dct[end_key] = self.end
-        if len(self.deletions) > 0:
-            dct[deletions_key] = self.deletions
-        if len(self.insertions) > 0:
-            dct[insertions_key] = self.insertions
-        if self.label is not None:
-            dct[domain_label_key] = self.label
-        return NoIndent(dct) if suppress_indent else dct
-
-    @staticmethod
-    def from_json(json_map: Dict[str, Any]) -> 'Domain':  # remove quotes when Py3.6 support dropped
-        helix = mandatory_field(Domain, json_map, helix_idx_key)
-        forward = mandatory_field(Domain, json_map, forward_key, *legacy_forward_keys)
-        start = mandatory_field(Domain, json_map, start_key)
-        end = mandatory_field(Domain, json_map, end_key)
-        deletions = json_map.get(deletions_key, [])
-        insertions = cast(List[Tuple[int, int]],  # noqa
-                          list(map(tuple, json_map.get(insertions_key, []))))  # type: ignore
-        name = json_map.get(domain_name_key)
-        label = json_map.get(domain_label_key)
-        return Domain(
-            helix=helix,
-            forward=forward,
-            start=start,
-            end=end,
-            deletions=deletions,
-            insertions=insertions,
-            name=name,
-            label=label,
-        )
-
-    def __repr__(self) -> str:
-        rep = (f'Domain(' +
-               (f'name={self.name}' if self.name is not None else '') +
-               f', helix={self.helix}'
-               f', forward={self.forward}'
-               f', start={self.start}'
-               f', end={self.end}') + \
-              (f', deletions={self.deletions}' if len(self.deletions) > 0 else '') + \
-              (f', insertions={self.insertions}' if len(self.insertions) > 0 else '') + \
-              ')'
-        return rep
-
-    def __str__(self) -> str:
-        return repr(self) if self.name is None else self.name
-
-    def strand(self) -> 'Strand':  # remove quotes when Py3.6 support dropped
-        """
-        :return: The :any:`Strand` that contains this :any:`Loopout`.
-        """
-        if self._parent_strand is None:
-            raise ValueError('_parent_strand has not yet been set')
-        return self._parent_strand
-
-    def set_name(self, name: str) -> None:
-        """Sets name of this :any:`Domain`."""
-        self.name = name
-
-    def set_label(self, label: DomainLabel) -> None:
-        """Sets label of this :any:`Domain`."""
-        self.label = label
-
-    def _check_start_end(self) -> None:
-        if self.start >= self.end:
-            if self._parent_strand is None:
-                raise ValueError(f'start = {self.start} must be less than end = {self.end}\n'
-                                 f'_parent_strand has not yet been set')
-            raise StrandError(self._parent_strand,
-                              f'start = {self.start} must be less than end = {self.end}')
-
-    # @staticmethod
-    # def is_loopout() -> bool:
-    #     """Indicates if this is a :any:`Loopout` (always false)
-    #     Useful when object could be either :any:`Loopout` or :any:`Domain`."""
-    #     return False
-    #
-    # @staticmethod
-    # def is_domain() -> bool:
-    #     """Indicates if this is a :any:`Domain` (always true)
-    #     Useful when object could be either :any:`Loopout` or :any:`Domain`."""
-    #     return True
-
-    def set_start(self, new_start: int) -> None:
-        self.start = new_start
-        self._check_start_end()
-
-    def set_end(self, new_end: int) -> None:
-        self.end = new_end
-        self._check_start_end()
-
-    def offset_5p(self) -> int:
-        """5' offset of this :any:`Domain`, INCLUSIVE."""
-        if self.forward:
-            return self.start
-        else:
-            return self.end - 1
-        # return self.start if self.forward else self.end - 1
-
-    def offset_3p(self) -> int:
-        """3' offset of this :any:`Domain`, INCLUSIVE."""
-        return self.end - 1 if self.forward else self.start
-
-    def _num_insertions(self) -> int:
-        # total number of insertions in this Domain
-        return sum(insertion[1] for insertion in self.insertions)
-
-    def contains_offset(self, offset: int) -> bool:
-        """Indicates if `offset` is the offset of a base on this :any:`Domain`.
-
-        Note that offsets refer to visual portions of the displayed grid for the Helix.
-        If for example, this Domain starts at position 0 and ends at 10, and it has 5 deletions,
-        then it contains the offset 7 even though there is no base 7 positions from the start."""
-        return self.start <= offset < self.end
-
-    def __len__(self) -> int:
-        """Same as :meth:`Domain.dna_length`.
-
-        See also :meth:`Domain.visual_length`."""
-        return self.dna_length()
-
-    def dna_length(self) -> int:
-        """Number of bases in this Domain."""
-        return self.end - self.start - len(self.deletions) + self._num_insertions()
-
-    def dna_length_in(self, left: int, right: int) -> int:
-        """Number of bases in this Domain between offsets `left` and `right` (INCLUSIVE)."""
-        if not left <= right + 1:
-            raise ValueError(f'left = {left} and right = {right} but we should have left <= right + 1')
-        if not self.start <= left:
-            raise ValueError(f'left = {left} should be at least self.start = {self.start}')
-        if not right < self.end:
-            raise ValueError(f'right = {right} should be at most self.end - 1 = {self.end - 1}')
-        num_deletions = sum(1 for offset in self.deletions if left <= offset <= right)
-        num_insertions = sum(length for (offset, length) in self.insertions if left <= offset <= right)
-        return (right - left + 1) - num_deletions + num_insertions
-
-    def visual_length(self) -> int:
-        """Distance between :any:`Domain.start` offset and :any:`Domain.end` offset.
-
-        This can be more or less than the :meth:`Domain.dna_length` due to insertions and deletions."""
-        return self.end - self.start
-
-    def dna_sequence(self) -> Optional[str]:
-        """Return DNA sequence of this Domain, or ``None`` if no DNA sequence has been assigned
-        to this :any:`Domain`'s :any:`Strand`."""
-        return self.dna_sequence_in(self.start, self.end - 1)
-
-    def dna_sequence_in(self, offset_left: int, offset_right: int) -> Optional[str]:
-        """Return DNA sequence of this Domain in the interval of offsets given by
-        [`offset_left`, `offset_right`], INCLUSIVE, or ``None`` if no DNA sequence has been assigned
-        to this :any:`Domain`'s :any:`Strand`.
-
-        WARNING: This is inclusive on both ends,
-        unlike other parts of this API where the right endpoint is exclusive.
-        This is to make the notion well-defined when one of the endpoints is on an offset with a
-        deletion or insertion."""
-        strand_seq = self._parent_strand.dna_sequence if self._parent_strand is not None else None
-        if strand_seq is None:
-            return None
-
-        # if on a deletion, move inward until we are off of it
-        while offset_left in self.deletions:
-            offset_left += 1
-        while offset_right in self.deletions:
-            offset_right -= 1
-
-        if offset_left > offset_right:
-            return ''
-        if offset_left >= self.end:
-            return ''
-        if offset_right < 0:
-            return ''
-
-        str_idx_left = self.domain_offset_to_strand_dna_idx(offset_left, self.forward)
-        str_idx_right = self.domain_offset_to_strand_dna_idx(offset_right, not self.forward)
-        if not self.forward:  # these will be out of order if strand is left
-            str_idx_left, str_idx_right = str_idx_right, str_idx_left
-        subseq = strand_seq[str_idx_left:str_idx_right + 1]
-        return subseq
-
-    def get_seq_start_idx(self) -> int:
-        """Starting DNA subsequence index for first base of this :any:`Domain` on its
-        Parent :any:`Strand`'s DNA sequence."""
-        if self._parent_strand is None:
-            raise ValueError('should not call this method if a Strand has not be associated to this Domain')
-        domains = self._parent_strand.domains
-        # index of self in parent strand's list of domains
-        self_domain_idx = domains.index(self)
-        # index of self's position within the DNA sequence of parent strand
-        self_seq_idx_start = sum(prev_domain.dna_length()
-                                 for prev_domain in domains[:self_domain_idx])
-        return self_seq_idx_start
-
-    def domain_offset_to_strand_dna_idx(self, offset: int, offset_closer_to_5p: bool) -> int:
-        """ Convert from offset on this :any:`Domain`'s :any:`Helix`
-        to string index on the parent :any:`Strand`'s DNA sequence.
-
-        If `offset_closer_to_5p` is ``True``, (this only matters if `offset` contains an insertion)
-        then the only leftmost string index corresponding to this offset is included,
-        otherwise up to the rightmost string index (including all insertions) is included."""
-        if offset in self.deletions:
-            raise ValueError(f'offset {offset} illegally contains a deletion from {self.deletions}')
-
-        # length adjustment for insertions depends on whether this is a left or right offset
-        len_adjust = self._net_ins_del_length_increase_from_5p_to(offset, offset_closer_to_5p)
-
-        # get string index assuming this Domain is first on Strand
-        if self.forward:
-            offset += len_adjust  # account for insertions and deletions
-            domain_str_idx = offset - self.start
-        else:
-            # account for insertions and deletions
-            offset -= len_adjust  # account for insertions and deletions
-            domain_str_idx = self.end - 1 - offset
-
-        # correct for existence of previous Domains on this Strand
-        return domain_str_idx + self.get_seq_start_idx()
-
-    def _net_ins_del_length_increase_from_5p_to(self, offset_edge: int, offset_closer_to_5p: bool) -> int:
-        """Net number of insertions from 5'/3' end to offset_edge,
-        INCLUSIVE on 5'/3' end, EXCLUSIVE on offset_edge.
-
-        Set `five_p` ``= False`` to test from 3' end to `offset_edge`."""
-        length_increase = 0
-        for deletion in self.deletions:
-            if self._between_5p_and_offset(deletion, offset_edge):
-                length_increase -= 1
-        for (insertion_offset, insertion_length) in self.insertions:
-            if self._between_5p_and_offset(insertion_offset, offset_edge):
-                length_increase += insertion_length
-        # special case for when offset_edge is an endpoint closer to the 3' end,
-        # we add its extra insertions also in this case
-        if not offset_closer_to_5p:
-            insertion_map: Dict[int, int] = dict(self.insertions)
-            if offset_edge in insertion_map:
-                insertion_length = insertion_map[offset_edge]
-                length_increase += insertion_length
-        return length_increase
-
-    def _between_5p_and_offset(self, offset_to_test: int, offset_edge: int) -> bool:
-        return ((self.forward and self.start <= offset_to_test < offset_edge) or
-                (not self.forward and offset_edge < offset_to_test < self.end))
-
-    # def _between_3p_and_offset(self, offset_to_test: int, offset_edge: int) -> bool:
-    #     return ((self.direction == Direction.left and self.start <= offset_to_test < offset_edge) or
-    #             (self.direction == Direction.forward and offset_edge < offset_to_test < self.end))
-
-    # The type hint 'Domain' must be in quotes since Domain is not yet defined.
-    # This is a "forward reference": https://www.python.org/dev/peps/pep-0484/#forward-references
-    # remove quotes when Py3.6 support dropped
-    # def overlaps(self, other: Domain) -> bool:
-    def overlaps(self, other: 'Domain') -> bool:
-        r"""Indicates if this :any:`Domain`'s set of offsets (the set
-        :math:`\{x \in \mathbb{N} \mid`
-        ``self.start``
-        :math:`\leq x \leq`
-        ``self.end``
-        :math:`\}`)
-        has nonempty intersection with those of `other`,
-        and they appear on the same helix,
-        and they point in opposite directions."""  # noqa (suppress PEP warning)
-        return (self.helix == other.helix and
-                self.forward == (not other.forward) and
-                self.compute_overlap(other)[0] >= 0)
-
-    # remove quotes when Py3.6 support dropped
-    # def overlaps_illegally(self, other: Domain):
-    def overlaps_illegally(self, other: 'Domain') -> bool:
-        r"""Indicates if this :any:`Domain`'s set of offsets (the set
-        :math:`\{x \in \mathbb{N} \mid`
-        ``self.start``
-        :math:`\leq x \leq`
-        ``self.end``
-        :math:`\}`)
-        has nonempty intersection with those of `other`,
-        and they appear on the same helix,
-        and they point in the same direction."""  # noqa (suppress PEP warning)
-        return (self.helix == other.helix and
-                self.forward == other.forward and
-                self.compute_overlap(other)[0] >= 0)
-
-    # remove quotes when Py3.6 support dropped
-    # def compute_overlap(self, other: Domain) -> Tuple[int, int]:
-    def compute_overlap(self, other: 'Domain') -> Tuple[int, int]:
-        """Return [left,right) offset indicating overlap between this Domain and `other`.
-
-        Return ``(-1,-1)`` if they do not overlap (different helices, or non-overlapping regions
-        of the same helix)."""
-        overlap_start = max(self.start, other.start)
-        overlap_end = min(self.end, other.end)
-        if overlap_start >= overlap_end:  # overlap is empty
-            return -1, -1
-        return overlap_start, overlap_end
-
-    def insertion_offsets(self) -> List[int]:
-        """Return offsets of insertions (but not their lengths)."""
-        return [ins_off for (ins_off, _) in self.insertions]
-
-
-@dataclass
-class Loopout(_JSONSerializable, Generic[DomainLabel]):
-    """Represents a single-stranded loopout on a :any:`Strand`.
-
-    One could think of a :any:`Loopout` as a type of :any:`Domain`, but none of the fields of
-    :any:`Domain` make sense for :any:`Loopout`, so they are not related to each other in the type
-    hierarchy. It is interpreted that a :any:`Loopout` is a single-stranded region bridging two
-    :any:`Domain`'s that are connected to :any:`Helix`'s.
-    It is illegal for two consecutive :any:`Domain`'s to both
-    be :any:`Loopout`'s,
-    or for a :any:`Loopout` to occur on either end of the :any:`Strand`
-    (i.e., each :any:`Strand` must begin and end with a :any:`Domain`).
-
-    For example, one use of a loopout is to describe a hairpin (a.k.a.,
-    `stem-loop <https://en.wikipedia.org/wiki/Stem-loop>`_).
-    The following creates a :any:`Strand` that represents a hairpin with a stem length of 10 and a loop
-    length of 5.
-
-    .. code-block:: Python
-
-        import scadnano as sc
-
-        domain_f = sc.Domain(helix=0, forward=True, start=0, end=10)
-        loop = sc.Loopout(length=5)
-        domain_r = sc.Domain(helix=0, forward=False, start=0, end=10)
-        hairpin = sc.Strand([domain_f, loop, domain_r])
-
-    It can also be created with chained method calls
-
-    .. code-block:: Python
-
-        import scadnano as sc
-
-        design = sc.Design(helices=[sc.Helix(max_offset=10)])
-        design.strand(0,0).move(10).loopout(0,5).move(-10)
-    """
-
-    length: int
-    """Length (in DNA bases) of this :any:`Loopout`."""
-
-    name: Optional[str] = None
-    """
-    Optional name to give this :any:`Loopout`.
-
-    This is used to interoperate with the dsd DNA sequence design package.
-    """
-
-    label: Optional[DomainLabel] = None
-    """
-    Generic "label" object to associate to this :any:`Loopout`.
-
-    Useful for associating extra information with the :any:`Loopout` that will be serialized, for example,
-    for DNA sequence design. It must be an object (e.g., a dict or primitive type such as str or int) 
-    that is naturally JSON serializable. (Calling 
-    `json.dumps <https://docs.python.org/3/library/json.html#json.dumps>`_
-    on the object should succeed without having to specify a custom encoder.)
-    """
-
-    # not serialized; for efficiency
-    # remove quotes when Py3.6 support dropped
-    _parent_strand: Optional['Strand'] = field(init=False, repr=False, compare=False, default=None)
-
-    def to_json_serializable(self, suppress_indent: bool = True,
-                             **kwargs: Any) -> Union[Dict[str, Any], NoIndent]:
-        dct: Dict[str, Any] = {loopout_key: self.length}
-        if self.name is not None:
-            dct[domain_name_key] = self.name
-        if self.label is not None:
-            dct[domain_label_key] = self.label
-        return NoIndent(dct) if suppress_indent else dct
-
-    @staticmethod
-    def from_json(json_map: Dict[str, Any]) -> 'Loopout':  # remove quotes when Py3.6 support dropped
-        # XXX: this should never fail since we detect whether to call this from_json by the presence
-        # of a length key in json_map
-        length_str = mandatory_field(Loopout, json_map, loopout_key)
-        length = int(length_str)
-        name = json_map.get(domain_name_key)
-        label = json_map.get(domain_label_key)
-        return Loopout(length=length, name=name, label=label)
-
-    def strand(self) -> 'Strand':  # remove quotes when Py3.6 support dropped
-        """
-        :return: The :any:`Strand` that contains this :any:`Loopout`.
-        """
-        if self._parent_strand is None:
-            raise ValueError('_parent_strand has not yet been set')
-        return self._parent_strand
-
-    def __repr__(self) -> str:
-        return f'Loopout(' + \
-               (f'{self.name}, ' if self.name is not None else '') + \
-               f'{self.length}, ' + \
-               (f'{self.label}, ' if self.label is not None else '') + \
-               f')'
-
-    def __str__(self) -> str:
-        return repr(self) if self.name is None else self.name
-
-    def set_name(self, name: str) -> None:
-        """Sets name of this :any:`Loopout`."""
-        self.name = name
-
-    def set_label(self, label: Optional[DomainLabel]) -> None:
-        """Sets label of this :any:`Loopout`."""
-        self.label = label
-
-    def __len__(self) -> int:
-        """Same as :any:`Loopout.dna_length`"""
-        return self.dna_length()
-
-    def dna_length(self) -> int:
-        """Length of this :any:`Loopout`; same as field :py:data:`Loopout.length`."""
-        return self.length
-
-    def dna_sequence(self) -> Optional[str]:
-        """Return DNA sequence of this :any:`Loopout`, or ``None`` if no DNA sequence has been assigned
-        to the :any:`Strand` of this :any:`Loopout`."""
-        if self._parent_strand is None:
-            raise ValueError('_parent_strand has not been set')
-        strand_seq = self._parent_strand.dna_sequence
-        if strand_seq is None:
-            return None
-
-        str_idx_left = self.get_seq_start_idx()
-        str_idx_right = str_idx_left + self.length  # EXCLUSIVE (unlike similar code for Domain)
-        subseq = strand_seq[str_idx_left:str_idx_right]
-        return subseq
-
-    def get_seq_start_idx(self) -> int:
-        """Starting DNA subsequence index for first base of this :any:`Loopout` on its
-        :any:`Strand`'s DNA sequence."""
-        if self._parent_strand is None:
-            raise ValueError('_parent_strand has not been set')
-        domains = self._parent_strand.domains
-        # index of self in parent strand's list of domains
-        self_domain_idx = domains.index(self)
-        # index of self's position within the DNA sequence of parent strand
-        self_seq_idx_start = sum(prev_domain.dna_length()
-                                 for prev_domain in domains[:self_domain_idx])
-        return self_seq_idx_start
-
-
-_wctable = str.maketrans('ACGTacgt', 'TGCAtgca')
-
-
-def wc(seq: str) -> str:
-    """Return reverse Watson-Crick complement of `seq`."""
-    return seq.translate(_wctable)[::-1]
-
-
-@dataclass
-class IDTFields(_JSONSerializable):
-    """Data required when ordering DNA strands from the synthesis company
-    `IDT (Integrated DNA Technologies) <https://www.idtdna.com/>`_.
-    This data is used when automatically generating files used to order DNA from IDT.
-
-    When exporting to IDT files via :py:meth:`Design.write_idt_plate_excel_file`
-    or :py:meth:`Design.write_idt_bulk_input_file`, the field :py:data:`Strand.name` is used for the
-    name if it exists, otherwise a reasonable default is chosen."""
-
-    scale: str = default_idt_scale
-    """Synthesis scale at which to synthesize the strand (third field in IDT bulk input:
-    https://www.idtdna.com/site/order/oligoentry).
-    Choices supplied by IDT at the time this was written: 
-    ``"25nm"``, ``"100nm"``, ``"250nm"``, ``"1um"``, ``"5um"``, 
-    ``"10um"``, ``"4nmU"``, ``"20nmU"``, ``"PU"``, ``"25nmS"``.
-    
-    Optional field.
-    """
-
-    purification: str = default_idt_purification
-    """Purification options (fourth field in IDT bulk input:
-    https://www.idtdna.com/site/order/oligoentry). 
-    Choices supplied by IDT at the time this was written: 
-    ``"STD"``, ``"PAGE"``, ``"HPLC"``, ``"IEHPLC"``, ``"RNASE"``, ``"DUALHPLC"``, ``"PAGEHPLC"``.
-    
-    Optional field.
-    """
-
-    plate: Optional[str] = None
-    """Name of plate in case this strand will be ordered on a 96-well or 384-well plate.
-    
-    Optional field, but non-optional if :py:data:`IDTField.well` is not ``None``.
-    """
-
-    well: Optional[str] = None
-    """Well position on plate in case this strand will be ordered on a 96-well or 384-well plate.
-    
-    Optional field, but non-optional if :py:data:`IDTField.plate` is not ``None``.
-    """
-
-    def __post_init__(self) -> None:
-        _check_idt_string_not_none_or_empty(self.scale, 'scale')
-        _check_idt_string_not_none_or_empty(self.purification, 'purification')
-        if self.plate is None and self.well is not None:
-            raise IllegalDesignError(f'IDTFields.plate cannot be None if IDTFields.well is not None\n'
-                                     f'IDTFields.well = {self.well}')
-        if self.plate is not None and self.well is None:
-            raise IllegalDesignError(f'IDTFields.well cannot be None if IDTFields.plate is not None\n'
-                                     f'IDTFields.plate = {self.plate}')
-
-    def to_json_serializable(self, suppress_indent: bool = True,
-                             **kwargs: Any) -> Union[NoIndent, Dict[str, Any]]:
-        dct: Dict[str, Any] = dict(self.__dict__)
-        if self.plate is None:
-            del dct['plate']
-        if self.well is None:
-            del dct['well']
-        return NoIndent(dct)
-
-    @staticmethod
-    def from_json(json_map: Dict[str, Any]) -> 'IDTFields':
-        scale = mandatory_field(IDTFields, json_map, idt_scale_key)
-        purification = mandatory_field(IDTFields, json_map, idt_purification_key)
-        plate = json_map.get(idt_plate_key)
-        well = json_map.get(idt_well_key)
-        return IDTFields(scale=scale, purification=purification, plate=plate, well=well)
-
-
-def _check_idt_string_not_none_or_empty(value: str, field_name: str) -> None:
-    if value is None:
-        raise IllegalDesignError(f'field {field_name} in IDTFields cannot be None')
-    if len(value) == 0:
-        raise IllegalDesignError(f'field {field_name} in IDTFields cannot be empty')
-
-
-class StrandBuilder(Generic[StrandLabel, DomainLabel]):
-    """
-    Represents a :any:`Strand` that is being built in an existing :any:`Design`.
-
-    This is an intermediate object created when using chained method building by calling
-    :py:meth:`Design.strand`, for example
-
-    .. code-block:: Python
-
-        design.strand(0, 0).to(10).cross(1).to(5).with_modification_5p(mod.biotin_5p).as_scaffold()
-
-    :any:`StrandBuilder` should generally not be created directly.
-    Although it is convenient to use chained method calls, it is also sometimes useful to assign the
-    :any:`StrandBuilder` object into a variable and then call the methods on that variable. For example,
-    this code is equivalent to the above line:
-
-    .. code-block:: Python
-
-        strand_builder = design.strand(0, 0)
-        strand_builder.to(10)
-        strand_builder.cross(1)
-        strand_builder.to(5)
-        strand_builder.with_modification_5p(mod.biotin_5p)
-        strand_builder.as_scaffold()
-    """
-
-    # remove quotes when Py3.6 support dropped
-    def __init__(self, design: 'Design[StrandLabel, DomainLabel]', helix: int, offset: int):
-        self.design: Design[StrandLabel, DomainLabel] = design
-        self.current_helix: int = helix
-        self.current_offset: int = offset
-        # self.loopout_length: Optional[int] = None
-        self._strand: Optional[Strand[StrandLabel, DomainLabel]] = None
-        self.just_moved_to_helix: bool = True
-        self.last_domain: Optional[Domain[DomainLabel]] = None
-
-    @property
-    def strand(self) -> 'Strand[StrandLabel, DomainLabel]':
-        if self._strand is None:
-            raise ValueError('no Strand created yet; make at least one domain first')
-        return self._strand
-
-    # remove quotes when Py3.6 support dropped
-    def cross(self, helix: int, offset: Optional[int] = None, move: Optional[int] = None) \
-            -> 'StrandBuilder[StrandLabel, DomainLabel]':
-        """
-        Add crossover. To have any effect, must be followed by call to :py:meth:`StrandBuilder.to`
-        or :py:meth:`StrandBuilder.move`.
-
-        :param helix: :any:`Helix` to crossover to
-        :param offset: new offset on `helix`. If not specified, defaults to current offset.
-            (i.e., a "vertical" crossover)
-            Mutually excusive with `move`.
-        :param move:
-            Relative distance to new offset on `helix` from current offset.
-            If not specified, defaults to using parameter `offset`.
-            Mutually excusive with `offset`.
-        :return: self
-        """
-        if self._strand is None:
-            raise ValueError('no Strand created yet; make at least one domain first')
-        if move is not None and offset is not None:
-            raise IllegalDesignError('move and offset cannot both be specified:\n'
-                                     f'move:   {move}\n'
-                                     f'offset: {offset}')
-        self.last_domain = None
-        self.current_helix = helix
-        if offset is not None:
-            self.current_offset = offset
-        elif move is not None:
-            self.current_offset += move
-        return self
-
-    # remove quotes when Py3.6 support dropped
-    def loopout(self, helix: int, length: int, offset: Optional[int] = None, move: Optional[int] = None) \
-            -> 'StrandBuilder[StrandLabel, DomainLabel]':
-        """
-        Like :py:meth:`StrandBuilder.cross`, but creates a :any:`Loopout` instead of a crossover.
-
-        :param helix: :any:`Helix` to crossover to
-        :param length: length of :any:`Loopout` to add
-        :param offset: new offset on `helix`. If not specified, defaults to current offset.
-            (i.e., a "vertical" loopout)
-            Mutually excusive with `move`.
-        :param move:
-            Relative distance to new offset on `helix` from current offset.
-            If not specified, defaults to using parameter `offset`.
-            Mutually excusive with `offset`.
-        :return: self
-        """
-        if self._strand is None:
-            raise ValueError('no Strand created yet; make at least one domain first')
-        self.cross(helix, offset=offset, move=move)
-        self.design.append_domain(self._strand, Loopout(length))
-        return self
-
-    # remove quotes when Py3.6 support dropped
-    def move(self, delta: int) -> 'StrandBuilder[StrandLabel, DomainLabel]':
-        """
-        Extends this :any:`StrandBuilder` on the current helix to offset given by the current offset
-        plus `delta`, which adds a new :any:`Domain` to the :any:`Strand` being built. This is a
-        "relative move", whereas :py:meth:`StrandBuilder.to` and :py:meth:`StrandBuilder.update_to`
-        are "absolute moves".
-
-        This updates the underlying :any:`Design` with a new :any:`Domain`,
-        and if :py:meth:`StrandBuilder.loopout` was last called on this :any:`StrandBuilder`,
-        also a new :any:`Loopout`.
-
-        If two instances of :py:meth:`StrandBuilder.move` are chained together, this creates two domains
-        on the same helix. The two offsets must move in the same direction. In other words, if we call
-        ``.move(o1).move(o2)``, then ``o1`` and ``o2`` must be either both negative or both positive.
-
-        :param delta:
-            Distance to new offset to extend to, compared to current offset.
-            If less than current offset, the new :any:`Domain` is reverse, otherwise it is forward.
-        :return: self
-        """
-        return self.to(self.current_offset + delta)
-
-    # remove quotes when Py3.6 support dropped
-    def to(self, offset: int) -> 'StrandBuilder[StrandLabel, DomainLabel]':
-        """
-        Extends this :any:`StrandBuilder` on the current helix to offset `offset`,
-        which adds a new :any:`Domain` to the :any:`Strand` being built. This is an
-        "absolute move", whereas :py:meth:`StrandBuilder.move` is a "relative move".
-
-        This updates the underlying :any:`Design` with a new :any:`Domain`,
-        and if :py:meth:`StrandBuilder.loopout` was last called on this :any:`StrandBuilder`,
-        also a new :any:`Loopout`.
-
-        If two instances of :py:meth:`StrandBuilder.to` are chained together, this creates two domains
-        on the same helix. The two offsets must move in the same direction. In other words, if the starting
-        offset is ``s``, and we call ``.to(o1).to(o2)``, then either ``s < o1 < o2`` or ``o2 < o1 < s``
-        must be true.
-
-        To simply change the current offset after calling :py:meth:`StrandBuilder.to`, without creating
-        a new Domain, call :py:meth:`StrandBuilder.update_to` instead.
-
-        :param offset: new offset to extend to. If less than current offset,
-            the new :any:`Domain` is reverse, otherwise it is forward.
-        :return: self
-        """
-        if self.last_domain and ((self.last_domain.forward and offset < self.current_offset) or (
-                not self.last_domain.forward and offset > self.current_offset)):
-            raise IllegalDesignError('offsets must be monotonic '
-                                     '(strictly increasing or strictly decreasing) '
-                                     'when calling to() twice in a row')
-
-        if offset > self.current_offset:
-            forward = True
-            start = self.current_offset
-            end = offset
-        elif offset < self.current_offset:
-            forward = False
-            start = offset
-            end = self.current_offset
-        else:
-            raise IllegalDesignError(f'offset {offset} cannot be equal to current offset')
-
-        domain: Domain[DomainLabel] = Domain(helix=self.current_helix, forward=forward, start=start, end=end)
-        self.last_domain = domain
-        if self._strand is not None:
-            self.design.append_domain(self._strand, domain)
-        else:
-            self._strand = Strand(domains=[domain])
-            self.design.add_strand(self._strand)
-
-        self.current_offset = offset
-
-        return self
-
-    # remove quotes when Py3.6 support dropped
-    def update_to(self, offset: int) -> 'StrandBuilder[StrandLabel, DomainLabel]':
-        """
-        Like :py:meth:`StrandBuilder.to`, but changes the current offset without creating
-        a new :any:`Domain`. So unlike :py:meth:`StrandBuilder.to`, several consecutive calls to
-        :py:meth:`StrandBuilder.update_to` are equivalent to only making the final call. This is an
-        "absolute move", whereas :py:meth:`StrandBuilder.move` is a "relative move".
-
-        If :py:meth:`StrandBuilder.cross` or :py:meth:`StrandBuilder.loopout` was just called,
-        then :py:meth:`StrandBuilder.to` and :py:meth:`StrandBuilder.update_to` have the same effect.
-
-        :param offset: new offset to extend to. If less than offset of the last call to
-            :py:meth:`StrandBuilder.cross` or :py:meth:`StrandBuilder.loopout`,
-            the new :any:`Domain` is reverse, otherwise it is forward.
-        :return: self
-        """
-        if not self.last_domain:
-            return self.to(offset)
-
-        domain = self.last_domain
-        if (self.last_domain.forward and offset < self.current_offset) or (
-                not self.last_domain.forward and offset > self.current_offset):
-            raise IllegalDesignError(f'when calling ')
-
-        if domain.forward:
-            domain.set_end(offset)
-        else:
-            domain.set_start(offset)
-
-        self.current_offset = offset
-
-        return self
-
-    def as_circular(self) -> 'StrandBuilder[StrandLabel, DomainLabel]':
-        """
-        Makes :any:`Strand` being built circular.
-
-        :return: self
-        """
-        if self._strand is None:
-            raise ValueError('no Strand created yet; make at least one domain first')
-        self._strand.set_circular()
-        return self
-
-    # remove quotes when Py3.6 support dropped
-    def as_scaffold(self) -> 'StrandBuilder[StrandLabel, DomainLabel]':
-        """
-        Makes :any:`Strand` being built a scaffold.
-
-        :return: self
-        """
-        if self._strand is None:
-            raise ValueError('no Strand created yet; make at least one domain first')
-        self._strand.set_scaffold(True)
-        return self
-
-    def with_idt(self, scale: str = default_idt_scale,
-                 purification: str = default_idt_purification,
-                 plate: Optional[str] = None, well: Optional[str] = None) \
-            -> 'StrandBuilder[StrandLabel, DomainLabel]':
-        """
-        Gives :any:`IDTFields` value to :any:`Strand` being built.
-        Only a name is required; other fields are given reasonable default values.
-
-        :param scale:
-            see :py:data:`IDTFields.scale`
-        :param purification:
-            see :py:data:`IDTFields.purification`
-        :param plate:
-            see :py:data:`IDTFields.plate`
-        :param well:
-            see :py:data:`IDTFields.well`
-        :return: self
-        """
-        if self._strand is None:
-            raise ValueError('no Strand created yet; make at least one domain first')
-        self._strand.idt = IDTFields(scale=scale, purification=purification,
-                                     plate=plate, well=well)
-        return self
-
-    # remove quotes when Py3.6 support dropped
-    def with_modification_5p(self, mod: Modification5Prime) -> 'StrandBuilder[StrandLabel, DomainLabel]':
-        """
-        Sets Strand being built to have given 5' modification.
-
-        :param mod: 5' modification
-        :return: self
-        """
-        if self._strand is None:
-            raise ValueError('no Strand created yet; make at least one domain first')
-        self._strand.set_modification_5p(mod)
-        return self
-
-    # remove quotes when Py3.6 support dropped
-    def with_modification_3p(self, mod: Modification3Prime) -> 'StrandBuilder[StrandLabel, DomainLabel]':
-        """
-        Sets Strand being built to have given 3' modification.
-
-        :param mod: 3' modification
-        :return: self
-        """
-        if self._strand is None:
-            raise ValueError('no Strand created yet; make at least one domain first')
-        self._strand.set_modification_3p(mod)
-        return self
-
-    # remove quotes when Py3.6 support dropped
-    def with_modification_internal(self, idx: int, mod: ModificationInternal, warn_on_no_dna: bool) \
-            -> 'StrandBuilder[StrandLabel, DomainLabel]':
-        """
-        Sets Strand being built to have given internal modification.
-
-        :param idx: idx along DNA sequence of internal modification
-        :param mod: internal modification
-        :param warn_on_no_dna: whether to print warning to screen if DNA has not been assigned
-        :return: self
-        """
-        if self._strand is None:
-            raise ValueError('no Strand created yet; make at least one domain first')
-        self._strand.set_modification_internal(idx, mod, warn_on_no_dna)
-        return self
-
-    # remove quotes when Py3.6 support dropped
-    def with_color(self, color: Color) -> 'StrandBuilder[StrandLabel, DomainLabel]':
-        """
-        Sets Strand being built to have given color.
-
-        :param color: color to set for Strand
-        :return: self
-        """
-        if self._strand is None:
-            raise ValueError('no Strand created yet; make at least one domain first')
-        self._strand.set_color(color)
-        return self
-
-    # remove quotes when Py3.6 support dropped
-    def with_sequence(self, sequence: str, assign_complement: bool = True) \
-            -> 'StrandBuilder[StrandLabel, DomainLabel]':
-        """
-        Assigns `sequence` as DNA sequence of the :any:`Strand` being built.
-        This should be done after the :any:`Strand`'s structure is done being built, e.g.,
-
-        .. code-block:: Python
-
-            design.strand(0, 0).to(10).cross(1).to(5).with_sequence('AAAAAAAAAACGCGC')
-
-        :param sequence: the DNA sequence to assign to the :any:`Strand`
-        :param assign_complement: whether to automatically assign the complement to existing :any:`Strand`'s
-            bound to this :any:`Strand`. This has the same meaning as the parameter `assign_complement` in
-            :py:meth:`Design.assign_dna`.
-        :return: self
-        """
-        if self._strand is None:
-            raise ValueError('no Strand created yet; make at least one domain first')
-        self.design.assign_dna(strand=self._strand, sequence=sequence, assign_complement=assign_complement)
-        return self
-
-    # remove quotes when Py3.6 support dropped
-    def with_domain_sequence(self, sequence: str, assign_complement: bool = True) \
-            -> 'StrandBuilder[StrandLabel, DomainLabel]':
-        """
-        Assigns `sequence` as DNA sequence of the most recently created :any:`Domain` in
-        the :any:`Strand` being built. This should be called immediately after a :any:`Domain` is created
-        via a call to
-        :py:meth:`StrandBuilder.to`,
-        :py:meth:`StrandBuilder.update_to`,
-        or
-        :py:meth:`StrandBuilder.loopout`, e.g.,
-
-        .. code-block:: Python
-
-            design.strand(0, 5).to(8).with_domain_sequence('AAA')\\
-                .cross(1).to(5).with_domain_sequence('TTT')\\
-                .loopout(2, 4).with_domain_sequence('CCCC')\\
-                .to(10).with_domain_sequence('GGGGG')
-
-        :param sequence: the DNA sequence to assign to the :any:`Domain`
-        :param assign_complement: whether to automatically assign the complement to existing :any:`Strand`'s
-            bound to this :any:`Strand`. This has the same meaning as the parameter `assign_complement` in
-            :py:meth:`Design.assign_dna`.
-        :return: self
-        """
-        if self._strand is None:
-            raise ValueError('no Strand created yet; make at least one domain first')
-        last_domain = self._strand.domains[-1]
-        self.design.assign_dna(strand=self._strand, sequence=sequence, domain=last_domain,
-                               assign_complement=assign_complement)
-        return self
-
-    # remove quotes when Py3.6 support dropped
-    def with_name(self, name: str) -> 'StrandBuilder[StrandLabel, DomainLabel]':
-        """
-        Assigns `name` as name of the :any:`Strand` being built.
-
-        .. code-block:: Python
-
-            design.strand(0, 0).to(10).cross(1).to(5).with_name('scaffold')
-
-        :param name: name to assign to the :any:`Strand`
-        :return: self
-        """
-        if self._strand is None:
-            raise ValueError('no Strand created yet; make at least one domain first')
-        self._strand.set_name(name)
-        return self
-
-    # remove quotes when Py3.6 support dropped
-    def with_label(self, label: StrandLabel) -> 'StrandBuilder[StrandLabel, DomainLabel]':
-        """
-        Assigns `label` as label of the :any:`Strand` being built.
-
-        .. code-block:: Python
-
-            design.strand(0, 0).to(10).cross(1).to(5).with_label('scaffold')
-
-        :param label: label to assign to the :any:`Strand`
-        :return: self
-        """
-        if self._strand is None:
-            raise AssertionError('_strand cannot be None')
-        self._strand.set_label(label)
-        return self
-
-    # remove quotes when Py3.6 support dropped
-    def with_domain_name(self, name: str) -> 'StrandBuilder[StrandLabel, DomainLabel]':
-        """
-        Assigns `name` as of the most recently created :any:`Domain` or :any:`Loopout` in
-        the :any:`Strand` being built. This should be called immediately after a :any:`Domain` is created
-        via a call to
-        :py:meth:`StrandBuilder.to`,
-        :py:meth:`StrandBuilder.update_to`,
-        or
-        :py:meth:`StrandBuilder.loopout`, e.g.,
-
-        .. code-block:: Python
-
-            design.strand(0, 0).to(10).with_domain_name('dom1*').cross(1).to(5).with_domain_name('dom1')
-
-        :param name: name to assign to the most recently created :any:`Domain` or :any:`Loopout`
-        :return: self
-        """
-        if self._strand is None:
-            raise ValueError('no Strand created yet; make at least one domain first')
-        last_domain = self._strand.domains[-1]
-        last_domain.set_name(name)
-        return self
-
-    # remove quotes when Py3.6 support dropped
-    def with_domain_label(self, label: DomainLabel) -> 'StrandBuilder[StrandLabel, DomainLabel]':
-        """
-        Assigns `label` as label of the most recently created :any:`Domain` or :any:`Loopout` in
-        the :any:`Strand` being built. This should be called immediately after a :any:`Domain` is created
-        via a call to
-        :py:meth:`StrandBuilder.to`,
-        :py:meth:`StrandBuilder.update_to`,
-        or
-        :py:meth:`StrandBuilder.loopout`, e.g.,
-
-        .. code-block:: Python
-
-            design.strand(0, 5).to(8).with_domain_label('domain 1')\\
-                .cross(1).to(5).with_domain_label('domain 2')\\
-                .loopout(2, 4).with_domain_label('domain 3')\\
-                .to(10).with_domain_label('domain 4')
-
-        :param label: label to assign to the :any:`Domain` or :any:`Loopout`
-        :return: self
-        """
-        if self._strand is None:
-            raise ValueError('no Strand created yet; make at least one domain first')
-        last_domain = self._strand.domains[-1]
-        last_domain.set_label(label)
-        return self
-
-
-@dataclass
-class Strand(_JSONSerializable, Generic[StrandLabel, DomainLabel]):
-    """
-    Represents a single strand of DNA.
-
-    Each maximal portion that is continguous on a single :any:`Helix` is a :any:`Domain`.
-    Crossovers from one :any:`Helix` to another are implicitly from the 3' end of one of this
-    Strand's :any:`Domain`'s to the 5' end of the next :any:`Domain`.
-
-    A portion of the :any:`Strand` not associated to any :any:`Helix` is represented by a :any:`Loopout`.
-    Two :any:`Loopout`'s cannot occur consecutively on a :any:`Strand`, nor can a :any:`Strand`
-    contain only a :any:`Loopout` but no :any:`Domain`.
-
-
-    One can set the strand to be a scaffold in the constructor:
-
-    .. code-block:: Python
-
-        import scadnano as sc
-
-        scaffold_domains = [ ... ]
-        scaffold_strand = sc.Strand(domains=scaffold_domains, is_scaffold=True)
-
-    or by calling :py:meth:`Strand.set_scaffold` on the :any:`Strand` object:
-
-    .. code-block:: Python
-
-        import scadnano as sc
-
-        scaffold_domains = [ ... ]
-        scaffold_strand = sc.Strand(domains=scaffold_domains)
-        scaffold_strand.set_scaffold()
-
-    Both will give the strand the same color that
-    `cadnano <https://cadnano.org>`_
-    uses for the scaffold.
-    """
-
-    domains: List[Union[Domain[DomainLabel], Loopout[DomainLabel]]]
-    """:any:`Domain`'s (or :any:`Loopout`'s) composing this Strand. 
-    Each :any:`Domain` is contiguous on a single :any:`Helix` 
-    and could be either single-stranded or double-stranded, 
-    whereas each :any:`Loopout` is single-stranded and has no associated :any:`Helix`."""
-
-    circular: bool = False
-    """If True, this :any:`Strand` is circular and has no 5' or 3' end. Although there is still a 
-    first and last :any:`Domain`, we interpret there to be a crossover from the 3' end of the last domain
-    to the 5' end of the first domain, and any circular permutation of :py:data:`Strand.domains` 
-    should result in a functionally equivalent :any:`Strand`. It is illegal to have a 
-    :any:`Modification5Prime` or :any:`Modification3Prime` on a circular :any:`Strand`."""
-
-    dna_sequence: Optional[str] = None
-    """Do not assign directly to this field. Always use :any:`Design.assign_dna` 
-    (for complementarity checking) or :any:`Strand.set_dna_sequence` 
-    (without complementarity checking, to allow mismatches)."""
-
-    color: Optional[Color] = None
-    """Color to show this strand in the main view. If not specified in the constructor,
-    a color is assigned by cycling through a list of defaults given by 
-    :meth:`ColorCycler.colors`"""
-
-    idt: Optional[IDTFields] = None
-    """Fields used when ordering strands from the synthesis company IDT 
-    (Integrated DNA Technologies, Coralville, IA). If present (i.e., not equal to :const:`None`)
-    then the method :py:meth:`Design.write_idt_bulk_input_file` can be called to automatically
-    generate an text file for ordering strands in test tubes: 
-    https://www.idtdna.com/site/order/oligoentry,
-    as can the method :py:meth:`Design.write_idt_plate_excel_file` for writing a Microsoft Excel 
-    file that can be uploaded to IDT's website for describing DNA sequences to be ordered in 96-well
-    or 384-well plates."""
-
-    is_scaffold: bool = False
-    """Indicates whether this :any:`Strand` is a scaffold for a DNA origami. If any :any:`Strand` in a
-    :any:`Design` is a scaffold, then the design is considered a DNA origami design."""
-
-    modification_5p: Optional[Modification5Prime] = None
-    """
-    5' modification; None if there is no 5' modification. 
-    Illegal to have if :py:data:`Strand.circular` is True.
-    """
-
-    modification_3p: Optional[Modification3Prime] = None
-    """
-    3' modification; None if there is no 3' modification. 
-    Illegal to have if :py:data:`Strand.circular` is True.
-    """
-
-    modifications_int: Dict[int, ModificationInternal] = field(default_factory=dict)
-    """:any:`Modification`'s to the DNA sequence (e.g., biotin, Cy3/Cy5 fluorphores). Maps offset to 
-    modification. If the internal modification is attached to a base 
-    (e.g., internal biotin, /iBiodT/ from IDT), 
-    then the offset is that of the base.
-    If it goes between two bases 
-    (e.g., internal Cy3, /iCy3/ from IDT),
-    then the offset is that of the previous base, 
-    e.g., to put a Cy3 between bases at offsets 3 and 4, the offset should be 3. 
-    So for an internal modified base on a sequence of length n, the allowed offsets are 0,...,n-1,
-    and for an internal modification that goes between bases, the allowed offsets are 0,...,n-2."""
-
-    name: Optional[str] = None
-    """
-    Optional name to give the strand. If specified it is shown on mouseover in the scadnano web interface.
-    
-    This is used to interoperate with the dsd DNA sequence design package.
-    """
-
-    label: Optional[StrandLabel] = None
-    """Generic "label" object to associate to this :any:`Strand`.
-    
-    Useful for associating extra information with the Strand that will be serialized, for example,
-    for DNA sequence design. It must be an object (e.g., a dict or primitive type such as str or int) 
-    that is naturally JSON serializable. (Calling ``json.dumps`` on the object should succeed without
-    having to specify a custom encoder.)
-    """
-
-    # not serialized; efficient way to see a list of all domains on a given helix
-    _helix_idx_domain_map: Dict[int, List[Domain[DomainLabel]]] = field(
-        init=False, repr=False, compare=False, default_factory=dict)
-
-    def __post_init__(self) -> None:
-        self._helix_idx_domain_map = defaultdict(list)
-
-        self.set_domains(self.domains)
-
-        self._ensure_modifications_legal()
-        self._ensure_domains_nonoverlapping()
-
-    def to_json_serializable(self, suppress_indent: bool = True, **kwargs: Any) -> Dict[str, Any]:
-        dct: Dict[str, Any] = OrderedDict()
-        if self.name is not None:
-            dct[strand_name_key] = self.name
-        if self.circular:
-            dct[circular_key] = self.circular
-        if self.color is not None:
-            dct[color_key] = self.color.to_json_serializable(suppress_indent)
-        if self.dna_sequence is not None:
-            dct[dna_sequence_key] = self.dna_sequence
-        if self.idt is not None:
-            dct[idt_key] = self.idt.to_json_serializable(suppress_indent)
-        dct[domains_key] = [domain.to_json_serializable(suppress_indent) for domain in self.domains]
-        if hasattr(self, is_scaffold_key) and self.is_scaffold:
-            dct[is_scaffold_key] = self.is_scaffold
-
-        if self.modification_5p is not None:
-            dct[modification_5p_key] = self.modification_5p.id
-        if self.modification_3p is not None:
-            dct[modification_3p_key] = self.modification_3p.id
-        if len(self.modifications_int) > 0:
-            mods_dict = {}
-            for offset, mod in self.modifications_int.items():
-                mods_dict[f"{offset}"] = mod.id
-            dct[modifications_int_key] = NoIndent(mods_dict) if suppress_indent else mods_dict
-
-        if self.label is not None:
-            dct[strand_label_key] = NoIndent(self.label) if suppress_indent else self.label
-
-        return dct
-
-    @staticmethod
-    def from_json(json_map: dict) -> 'Strand':  # remove quotes when Py3.6 support dropped
-        domain_jsons = mandatory_field(Strand, json_map, domains_key, *legacy_domains_keys)
-        if len(domain_jsons) == 0:
-            raise IllegalDesignError(f'{domains_key} list cannot be empty')
-
-        domains: List[Union[Domain, Loopout]] = []
-        for domain_json in domain_jsons:
-            if loopout_key in domain_json:
-                domains.append(Loopout.from_json(domain_json))
-            else:
-                domains.append(Domain.from_json(domain_json))
-        if isinstance(domains[0], Loopout):
-            raise IllegalDesignError('Loopout at beginning of Strand not supported')
-        if isinstance(domains[-1], Loopout):
-            raise IllegalDesignError('Loopout at end of Strand not supported')
-
-        is_scaffold = json_map.get(is_scaffold_key, False)
-        circular = json_map.get(circular_key, False)
-
-        dna_sequence = optional_field(None, json_map, dna_sequence_key, *legacy_dna_sequence_keys)
-
-        color_str = json_map.get(color_key,
-                                 default_scaffold_color if is_scaffold else default_strand_color)
-        if isinstance(color_str, int):
-            def decimal_int_to_hex(d: int) -> str:
-                return "#" + "{0:#08x}".format(d, 8)[2:]  # type: ignore
-
-            color_str = decimal_int_to_hex(color_str)
-        color = Color(hex_string=color_str)
-
-        label = json_map.get(strand_label_key)
-
-        name = json_map.get(strand_name_key)
-
-        idt_dict: Optional[dict] = json_map.get(idt_key)
-        idt = None if idt_dict is None else IDTFields.from_json(idt_dict)
-        # legacy:
-        # if no name is specified, but there's a name field in idt, then use that as the Strand's name
-        if name is None and idt_dict is not None and idt_name_key in idt_dict:
-            name = idt_dict[idt_name_key]
-
-        return Strand(
-            domains=domains,
-            dna_sequence=dna_sequence,
-            circular=circular,
-            color=color,
-            idt=idt,
-            is_scaffold=is_scaffold,
-            name=name,
-            label=label,
-        )
-
-    def __eq__(self, other: Any) -> bool:  # remove quotes when Py3.6 support dropped
-        if not isinstance(other, Strand):
-            return False
-        return self.domains == other.domains
-
-    def __hash__(self) -> int:
-        return hash(self.domains)
-
-    def __str__(self) -> str:
-        return repr(self) if self.name is None else self.name
-
-    def rotate_domains(self, rotation: int, forward: bool = True) -> None:
-        """
-        "Rotates" the strand by replacing domains with a circular rotation, e.g., if the domains are
-
-        A, B, C, D, E, F
-
-        then ``strand.rotate_domains(2)`` makes the :any:`Strand` have the same domains, but in this order:
-        
-        E, F, A, B, C, D
-        
-        and  ``strand.rotate_domains(2, forward=False)`` makes
-        
-        C, D, E, F, A, B
-
-        :param rotation:
-            Amount to rotate domains.
-        :param forward:
-            Whether to move domains forward (wrapping off 3' end back to 5' end) or backward (wrapping off
-            5' end back to 3' end).
-        """
-        idx = rotation if not forward else len(self.domains) - rotation
-        self.domains = self.domains[idx:] + self.domains[:idx]
-
-    def set_scaffold(self, is_scaf: bool = True) -> None:
-        """Sets this :any:`Strand` as a scaffold. Alters color to default scaffold color.
-
-        If `is_scaf` == ``False``, sets this strand as not a scaffold, and leaves the color alone."""
-        self.is_scaffold = is_scaf
-        if is_scaf:
-            self.color = default_scaffold_color
-
-    def set_name(self, name: str) -> None:
-        """Sets name of this :any:`Strand`."""
-        self.name = name
-
-    def set_label(self, label: Any) -> None:
-        """Sets label of this :any:`Strand`."""
-        self.label = label
-
-    def set_color(self, color: Color) -> None:
-        """Sets color of this :any:`Strand`."""
-        self.color = color
-
-    def set_circular(self, circular: bool = True) -> None:
-        """
-        Sets this to be a circular :any:`Strand` (or non-circular if optional parameter is False).
-
-        :param circular:
-            whether to make this :any:`Strand` circular (True) or linear (False)
-        :raises StrandError:
-            if this :any:`Strand` has a 5' or 3' modification
-        """
-        if circular and self.modification_5p is not None:
-            raise StrandError(self, "cannot have a 5' modification on a circular strand")
-        if circular and self.modification_3p is not None:
-            raise StrandError(self, "cannot have a 3' modification on a circular strand")
-        self.circular = circular
-
-    def set_linear(self) -> None:
-        """
-        Makes this a linear (non-circular) :any:`Strand`. Equivalent to calling
-        `self.set_circular(False)`.
-        """
-        self.set_circular(False)
-
-    def set_domains(self, domains: Iterable[Union[Domain[DomainLabel], Loopout[DomainLabel]]]) -> None:
-        """
-        Sets the :any:`Domain`'s/:any:`Loopout`'s of this :any:`Strand` to be `domains`,
-        which can contain a mix of :any:`Domain`'s and :any:`Loopout`'s,
-        just like the field :py:data:`Strand.domains`.
-
-        :param domains:
-            The new sequence of :any:`Domain`'s/:any:`Loopout`'s to use for this :any:`Strand`.
-        :raises StrandError:
-            if domains has two consecutive :any:`Loopout`'s, consists of just a single :any:`Loopout`'s,
-            or starts or ends with a :any:`Loopout`
-        """
-        self.domains = domains if isinstance(domains, list) else list(domains)
-
-        for domain in self.domains:
-            if isinstance(domain, Domain):
-                self._helix_idx_domain_map[domain.helix].append(domain)
-
-        for domain in self.domains:
-            domain._parent_strand = self
-
-        if len(self.domains) == 1:
-            if isinstance(self.domains[0], Loopout):
-                raise StrandError(self, 'strand cannot have a single Loopout as its only domain')
-
-        if len(self.domains) == 0:
-            raise StrandError(self, 'domains cannot be empty')
-
-        for domain1, domain2 in _pairwise(self.domains):
-            if isinstance(domain1, Loopout) and isinstance(domain2, Loopout):
-                raise StrandError(self, 'cannot have two consecutive Loopouts in a strand')
-
-        if isinstance(self.domains[0], Loopout):
-            raise StrandError(self, 'strand cannot begin with a loopout')
-
-        if isinstance(self.domains[-1], Loopout):
-            raise StrandError(self, 'strand cannot end with a loopout')
-
-    def idt_export_name(self, unique_names: bool = False) -> str:
-        """
-        :param unique_names:
-            If True and default name is used,
-            enforces that strand names must be unique by encoding the forward/reverse Boolean
-            into the name.
-            If False (the default), uses cadnano's exact naming convention, which allows two strands
-            to have the same default name, if they begin and end at the same (helix,offset) pair (but
-            point in opposite directions at each).
-            Has no effect if :py:data:`Strand.idt` or :py:data:`Strand.name` are defined;
-            if those are used, they must be explicitly set to be unique.
-        :return:
-            If :py:data:`Strand.name` is not None, return :py:data:`Strand.name`,
-            otherwise return the result of :py:meth:`Strand.default_export_name`
-            with parameter `unique_names`.
-        """
-        return self.name if self.name is not None else self.default_export_name(unique_names)
-
-    def default_export_name(self, unique_names: bool = False) -> str:
-        """
-        Returns a default name to use when exporting the DNA sequence.
-        Uses cadnano's naming
-        convention of, for example `'ST2[5]4[10]'` to indicate a strand that starts at helix 2, offset 5,
-        and ends at helix 4, offset 10. Note that this naming convention is not unique: two strands in
-        the system could share this name. To ensure it is unique, set the parameter `unique_names` to True,
-        which will modify the name with forward/reverse information from the first domain that uniquely
-        identifies the strand, e.g., `'ST2[5]F4[10]'` or `'ST2[5]R4[10]'`.
-
-        If the strand is a scaffold (i.e., if :py:data:`Strand.is_scaffold` is True),
-        then the name will begin with `'SCAF'` instead of `'ST'`.
-
-        :param unique_names:
-            If True, enforces that strand names must be unique by encoding the forward/reverse Boolean
-            into the name.
-            If False (the default), uses cadnano's exact naming convention, which allows two strands
-            to have the same default name, if they begin and end at the same (helix,offset) pair (but
-            point in opposite directions at each).
-        :return:
-            default name to export
-            (used, for example, by idt DNA export methods :py:meth:`Design.write_idt_plate_excel_file`
-            and :py:meth:`Design.write_idt_bulk_input_file`
-            if :py:data:`Strand.name` and :py:data:`Strand.idt.name` are both not set)
-        """
-        start_helix = self.first_bound_domain().helix
-        end_helix = self.last_bound_domain().helix
-        start_offset = self.first_bound_domain().offset_5p()
-        end_offset = self.last_bound_domain().offset_3p()
-        forward_str = 'F' if self.first_bound_domain().forward else 'R'
-        if not unique_names:
-            forward_str = ''
-        name = f'{start_helix}[{start_offset}]{forward_str}{end_helix}[{end_offset}]'
-        return f'SCAF{name}' if self.is_scaffold else f'ST{name}'
-
-    def set_modification_5p(self, mod: Modification5Prime = None) -> None:
-        """Sets 5' modification to be `mod`. `mod` cannot be non-None if :any:`Strand.circular` is True."""
-        if self.circular and mod is not None:
-            raise StrandError(self, "cannot have a 5' modification on a circular strand")
-        self.modification_5p = mod
-
-    def set_modification_3p(self, mod: Modification3Prime = None) -> None:
-        """Sets 3' modification to be `mod`. `mod` cannot be non-None if :any:`Strand.circular` is True."""
-        if self.circular and mod is not None:
-            raise StrandError(self, "cannot have a 3' modification on a circular strand")
-        self.modification_3p = mod
-
-    def remove_modification_5p(self) -> None:
-        """Removes 5' modification."""
-        self.modification_5p = None
-
-    def remove_modification_3p(self) -> None:
-        """Removes 3' modification."""
-        self.modification_3p = None
-
-    def set_modification_internal(self, idx: int, mod: ModificationInternal,
-                                  warn_on_no_dna: bool = True) -> None:
-        """Adds internal modification `mod` at given DNA index `idx`."""
-        if idx < 0:
-            raise IllegalDesignError('idx of modification must be nonnegative')
-        if idx >= self.dna_length():
-            raise IllegalDesignError(f'idx of modification must be at most length of DNA: '
-                                     f'{self.dna_length()}')
-        if self.dna_sequence is not None:
-            if mod.allowed_bases is not None and self.dna_sequence[idx] not in mod.allowed_bases:
-                raise IllegalDesignError(f'only bases {",".join(mod.allowed_bases)} are allowed at '
-                                         f'index {idx}, but sequence has base {self.dna_sequence[idx]} '
-                                         f'\nDNA sequence: {self.dna_sequence}'
-                                         f'\nmodification: {mod}')
-        elif warn_on_no_dna:
-            print('WARNING: no DNA sequence has been assigned, so certain error checks on the internal '
-                  'modification were not done. To be safe, first assign DNA, then add the modifications.')
-        self.modifications_int[idx] = mod
-
-    def remove_modification_internal(self, idx: int) -> None:
-        """Removes internal modification at given DNA index `idx`."""
-        if idx in self.modifications_int:
-            del self.modifications_int[idx]
-
-    def first_domain(self) -> Domain:
-        """First domain on this :any:`Strand`."""
-        domain = self.domains[0]
-        if isinstance(domain, Loopout):
-            raise StrandError(self, 'cannot have loopout as first domain on strand')
-        return domain
-
-    def last_domain(self) -> Domain:
-        """Last domain on this :any:`Strand`."""
-        domain = self.domains[-1]
-        if isinstance(domain, Loopout):
-            raise StrandError(self, 'cannot have loopout as last domain on strand')
-        return domain
-
-    def set_dna_sequence(self, sequence: str) -> None:
-        """Set this :any:`Strand`'s DNA sequence to `seq`
-        WITHOUT checking for complementarity with overlapping
-        :any:`Strand`'s or automatically assigning their sequences.
-        To assign a sequence to a :any:`Strand` and have the overlapping
-        :any:`Strand`'s automatically have the appropriate Watson-Crick complements assigned,
-        use :any:`Design.assign_dna`.
-
-        All whitespace in `sequence` is removed,
-        and lowercase bases 'a', 'c', 'g', 't' are converted to uppercase.
-
-        `sequence`, after all whitespace is removed, must be exactly the same length as
-        :py:meth:`Strand.dna_length`.
-        Wildcard symbols (:py:const:`DNA_case_wildcard`) are allowed to leave part of the DNA unassigned.
-        """
-        trimmed_seq = _remove_whitespace_and_uppercase(sequence)
-        if len(trimmed_seq) != self.dna_length():
-            domain = self.first_domain()
-            raise StrandError(self, f"strand starting at helix {domain.helix} offset {domain.offset_5p()} "
-                                    f"has length {self.dna_length()}, but you attempted to assign a "
-                                    f"DNA sequence of length {len(trimmed_seq)}: {sequence}")
-        self.dna_sequence = trimmed_seq
-
-    def dna_length(self) -> int:
-        """Return sum of DNA length of :any:`Domain`'s and :any:`Loopout`'s of this :any:`Strand`."""
-        acc = 0
-        for domain in self.domains:
-            acc += domain.dna_length()
-        return acc
-
-    def bound_domains(self) -> List[Domain]:
-        """:any:`Domain`'s of this :any:`Strand` that are not :any:`Loopout`'s."""
-        return [domain for domain in self.domains if isinstance(domain, Domain)]
-
-    def offset_5p(self) -> int:
-        """5' offset of this entire :any:`Strand`, INCLUSIVE."""
-        return self.first_domain().offset_5p()
-
-    def offset_3p(self) -> int:
-        """3' offset of this entire :any:`Strand`, INCLUSIVE."""
-        return self.last_domain().offset_3p()
-
-    def overlaps(self, other: 'Strand') -> bool:  # remove quotes when Py3.6 support dropped
-        """Indicates whether `self` overlaps `other_strand`, meaning that the set of offsets occupied
-        by `self` has nonempty intersection with those occupied by `other_strand`."""
-        for domain_self in self.bound_domains():
-            for domain_other in other.bound_domains():
-                if domain_self.overlaps(domain_other):
-                    return True
-        return False
-
-    def assign_dna_complement_from(self, other: 'Strand') -> None:  # remove quotes when Py3.6 support dropped
-        """Assuming a DNA sequence has been assigned to `other`, assign its Watson-Crick
-        complement to the portions of this Strand that are bound to `other`.
-
-        Generally this is not called directly; use :py:meth:`Design.assign_dna` to assign
-        a DNA sequence to a :any:`Strand`. The method :py:meth:`Design.assign_dna` will calculate
-        which other :any:`Strand`'s need
-        to be assigned via :py:meth:`Strand.assign_dna_complement_from`.
-
-        However, it is permitted to assign the field :py:data:`Strand.dna_sequence` directly
-        via the method :py:meth:`Strand.set_dna_sequence`.
-        This is used, for instance, to assign a DNA sequence to a :any:`Strand` bound to another
-        :any:`Strand`
-        with an assigned DNA sequence where they overlap. In this case no error checking
-        about sequence complementarity is done. This can be used to intentionally assign *mismatching*
-        DNA sequences to :any:`Strand`'s that are bound on a :any:`Helix`."""
-
-        already_assigned = self.dna_sequence is not None
-
-        # put DNA sequences to assign to domains in List, one position per domain
-        strand_complement_builder: List[str] = []
-        if already_assigned:
-            for domain in self.domains:
-                domain_seq = domain.dna_sequence()
-                if domain_seq is None:
-                    raise ValueError(f'no DNA sequence has been assigned to {self}')
-                strand_complement_builder.append(domain_seq)
-        else:
-            for domain in self.domains:
-                wildcards = DNA_base_wildcard * domain.dna_length()
-                strand_complement_builder.append(wildcards)
-
-        for (domain_idx, domain_self) in enumerate(self.domains):
-            if isinstance(domain_self, Loopout):
-                domain_self_dna_sequence = DNA_base_wildcard * domain_self.dna_length()
-            else:
-                helix = domain_self.helix
-
-                # for helix, domains_on_helix_self in self._helix_idx_domain_map.items():
-                domains_on_helix_other = other._helix_idx_domain_map[helix]
-                # for domain_self in domains_on_helix_self:
-                overlaps = []
-                for domain_other in domains_on_helix_other:
-                    if domain_self != domain_other and domain_self.overlaps(domain_other):
-                        overlap = domain_self.compute_overlap(domain_other)
-                        overlaps.append((overlap, domain_other))
-
-                overlaps.sort()
-
-                domain_complement_builder = []
-                start_idx = domain_self.start
-                # repeatedly insert wildcards into gaps, then reverse WC complement
-                for ((overlap_left, overlap_right), domain_other) in overlaps:
-                    # wildcards = DNA_base_wildcard * (overlap_left - start_idx)
-                    num_wildcard_bases = domain_self.dna_length_in(start_idx, overlap_left - 1)
-                    wildcards = DNA_base_wildcard * num_wildcard_bases
-
-                    other_seq = domain_other.dna_sequence_in(overlap_left, overlap_right - 1)
-                    if other_seq is None:
-                        raise ValueError(f'no DNA sequence has been assigned to strand {other}')
-                    overlap_complement = wc(other_seq)
-                    domain_complement_builder.append(wildcards)
-                    domain_complement_builder.append(overlap_complement)
-                    start_idx = overlap_right
-
-                # last wildcard for gap between last overlap and end
-                # last_wildcards = DNA_base_wildcard * (domain_self.end - start_idx)
-                num_wildcard_bases = domain_self.dna_length_in(start_idx, domain_self.end - 1)
-                last_wildcards = DNA_base_wildcard * num_wildcard_bases
-
-                domain_complement_builder.append(last_wildcards)
-
-                # If pointing left, each individual overlap sequence was reverse orientation in wc(),
-                # but not the list of all of them put together until now.
-                if not domain_self.forward:
-                    domain_complement_builder.reverse()
-
-                domain_self_dna_sequence = ''.join(domain_complement_builder)
-
-            # merge with existing pre-assigned sequence
-            existing_domain_self_dna_sequence = strand_complement_builder[domain_idx]
-            merged_domain_self_dna_sequence = _string_merge_wildcard(domain_self_dna_sequence,
-                                                                     existing_domain_self_dna_sequence,
-                                                                     DNA_base_wildcard)
-            strand_complement_builder[domain_idx] = merged_domain_self_dna_sequence
-
-        strand_complement = ''.join(strand_complement_builder)
-        new_dna_sequence = strand_complement
-        if self.dna_sequence is not None:
-            try:
-                new_dna_sequence = _string_merge_wildcard(self.dna_sequence, new_dna_sequence,
-                                                          DNA_base_wildcard)
-            except ValueError:
-                domain_self = self.first_domain()
-                domain_other = other.first_domain()
-                msg = f'strand starting at helix {domain_self.helix}, offset {domain_self.offset_5p()} ' \
-                      f'has length ' \
-                      f'{self.dna_length()} and already has a partial DNA sequence assignment of length ' \
-                      f'{len(self.dna_sequence)}, which is \n' \
-                      f'{self.dna_sequence}, ' \
-                      f'but you tried to assign sequence of length {len(new_dna_sequence)} to it, which ' \
-                      f'is\n{new_dna_sequence} (this assignment was indirect, since you assigned directly ' \
-                      f'to a strand bound to this one). This occurred while directly assigning a DNA ' \
-                      f'sequence to the strand whose 5\' end is at helix {domain_other.helix}, and is of ' \
-                      f'length {other.dna_length()}.'
-                raise IllegalDesignError(msg)
-
-        self.set_dna_sequence(new_dna_sequence)
-        # self.dna_sequence = _pad_dna(new_dna_sequence, self.dna_length())
-
-    def insert_domain(self, order: int, domain: Union[Domain, Loopout]) -> None:
-        # Only intended to be called by Design.insert_domain
-        self.domains.insert(order, domain)
-        domain._parent_strand = self
-        if isinstance(domain, Domain):
-            self._helix_idx_domain_map[domain.helix].append(domain)
-
-        # add wildcard symbols to DNA sequence to maintain its length
-        if self.dna_sequence is not None:
-            start_idx = self.dna_index_start_domain(domain)
-            end_idx = start_idx + domain.dna_length()
-            prefix = self.dna_sequence[:start_idx]
-            suffix = self.dna_sequence[start_idx:]
-            new_wildcards = DNA_base_wildcard * (end_idx - start_idx)
-            self.dna_sequence = prefix + new_wildcards + suffix
-
-    def remove_domain(self, domain: Union[Domain, Loopout]) -> None:
-        # Only intended to be called by Design.remove_domain
-
-        # remove relevant portion of DNA sequence to maintain its length
-        if self.dna_sequence is not None:
-            start_idx = self.dna_index_start_domain(domain)
-            end_idx = start_idx + domain.dna_length()
-            prefix = self.dna_sequence[:start_idx]
-            suffix = self.dna_sequence[end_idx:]
-            self.dna_sequence = prefix + suffix
-
-        self.domains.remove(domain)
-        domain._parent_strand = None
-        if isinstance(domain, Domain):
-            self._helix_idx_domain_map[domain.helix].remove(domain)
-
-    def dna_index_start_domain(self, domain: Union[Domain, Loopout]) -> int:
-        """
-        Returns index in DNA sequence of domain, e.g., if there are five domains
-
-        012 3 45 678 9
-        AAA-C-GG-TTT-ACGT
-
-        Then their indices, respectively in order, are 0, 3, 4, 6, 9.
-
-        :param domain: :any: to find the start DNA index of
-        :return: index (within DNA sequence string) of substring of DNA starting with given :any:`Domain`
-        """
-        domain_order = self.domains.index(domain)
-        idx = sum(self.domains[i].dna_length() for i in range(domain_order))
-        return idx
-
-    def contains_loopouts(self) -> bool:
-        for domain in self.domains:
-            if isinstance(domain, Loopout):
-                return True
-        return False
-
-    def first_bound_domain(self) -> Domain:
-        """First :any:`Domain` (i.e., not a :any:`Loopout`) on this :any:`Strand`.
-
-        Currently the first and last strand must not be :any:`Loopout`'s, so this should return the same
-        domain as :py:meth:`Strand.first_domain`, but in case an initial or final :any:`Loopout` is
-        supported in the future, this method is provided."""
-        for domain in self.domains:
-            if isinstance(domain, Domain):
-                return domain
-        raise StrandError(self, 'should not be able to have a Strand with no (bound) Domains')
-
-    def last_bound_domain(self) -> Domain:
-        """Last :any:`Domain` (i.e., not a :any:`Loopout`) on this :any:`Strand`.
-
-        Currently the first and last strand must not be :any:`Loopout`'s, so this should return the same
-        domain as :py:meth:`Strand.first_domain`, but in case an initial or final :any:`Loopout` is
-        supported in the future, this method is provided."""
-        domain_rev = list(self.domains)
-        domain_rev.reverse()
-        for domain in domain_rev:
-            if isinstance(domain, Domain):
-                return domain
-        raise AssertionError('should not be able to have a Strand with no (bound) Domains')
-
-    def reverse(self) -> None:
-        """
-        Reverses "polarity" of this :any:`Strand`.
-
-        Does NOT check whether this keeps the :any:`Design` legal, so be cautious in calling this method
-        directly. To reverse every :any:`Strand`, called :py:meth:`Design.reverse_all`.
-        If the design was legal before, it will be legal after calling that method.
-        """
-        self.domains.reverse()
-        for domain in self.bound_domains():
-            domain.forward = not domain.forward
-
-    def _ensure_modifications_legal(self, check_offsets_legal: bool = False) -> None:
-        if check_offsets_legal:
-            if self.dna_sequence is None:
-                raise IllegalDesignError(f"must assign DNA sequence first")
-            mod_i_offsets_list = list(self.modifications_int.keys())
-            min_offset = min(mod_i_offsets_list) if len(mod_i_offsets_list) > 0 else None
-            max_offset = max(mod_i_offsets_list) if len(mod_i_offsets_list) > 0 else None
-            if min_offset is not None and min_offset < 0:
-                raise IllegalDesignError(f"smallest offset is {min_offset} but must be nonnegative: "
-                                         f"{self.modifications_int}")
-            if max_offset is not None and max_offset > len(self.dna_sequence):
-                raise IllegalDesignError(f"largeest offset is {max_offset} but must be at most "
-                                         f"{len(self.dna_sequence)}: "
-                                         f"{self.modifications_int}")
-
-    def _ensure_domains_nonoverlapping(self) -> None:
-        for d1, d2 in itertools.combinations(self.domains, 2):
-            if isinstance(d1, Domain) and isinstance(d2, Domain) and d1.overlaps_illegally(d2):
-                raise StrandError(self, f'two domains on strand overlap:'
-                                        f'\n{d1}'
-                                        f'\n{d2}')
-
-    def idt_dna_sequence(self) -> str:
-        self._ensure_modifications_legal(check_offsets_legal=True)
-
-        if self.dna_sequence is None:
-            raise ValueError('DNA sequence has not been assigned yet')
-
-        ret_list: List[str] = []
-        if self.modification_5p is not None and self.modification_5p.idt_text is not None:
-            ret_list.append(self.modification_5p.idt_text)
-
-        for offset, base in enumerate(self.dna_sequence):
-            ret_list.append(base)
-            if offset in self.modifications_int:  # if internal mod attached to base, replace base
-                mod = self.modifications_int[offset]
-                if mod.idt_text is not None:
-                    if mod.allowed_bases is not None:
-                        if base not in mod.allowed_bases:
-                            msg = f'internal modification {mod} can only replace one of these bases: ' \
-                                  f'{",".join(mod.allowed_bases)}, but the base at offset {offset} is {base}'
-                            raise IllegalDesignError(msg)
-                        ret_list[-1] = mod.idt_text  # replace base with modified base
-                    else:
-                        ret_list.append(mod.idt_text)  # append modification between two bases
-
-        if self.modification_3p is not None and self.modification_3p.idt_text is not None:
-            ret_list.append(self.modification_3p.idt_text)
-
-        return ''.join(ret_list)
-
-    def no_modifications_version(self) -> 'Strand':
-        """
-        :return: version of this :any:`Strand` with no DNA modifications.
-        """
-        strand_nomods = replace(self, modification_3p=None, modification_5p=None, modifications_int={})
-        return strand_nomods
-
-
-# for defining comparables for strongly typed sorting
-class Comparable(metaclass=ABCMeta):
-    @abstractmethod
-    def __lt__(self, other: Any) -> bool: ...
-
-
-T = TypeVar('T')
-# CT = TypeVar('CT', bound=Comparable)
-
-# KeyFunction = Callable[[T], CT]
-KeyFunction = Callable[[T], Any]
-
-
-class StrandOrder(enum.Enum):
-    """
-    Which part of a :any:`Strand` to use for sorting in the
-    `key function <https://docs.python.org/3/howto/sorting.html#key-functions>`_
-    returned by :py:meth:`strand_order_key_function`.
-    """
-    five_prime = 0
-    """5' end of the strand"""
-
-    three_prime = 1
-    """3' end of the strand"""
-
-    five_or_three_prime = 2
-    """Either 5' end or 3' end is used, whichever is first according to the sort order."""
-
-    top_left_domain = 3
-    """The start offset of the "top-left" :any:`Domain` of the :any:`Strand`: the :any:`Domain` whose
-    :py:data:`Domain.helix` is minimal, and, among all such :any:`Domain`'s, the one with 
-    minimal :py:data:`Domain.start`."""
-
-
-def strand_order_key_function(*, column_major: bool = True, strand_order: StrandOrder) -> KeyFunction[Strand]:
-    """
-    Returns a `key function <https://docs.python.org/3/howto/sorting.html#key-functions>`_
-    indicating a sorted order for :any:`Strand`'s. Useful as a parameter for
-    :py:meth:`Design.`.
-
-    :param column_major:
-        If true, column major order is used: ordered by base offset first, then by helix.
-        Otherwise row-major order is used: ordered by helix first, then by base offset.
-    :param strand_order:
-        Which part of the strand to use as a key for the sorted order.
-        See :any:`StrandOrder` for definitions.
-    :return:
-        A `key function <https://docs.python.org/3/howto/sorting.html#key-functions>`_ that can be
-        passed to :py:meth:`Design.` to specify a sorted order for the :any:`Strand`'s.
-    """
-
-    def key(strand: Strand) -> Tuple[int, int]:
-        # we'll return a tuple (helix_idx, offset) for row-major or (offset, helix_idx) for col-major.
-        helix_idx: int
-        offset: int
-
-        if strand_order == StrandOrder.five_prime:
-            helix_idx = strand.first_bound_domain().helix
-            offset = strand.first_bound_domain().offset_5p()
-        elif strand_order == StrandOrder.three_prime:
-            helix_idx = strand.last_bound_domain().helix
-            offset = strand.last_bound_domain().offset_3p()
-        elif strand_order == StrandOrder.five_or_three_prime:
-            helix_idx_5p = strand.first_bound_domain().helix
-            offset_5p = strand.first_bound_domain().offset_5p()
-            helix_idx_3p = strand.last_bound_domain().helix
-            offset_3p = strand.last_bound_domain().offset_3p()
-            if column_major:
-                offset, helix_idx = min((offset_5p, helix_idx_5p), (offset_3p, helix_idx_3p))
-            else:
-                helix_idx, offset = min((helix_idx_5p, offset_5p), (helix_idx_3p, offset_3p))
-        elif strand_order == StrandOrder.top_left_domain:
-            helix_idx = strand.first_bound_domain().helix
-            offset = strand.first_bound_domain().start
-            for domain in strand.bound_domains():
-                if (helix_idx, offset) > (domain.helix, domain.start):
-                    helix_idx, offset = domain.helix, domain.start
-        else:
-            raise ValueError(f'{strand_order} is not a valid StrandOrder')
-
-        if column_major:
-            return offset, helix_idx
-        else:
-            return helix_idx, offset
-
-    return key
-
-
-def _pad_and_remove_whitespace_and_uppercase(sequence: str, strand: Strand, start: int = 0) -> str:
-    sequence = _remove_whitespace_and_uppercase(sequence)
-    padded_sequence = _pad_dna(sequence, strand.dna_length(), start)
-    return padded_sequence
-
-
-def _remove_whitespace_and_uppercase(sequence: str) -> str:
-    sequence = re.sub(r'\s*', '', sequence)
-    sequence = sequence.upper()
-    return sequence
-
-
-def _pad_dna(sequence: str, length: int, start: int = 0) -> str:
-    """Return `sequence` modified to have length `length`.
-
-    If len(sequence) < length, pad with  :py:data:`DNA_base_wildcard`.
-
-    If len(sequence) > length, remove extra symbols, from 0 up to `start`, and at the end.
-
-    :param sequence: sequence to pad
-    :param length: final length of padded sequence
-    :param start: index at which to start padding. If not specified, defaults to 0
-    :return: padded sequence
-    """
-    if start < 0:
-        raise ValueError(f'cannot pad DNA with negative start, but start = {start}')
-    elif start >= length:
-        raise ValueError(f'cannot pad DNA with start >= length, but start = {start} and '
-                         f'length = {length}')
-    if len(sequence) > length:
-        sequence = sequence[start:start + length]
-    elif len(sequence) < length:
-        prefix = DNA_base_wildcard * start
-        suffix = DNA_base_wildcard * (length - len(sequence) - start)
-        sequence = prefix + sequence + suffix
-    return sequence
-
-
-def _string_merge_wildcard(s1: str, s2: str, wildcard: str) -> str:
-    """Takes a "union" of two equal-length strings `s1` and `s2`.
-    Whenever one has a symbol `wildcard` and the other does not, the result has the non-wildcard symbol.
-
-    Raises :py:class:`ValueError` if `s1` and `s2` are not the same length or do not agree on non-wildcard
-    symbols at any position."""
-    if len(s1) != len(s2):
-        raise ValueError(f'\ns1={s1} and\ns2={s2}\nare not the same length.')
-    union_builder = []
-    for i in range(len(s1)):
-        c1, c2 = s1[i], s2[i]
-        if c1 == wildcard:
-            union_builder.append(c2)
-        elif c2 == wildcard:
-            union_builder.append(c1)
-        elif c1 != c2:
-            raise ValueError(f's1={s1} and s2={s2} have unequal symbols {c1} and {c2} at position {i}.')
-        elif c1 == c2:
-            union_builder.append(c1)
-        else:
-            raise AssertionError('should be unreachable')
-    return ''.join(union_builder)
-
-
-class IllegalDesignError(ValueError):
-    """Indicates that some aspect of the :any:`Design` object is illegal."""
-
-    def __init__(self, the_cause: str) -> None:
-        self.cause = the_cause
-
-    # __str__ is to print() the value
-    def __str__(self) -> str:
-        return repr(self.cause)
-
-
-class StrandError(IllegalDesignError):
-    """Indicates that the :any:`Design` is illegal due to some specific :any:`Strand`.
-    Information about the :any:`Strand` is embedded in the error message when this exception is
-    raised that helps to identify which :any:`Strand` caused the problem."""
-
-    def __init__(self, strand: Strand, the_cause: str) -> None:
-        # need to avoid calling first_bound_domain here to avoid infinite mutual recursion
-        first_domain: Optional[Domain]
-        last_domain: Optional[Domain]
-        if len(strand.domains) > 0 and isinstance(strand.domains[0], Domain):
-            first_domain = strand.domains[0]
-        else:
-            first_domain = None
-        if len(strand.domains) > 0 and isinstance(strand.domains[-1], Domain):
-            last_domain = strand.domains[-1]
-        else:
-            last_domain = None
-
-        msg = (f'''{the_cause}
-    strand length        =  {strand.dna_length()}
-    DNA length           =  {len(strand.dna_sequence) if strand.dna_sequence else "N/A"}
-    DNA sequence         =  {strand.dna_sequence}
-    strand 5' helix      =  {first_domain.helix if first_domain is not None else 'N/A'}
-    strand 5' end offset =  {first_domain.offset_5p() if first_domain is not None else 'N/A'}
-    strand 3' helix      =  {last_domain.helix if last_domain is not None else 'N/A'}
-    strand 3' end offset =  {last_domain.offset_3p() if last_domain is not None else 'N/A'}\n''')
-
-        super().__init__(msg)
-        # super(IllegalDesignError, self).__init__(msg)
-
-
-# def _plates(idt_strands) -> List[str]:
-#     plates: Set[str] = set()
-#     for strand in idt_strands:
-#         if strand.idt is not None and strand.idt.plate is not None:
-#             plates.add(strand.idt.plate)
-#     return list(plates)
-
-
-_96WELL_PLATE_ROWS: List[str] = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']
-_96WELL_PLATE_COLS: List[int] = list(range(1, 13))
-
-_384WELL_PLATE_ROWS: List[str] = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N',
-                                  'O',
-                                  'P']
-_384WELL_PLATE_COLS: List[int] = list(range(1, 25))
-
-
-@enum.unique
-class PlateType(int, enum.Enum):
-    """Represents two different types of plates in which DNA sequences can be ordered."""
-
-    wells96 = 96
-    """96-well plate."""
-
-    wells384 = 384
-    """384-well plate."""
-
-    def rows(self) -> List[str]:
-        return _96WELL_PLATE_ROWS if self is PlateType.wells96 else _384WELL_PLATE_ROWS
-
-    def cols(self) -> List[int]:
-        return _96WELL_PLATE_COLS if self is PlateType.wells96 else _384WELL_PLATE_COLS
-
-
-class _PlateCoordinate:
-
-    def __init__(self, plate_type: PlateType) -> None:
-        self._plate_type = plate_type
-        self._plate: int = 1
-        self._row_idx: int = 0
-        self._col_idx: int = 0
-
-    def increment(self) -> None:
-        self._row_idx += 1
-        if self._row_idx == len(self._plate_type.rows()):
-            self._row_idx = 0
-            self._col_idx += 1
-            if self._col_idx == len(self._plate_type.cols()):
-                self._col_idx = 0
-                self._plate += 1
-
-    def plate(self) -> int:
-        return self._plate
-
-    def row(self) -> str:
-        return self._plate_type.rows()[self._row_idx]
-
-    def col(self) -> int:
-        return self._plate_type.cols()[self._col_idx]
-
-    def well(self) -> str:
-        return f'{self.row()}{self.col()}'
-
-
-def remove_helix_idxs_if_default(helices: List[Dict]) -> None:
-    # removes indices from each helix if they are the default (order of appearance in list)
-    default = True
-    for expected_idx, helix in enumerate(helices):
-        idx = helix[idx_on_helix_key]
-        if idx != expected_idx:
-            default = False
-            break
-
-    if default:
-        for helix in helices:
-            del helix[idx_on_helix_key]
-
-
-def add_quotes(string: str) -> str:
-    # adds quotes around a string
-    return f'"{string}"'
-
-
-def mandatory_field(ret_type: Type, json_map: Dict[str, Any], main_key: str, *legacy_keys: str) -> Any:
-    # should be called from function whose return type is the type being constructed from JSON, e.g.,
-    # Design or Strand, given by ret_type. This helps give a useful error message
-    for key in (main_key,) + legacy_keys:
-        if key in json_map:
-            return json_map[key]
-    ret_type_name = ret_type.__name__
-    msg_about_keys = f'the key "{main_key}"'
-    if len(legacy_keys) > 0:
-        msg_about_keys += f" (or any of the following legacy keys: {', '.join(map(add_quotes, legacy_keys))})"
-    msg = f'I was looking for {msg_about_keys} in the JSON encoding of a {ret_type_name}, ' \
-          f'but I did not find it.' \
-          f'\n\nThis occurred when reading this JSON object:\n{json_map}'
-    raise IllegalDesignError(msg)
-
-
-def optional_field(default_value: Any, json_map: Dict[str, Any], main_key: str, *legacy_keys: str) -> Any:
-    # like dict.get, except that it checks for multiple keys
-    for key in (main_key,) + legacy_keys:
-        if key in json_map:
-            return json_map[key]
-    return default_value
-
-
-@dataclass
-class Geometry(_JSONSerializable):
-    """Parameters controlling some geometric visualization/physical aspects of Design."""
-
-    rise_per_base_pair: float = 0.332
-    """Distance in nanometers between two adjacent base pairs along the length of a DNA double helix."""
-
-    helix_radius: float = 1.0
-    """Radius of a DNA helix in nanometers."""
-
-    bases_per_turn: float = 10.5
-    """Number of DNA base pairs in a full turn of DNA."""
-
-    minor_groove_angle: float = 150.0
-    """Minor groove angle in degrees."""
-
-    inter_helix_gap: float = 1.0
-    """Gap between helices in nanometers (due to electrostatic repulsion; needed to display to scale)."""
-
-    def distance_between_helices(self) -> float:
-        return 2 * self.helix_radius + self.inter_helix_gap
-
-    def is_default(self) -> bool:
-        return self == _default_geometry
-
-    @staticmethod
-    def from_json(json_map: dict) -> 'Geometry':  # remove quotes when Py3.6 support dropped
-        geometry = Geometry()
-        geometry.rise_per_base_pair = optional_field(_default_geometry.rise_per_base_pair, json_map,
-                                                     rise_per_base_pair_key, *legacy_rise_per_base_pair_keys)
-        geometry.helix_radius = optional_field(_default_geometry.helix_radius, json_map, helix_radius_key)
-        geometry.bases_per_turn = optional_field(_default_geometry.bases_per_turn, json_map,
-                                                 bases_per_turn_key)
-        geometry.minor_groove_angle = optional_field(_default_geometry.minor_groove_angle, json_map,
-                                                     minor_groove_angle_key)
-        geometry.inter_helix_gap = optional_field(_default_geometry.inter_helix_gap, json_map,
-                                                  inter_helix_gap_key)
-        return geometry
-
-    @staticmethod
-    def keys() -> List[str]:
-        return [rise_per_base_pair_key, helix_radius_key, bases_per_turn_key, minor_groove_angle_key,
-                inter_helix_gap_key]
-
-    def values(self) -> List[float]:
-        return [self.rise_per_base_pair, self.helix_radius, self.bases_per_turn, self.minor_groove_angle,
-                self.inter_helix_gap]
-
-    @staticmethod
-    def default_values() -> List[float]:
-        return _default_geometry.values()
-
-    def to_json_serializable(self, suppress_indent: bool = True, **kwargs: Any) -> Dict[str, Any]:
-        dct: Dict[str, Any] = OrderedDict()
-        for name, val, val_default in zip(Geometry.keys(), self.values(), Geometry.default_values()):
-            if val != val_default:
-                dct[name] = val
-        return dct
-
-
-_default_geometry = Geometry()
-
-
-def _check_helices_view_order_and_return(
-        helices_view_order: Optional[List[int]], helix_idxs: Iterable[int]) -> List[int]:
-    if helices_view_order is None:
-        identity = sorted(helix_idxs)
-        helices_view_order = identity
-    else:
-        _check_helices_view_order_is_bijection(helices_view_order, helix_idxs)
-    return helices_view_order
-
-
-def _check_helices_grid_legal(grid: Grid, helices: Iterable[Helix]) -> None:
-    for helix in helices:
-        if grid == Grid.none and helix.grid_position is not None:
-            raise IllegalDesignError(
-                f'grid is none, but Helix {helix.idx} has grid_position = {helix.grid_position}')
-        elif grid != Grid.none and helix.position is not None:
-            raise IllegalDesignError(
-                f'grid is not none, but Helix {helix.idx} has position = ${helix.position}')
-
-
-def _check_helices_view_order_is_bijection(helices_view_order: List[int], helix_idxs: Iterable[int]) -> None:
-    if not (sorted(helices_view_order) == sorted(helix_idxs)):
-        raise IllegalDesignError(
-            f"The specified helices view order: {helices_view_order}\n "
-            f"is not a bijection on helices indices: {helix_idxs}.")
-
-
-@dataclass
-class Design(_JSONSerializable, Generic[StrandLabel, DomainLabel]):
-    """Object representing the entire design of the DNA structure."""
-
-    strands: List[Strand[StrandLabel, DomainLabel]]
-    """All of the :any:`Strand`'s in this :any:`Design`.
-    
-    Required field."""
-
-    helices: Dict[int, Helix] = None  # type: ignore
-    """All of the :any:`Helix`'s in this :any:`Design`. 
-    This is a dictionary mapping index to the :any:`Helix` with that index; if helices have indices 
-    0, 1, ..., num_helices-1, then this can be used as a list of Helices. 
-    
-    Optional field. If not specified, then the number of helices will be just large enough to store the
-    largest index :py:data:`Domain.helix` 
-    stored in any :any:`Domain` 
-    in :py:data:`Design.strands`."""
-
-    groups: Dict[str, HelixGroup] = None  # type: ignore
-    """:any:`HelixGroup`'s in this :any:`Design`."""
-
-    geometry: Geometry = field(default_factory=lambda: Geometry())
-    """Controls some geometric/physical aspects of this :any:`Design`."""
-
-    automatically_assign_color: bool = field(repr=False, default=True)
-    """If `automatically_assign_color` = ``False``, then for any :any:`Strand` such that
-    `Strand.color` = ``None``, do not automatically assign a :any:`Color` to it. 
-    In this case color will be set to its default of ``None`` and will not be
-    written to the JSON with :py:meth:`Design.write_scadnano_file` or :py:meth:`Design.to_json`."""
-
-    color_cycler: ColorCycler = field(default_factory=lambda: ColorCycler(), init=False)
-
-    def __init__(self, *,
-                 helices: Optional[Union[List[Helix], Dict[int, Helix]]] = None,
-                 groups: Optional[Dict[str, HelixGroup]] = None,
-                 strands: List[Strand] = None,
-                 grid: Optional[Grid] = None,
-                 helices_view_order: List[int] = None,
-                 geometry: Geometry = None) -> None:
-        """
-        :param helices: List of :any:`Helix`'s; if missing, set based on `strands`.
-        :param groups: Dict mapping group name to :any:`HelixGroup`.
-            Mutually exclusive with `helices_view_order`, and any non-none :any:`Grid`. If set, then each
-            :any:`Helix` must have its :py:data:`Helix.group` field set to a group name that is one of the
-            keys of this dict.
-        :param strands: List of :any:`Strand`'s. If missing, will be empty.
-        :param grid: :any:`Grid` to use.
-        :param helices_view_order: order in which to view helices from top to bottom in web interface
-            main view.
-            Mutually exclusive with `groups`.
-            This list must contain each :py:data:`Helix.idx` exactly once.
-            If no :py:data:`Helix.idx` is explicitly specified, then this should be a permutation of the
-            list [0,1,...,len(helices)-1].
-            default is to display in increasing order of :py:data:`Helix.idx`.
-        :param geometry: geometric physical parameters for visualization.
-            If not specified, a default set of parameters from the literature are used.
-        """
-        using_groups = groups is not None
-
-        if helices_view_order is not None and using_groups:
-            raise IllegalDesignError('Design.helices_view_order and Design.groups are mutually exclusive. '
-                                     'Set at most one of them.')
-
-        if grid is not None and using_groups:
-            raise IllegalDesignError('Design.grid and Design.groups are mutually exclusive. '
-                                     'Set at most one of them.')
-
-        if grid is None and not using_groups:
-            # make sure we only set this if groups are not being used
-            grid = Grid.none
-
-        self.strands = [] if strands is None else strands
-        self.color_cycler = ColorCycler()
-        self.geometry = Geometry() if geometry is None else geometry
-
-        if groups is None:
-            self.groups = {default_group_name: HelixGroup()}
-        else:
-            self.groups = groups
-            if grid is not None:
-                raise IllegalDesignError('cannot use a non-none grid for whole Design when helix groups are '
-                                         'used; only the HelixGroups can have non-none grids in this case')
-            if helices_view_order is not None:
-                raise IllegalDesignError('cannot use helices_view_order for whole Design when helix groups '
-                                         'are used; only the HelixGroups can have helices_view_order '
-                                         'in this case')
-
-        if helices is None:
-            if len(self.strands) > 0:
-                max_helix_idx = max(
-                    domain.helix for strand in self.strands for domain in strand.bound_domains())
-                helices = {idx: Helix(idx=idx) for idx in range(max_helix_idx + 1)}
-            else:
-                helices = {}
-        elif not (isinstance(helices, dict) or isinstance(helices, list)):
-            raise IllegalDesignError('type of parameter helices must be list of helices, '
-                                     f'dict mapping int to helices, or None, but it is {type(helices)}')
-
-        self.helices = Design._normalize_helices_as_dict(helices)
-
-        # set up helices_view_order in groups
-        uses_default_group = self._has_default_groups()
-        for name, group in self.groups.items():
-            helix_idxs_in_group = self.helices_idxs_in_group(name)
-            if uses_default_group:
-                helices_view_order_for_group = helices_view_order
-                grid_for_group = grid
-            else:
-                helices_view_order_for_group = group.helices_view_order
-                grid_for_group = group.grid
-            group.helices_view_order = _check_helices_view_order_and_return(helices_view_order_for_group,
-                                                                            helix_idxs_in_group)
-            if grid_for_group is None:
-                raise AssertionError()
-            group.grid = grid_for_group
-            helices_in_group = [self.helices[idx] for idx in helix_idxs_in_group]
-            _check_helices_grid_legal(group.grid, helices_in_group)
-
-        self.__post_init__()
-
-    def __post_init__(self) -> None:
-        # XXX: exact order of these calls is important
-        self._ensure_helices_distinct_objects()
-        self._ensure_strands_distinct_objects()
-        self._set_helices_grid_positions_or_positions()
-        self._build_domains_on_helix_lists()
-        self._set_helices_min_max_offsets(update=False)
-        self._ensure_helix_groups_exist()
-        self._assign_default_helices_view_orders_to_groups()
-        self._check_legal_design()
-
-        if self.automatically_assign_color:
-            self._assign_colors_to_strands()
-
-    @property
-    def helices_view_order(self) -> List[int]:
-        """
-        Return helices_view_order of this :any:`Design` if no :any:`HelixGroup`'s are being used, otherwise
-        raise a ValueError.
-
-        :return: helices_view_order of this :any:`Design`
-        """
-        group = self._get_default_group()
-        if group.helices_view_order is None:
-            raise ValueError(f'group {group} does not have helices_view_order defined')
-        return group.helices_view_order
-
-    @property
-    def grid(self) -> Grid:
-        """
-        Return grid of this :any:`Design` if no :any:`HelixGroup`'s are being used, otherwise
-        raise a ValueError.
-
-        :return: grid of this :any:`Design`
-        """
-        group = self._get_default_group()
-        return group.grid
-
-    def set_grid(self, grid: Grid) -> None:
-        """
-        Sets the grid of the default :any:`HelixGroup`, if the default is being used,
-        otherwise raises an exception.
-
-        :param grid:
-            new grid to set for the (only) :any:`HelixGroup` in this :any:`Design`
-        :raises IllegalDesignError:
-            if there is more than one :any:`HelixGroup` in this :any:`Design`
-        """
-        group = self._get_default_group()
-        group.grid = grid
-
-    def _get_default_group(self) -> HelixGroup:
-        # Gets default group and raise exception if default group is not being used
-        if not self._has_default_groups():
-            raise ValueError('The default group is not being used for this design.')
-        if self.groups is None:
-            raise AssertionError('Design.groups should not be None by this point')
-        groups: List[HelixGroup] = list(self.groups.values())
-        group: HelixGroup = groups[0]
-        return group
-
-    def helices_idxs_in_group(self, group_name: str) -> List[int]:
-        """
-        Indexes of :any:`Helix`'s in this group. Must be associated with a :any:`Design` for this to work.
-
-        :param group_name: name of group
-        :return: list of indices of :any:`Helix`'s in this :any:`HelixGroup`
-        """
-        return [idx for idx, helix in self.helices.items() if helix.group == group_name]
-
-    def _assign_colors_to_strands(self) -> None:
-        # if color not specified, pick one by cycling through list of staple colors,
-        # unless caller specified not to
-        for strand in self.strands:
-            self._assign_color_to_strand(strand)
-
-    def _assign_color_to_strand(self, strand: Strand) -> None:
-        if strand.color is None and self.automatically_assign_color:
-            if strand.is_scaffold:
-                strand.color = default_scaffold_color
-            else:
-                strand.color = next(self.color_cycler)
-
-    @staticmethod
-    def from_scadnano_file(filename: str) -> 'Design':  # remove quotes when Py3.6 support dropped
-        """
-        Loads a :any:`Design` from the file with the given name.
-
-        :param filename: name of the file with the design. Should be a JSON file ending in .dna
-        :return: Design described in the file
-        """
-        with open(filename) as f:
-            json_str = f.read()
-        return Design.from_scadnano_json_str(json_str)
-
-    @staticmethod
-    def from_scadnano_json_str(json_str: str) -> 'Design':  # remove quotes when Py3.6 support dropped
-        """
-        Loads a :any:`Design` from the given JSON string.
-
-        :param json_str: JSON description of the :any:`Design`
-        :return: Design described in the JSON string
-        """
-        json_map = json.loads(json_str)
-        try:
-            design = Design.from_scadnano_json_map(json_map)
-            return design
-        except KeyError as e:
-            raise IllegalDesignError(f'I was expecting a JSON key but did not find it: {e}')
-
-    @staticmethod
-    def _check_mutually_exclusive_fields(json_map: dict) -> None:
-        exclusive_pairs = [
-            (grid_key, groups_key),
-            (helices_view_order_key, groups_key),
-        ]
-        for key1, key2 in exclusive_pairs:
-            if key1 in json_map and key2 in json_map:
-                raise IllegalDesignError(f'cannot specify both "{key1}" and "{key2}" in Design JSON')
-
-    @staticmethod
-    def from_scadnano_json_map(
-            json_map: dict) -> 'Design':  # remove quotes when Py3.6 support dropped
-        """
-        Loads a :any:`Design` from the given JSON object (i.e., Python object obtained by calling
-        json.loads(json_str) from a string representing contents of a JSON file.
-
-        :param json_map: map describing the :any:`Design`;
-            should be JSON serializable via ``encode(json_map)``
-        :return: :any:`Design` described in the object
-        """
-        # version = json_map.get(version_key, initial_version)  # not sure what to do with version
-
-        Design._check_mutually_exclusive_fields(json_map)
-
-        grid = optional_field(None, json_map, grid_key)
-        grid_is_none = grid == Grid.none
-
-        using_groups = groups_key in json_map
-
-        helices = []
-        deserialized_helices_list = json_map[helices_key]
-        num_helices = len(deserialized_helices_list)
-
-        # create Helices
-        idx_default = 0
-        for helix_json in deserialized_helices_list:
-            helix = Helix.from_json(helix_json)
-            if not using_groups and grid_is_none and grid_position_key in helix_json:
-                raise IllegalDesignError(
-                    f'grid is none, but Helix {idx_default} has grid_position = {helix_json[grid_position_key]}')
-            elif not using_groups and not grid_is_none and position_key in helix_json:
-                raise IllegalDesignError(
-                    f'grid is not none, but Helix {idx_default} has position = ${helix_json[position_key]}')
-            helices.append(helix)
-            idx_default += 1
-
-        # helix groups
-        groups = None
-        if groups_key in json_map:
-            groups_json = json_map[groups_key]
-            groups = {}
-            for name, group_json in groups_json.items():
-                num_helices_in_group = sum(1 for helix in helices if helix.group == name)
-                groups[name] = HelixGroup.from_json(group_json, num_helices=num_helices_in_group)
-
-        # view order of helices
-        helices_view_order = json_map.get(helices_view_order_key)
-        if helices_view_order is not None:
-            helix_idxs = [helix.idx for helix in helices]
-            if len(helices_view_order) != num_helices:
-                raise IllegalDesignError(f'length of helices ({num_helices}) does not match '
-                                         f'length of helices_view_order ({len(helices_view_order)})')
-            if sorted(helices_view_order) != sorted(helix_idxs):
-                raise IllegalDesignError(f'helices_view_order = {helices_view_order} is not a '
-                                         f'permutation of the set of helices {helix_idxs}')
-
-        # strands
-        strands = []
-        strand_jsons = mandatory_field(Design, json_map, strands_key)
-        for strand_json in strand_jsons:
-            strand = Strand.from_json(strand_json)
-            strands.append(strand)
-
-        # modifications in whole design
-        if design_modifications_key in json_map:
-            all_mods_json = json_map[design_modifications_key]
-            all_mods = {}
-            for mod_key, mod_json in all_mods_json.items():
-                mod = Modification.from_json(mod_json)
-                mod = dataclasses.replace(mod, id=mod_key)
-                all_mods[mod_key] = mod
-            Design.assign_modifications_to_strands(strands, strand_jsons, all_mods)
-
-        geometry = None
-        if geometry_key in json_map:
-            geometry = Geometry.from_json(json_map[geometry_key])
-
-        return Design(
-            helices=helices,
-            groups=groups,
-            strands=strands,
-            grid=grid,
-            helices_view_order=helices_view_order,
-            geometry=geometry,
-        )
-
-    def to_json_serializable(self, suppress_indent: bool = True, **kwargs: Any) -> Dict[str, Any]:
-        dct: Any = OrderedDict()
-        dct[version_key] = __version__
-
-        if self._has_default_groups():
-            dct[grid_key] = str(self.grid)[5:]  # remove prefix 'Grid.'
-
-        if not self.geometry.is_default():
-            dct[geometry_key] = self.geometry.to_json_serializable(suppress_indent)
-
-        if not self._has_default_groups():
-            group_map = {}
-            for name, group in self.groups.items():
-                helix_idxs_in_group = [helix.idx for helix in self.helices.values() if
-                                       helix.group == name]
-                group_map[name] = group.to_json_serializable(suppress_indent,
-                                                             helix_idxs=helix_idxs_in_group)
-            dct[groups_key] = group_map
-
-        helices_json = []
-        for helix in self.helices.values():
-            group = self.groups[helix.group]
-            helix_json = helix.to_json_serializable(suppress_indent, grid=group.grid)
-            helices_json.append(helix_json)
-        dct[helices_key] = helices_json
-
-        # remove idx key from list of helices if they have the default index
-        unwrapped_helices = list(dct[helices_key])
-        if len(unwrapped_helices) > 0:
-            for i, wrapped in enumerate(unwrapped_helices):
-                if isinstance(wrapped, NoIndent):
-                    unwrapped_helices[i] = wrapped.value
-        remove_helix_idxs_if_default(unwrapped_helices)
-
-        if self._has_default_groups():
-            default_helices_view_order = sorted(self.helices.keys())
-            if self.helices_view_order != default_helices_view_order:
-                dct[helices_view_order_key] = NoIndent(
-                    self.helices_view_order) if suppress_indent else self.helices_view_order
-
-        # modifications
-        mods = self._all_modifications()
-        if len(mods) > 0:
-            mods_dict = {}
-            for mod in mods:
-                if mod.id not in mods_dict:
-                    mods_dict[mod.id] = mod.to_json_serializable(suppress_indent)
-            dct[design_modifications_key] = mods_dict
-
-        dct[strands_key] = [strand.to_json_serializable(suppress_indent) for strand in self.strands]
-
-        for helix_list_order, helix in enumerate(self.helices.values()):
-            helix_json_maybe: Union[NoIndent, Dict[str, Any]] = dct[helices_key][helix_list_order]
-            if isinstance(helix_json_maybe, NoIndent):
-                helix_json = helix_json_maybe.value  # get past NoIndent surrounding helix, if it is there
-            else:
-                helix_json = helix_json_maybe
-            # XXX: no need to check here because key was already deleted by Helix.to_json_serializable
-            # max_offset still needs to be checked here since it requires global knowledge of Strands
-            # if 0 == helix_json[min_offset_key]:
-            #     del helix_json[min_offset_key]
-            max_offset = max((domain.end for strand in self.strands for domain in strand.bound_domains()),
-                             default=-1)
-            if max_offset == helix_json[max_offset_key] or helix_json[max_offset_key] is None:
-                del helix_json[max_offset_key]
-
-        return dct
-
-    @property
-    def scaffold(self) -> Optional[Strand]:
-        """Returns the first scaffold in this :any:`Design`, if there is one, or ``None`` otherwise."""
-        for strand in self.strands:
-            if strand.is_scaffold:
-                return strand
-        return None
-
-    @staticmethod
-    def _normalize_helices_as_dict(helices: Union[List[Helix], Dict[int, Helix]]) -> Dict[int, Helix]:
-        def idx_of(helix: Helix, order: int) -> int:
-            return order if helix.idx is None else helix.idx
-
-        if isinstance(helices, list):
-            indices = [idx_of(helix, idx) for idx, helix in enumerate(helices)]
-            if len(set(indices)) < len(indices):
-                duplicates = [index for index, count in Counter(indices).items() if count > 1]
-                raise IllegalDesignError(
-                    'No two helices can share an index, but these indices appear on '
-                    f'multiple helices: {", ".join(map(str, duplicates))}')
-            helices = {idx_of(helix, idx): helix for idx, helix in enumerate(helices)}
-
-        for idx, helix in helices.items():
-            helix.idx = idx
-
-        return helices
-
-    @staticmethod
-    def assign_modifications_to_strands(strands: List[Strand], strand_jsons: List[dict],
-                                        all_mods: Dict[str, Modification]) -> None:
-        for strand, strand_json in zip(strands, strand_jsons):
-            if modification_5p_key in strand_json:
-                mod_name = strand_json[modification_5p_key]
-                strand.modification_5p = cast(Modification5Prime, all_mods[mod_name])
-            if modification_3p_key in strand_json:
-                mod_name = strand_json[modification_3p_key]
-                strand.modification_3p = cast(Modification3Prime, all_mods[mod_name])
-            if modifications_int_key in strand_json:
-                mod_names_by_offset = strand_json[modifications_int_key]
-                for offset_str, mod_name in mod_names_by_offset.items():
-                    offset = int(offset_str)
-                    strand.modifications_int[offset] = cast(ModificationInternal, all_mods[mod_name])
-
-    @staticmethod
-    def _cadnano_v2_import_find_5_end(vstrands: VStrands, strand_type: str, helix_num: int, base_id: int,
-                                      id_from: int,
-                                      base_from: int) -> Tuple[int, int, bool]:
-        """ Routine which finds the 5' end of a strand in a cadnano v2 import. It returns the
-        helix and the base of the 5' end.
-        """
-        id_from_before = helix_num  # 'id' stands for helix id
-        base_from_before = base_id
-
-        circular_seen = {}
-        is_circular = False
-
-        while not (id_from == -1 and base_from == -1):
-            if (id_from, base_from) in circular_seen:
-                is_circular = True
-                break
-            circular_seen[(id_from, base_from)] = True
-            id_from_before = id_from
-            base_from_before = base_from
-            id_from, base_from, _, _ = vstrands[id_from][strand_type][base_from]
-        return id_from_before, base_from_before, is_circular
-
-    @staticmethod
-    def _cadnano_v2_import_find_strand_color(vstrands: VStrands, strand_type: str, strand_5_end_base: int,
-                                             strand_5_end_helix: int) -> Color:
-        """Routine that finds the color of a cadnano v2 strand."""
-        color: Color = default_cadnano_strand_color
-
-        if strand_type == 'scaf':
-            return default_scaffold_color
-
-        if strand_type == 'stap':
-            base_id: int
-            stap_color: int
-
-            for base_id, stap_color in vstrands[strand_5_end_helix]['stap_colors']:
-                if base_id == strand_5_end_base:
-                    color = Color.from_cadnano_v2_int_hex(stap_color)
-                    break
-        return color
-
-    @staticmethod
-    def _cadnano_v2_import_extract_deletions(skip_table: Dict[int, Any], start: int, end: int) -> List[int]:
-        """ Routines which converts cadnano skips to scadnano deletions """
-        to_return: List[int] = []
-        for base_id in range(start, end):
-            if skip_table[base_id] == -1:
-                to_return.append(base_id)
-        return to_return
-
-    @staticmethod
-    def _cadnano_v2_import_extract_insertions(loop_table: Dict[int, Any],
-                                              start: int, end: int) -> List[Tuple[int, int]]:
-        """ Routines which converts cadnano skips to scadnano insertions """
-        to_return: List[Tuple[int, int]] = []
-        for base_id in range(start, end):
-            if loop_table[base_id] != 0:
-                to_return.append((base_id, loop_table[base_id]))
-        return to_return
-
-    @staticmethod
-    def _cadnano_v2_import_explore_domains(vstrands: VStrands, seen: Dict[Tuple[int, int], bool],
-                                           strand_type: str,
-                                           strand_5_end_base: int,
-                                           strand_5_end_helix: int) -> List[Domain]:
-        """Finds all domains of a cadnano v2 strand. """
-        curr_helix = strand_5_end_helix
-        curr_base = strand_5_end_base
-        domains: List[Domain] = []
-
-        direction_forward = (strand_type == 'scaf' and curr_helix % 2 == 0) or (
-            (strand_type == 'stap' and curr_helix % 2 == 1))
-        start, end = -1, -1
-        if direction_forward:
-            start = curr_base
-        else:
-            end = curr_base
-
-        circular_seen = {}
-        while not (curr_helix == -1 and curr_base == -1):
-            if (curr_helix, curr_base) in circular_seen:
-                break
-            circular_seen[(curr_helix, curr_base)] = True
-
-            old_helix = curr_helix
-            old_base = curr_base
-            seen[(curr_helix, curr_base)] = True
-            curr_helix, curr_base = vstrands[curr_helix][strand_type][curr_base][2:]
-            # Add crossover
-            # We have a crossover when we jump helix or when order is broken on same helix
-            # Or circular strand
-            if curr_helix != old_helix or (not direction_forward and curr_base > old_base) or (
-                    direction_forward and curr_base < old_base) or (
-                    curr_helix == strand_5_end_helix and curr_base == strand_5_end_base):
-
-                if direction_forward:
-                    end = old_base
-                else:
-                    start = old_base
-
-                domains.append(
-                    Domain(old_helix, direction_forward, min(start, end), max(start, end) + 1,
-                           deletions=Design._cadnano_v2_import_extract_deletions(
-                               vstrands[old_helix]['skip'], start, end),
-                           insertions=Design._cadnano_v2_import_extract_insertions(
-                               vstrands[old_helix]['loop'], start, end)))
-
-                direction_forward = (strand_type == 'scaf' and curr_helix % 2 == 0) or (
-                    (strand_type == 'stap' and curr_helix % 2 == 1))
-                start, end = -1, -1
-                if direction_forward:
-                    start = curr_base
-                else:
-                    end = curr_base
-
-        return domains
-
-    @staticmethod
-    def _cadnano_v2_import_circular_strands_merge_first_last_domains(domains: List[Domain]) -> None:
-        """ When we create domains for circular strands in the cadnano import routine, we may end up
-            with a fake crossover if first and last domain are on same helix, we have to merge them 
-            if it is the case.
-        """
-        if domains[0].helix != domains[-1].helix:
-            return
-
-        domains[0].start = min(domains[0].start, domains[-1].start)
-        domains[0].end = max(domains[0].end, domains[-1].end)
-
-        del domains[-1]
-
-    @staticmethod
-    def _cadnano_v2_import_explore_strand(vstrands: VStrands,
-                                          strand_type: str, seen: Dict[Tuple[int, int], bool],
-                                          helix_num: int,
-                                          base_id: int) -> Optional[Strand]:
-        """ Routine that will follow a cadnano v2 strand accross helices and create
-            cadnano domains and strand accordingly.
-        """
-
-        seen[(helix_num, base_id)] = True
-        id_from, base_from, id_to, base_to = vstrands[helix_num][strand_type][base_id]
-
-        if (id_from, base_from, id_to, base_to) == (-1, -1, -1, -1):
-            return None
-
-        strand_5_end_helix, strand_5_end_base, is_circular = Design._cadnano_v2_import_find_5_end(vstrands,
-                                                                                                  strand_type,
-                                                                                                  helix_num,
-                                                                                                  base_id,
-                                                                                                  id_from,
-                                                                                                  base_from)
-
-        strand_color = Design._cadnano_v2_import_find_strand_color(vstrands, strand_type,
-                                                                   strand_5_end_base,
-                                                                   strand_5_end_helix)
-        domains: List[Domain] = Design._cadnano_v2_import_explore_domains(vstrands, seen, strand_type,
-                                                                          strand_5_end_base,
-                                                                          strand_5_end_helix)
-        # merge first and last domain if circular
-        if is_circular:
-            Design._cadnano_v2_import_circular_strands_merge_first_last_domains(domains)
-        domains_loopouts = cast(List[Union[Domain, Loopout]],  # noqa
-                                domains)  # type: ignore
-        strand: Strand = Strand(domains=domains_loopouts,
-                                is_scaffold=(strand_type == 'scaf'), color=strand_color, circular=is_circular)
-
-        return strand
-
-    # remove quotes when Py3.6 support dropped
-    @staticmethod
-    def from_cadnano_v2(directory: Optional[str] = None, filename: Optional[str] = None,
-                        json_dict: Optional[dict] = None) -> 'Design':
-        """
-        Creates a Design from a cadnano v2 file.
-        """
-
-        if json_dict is None and filename is not None and directory is not None:
-            file_path = os.path.join(directory, filename)
-            f = open(file_path, 'r')
-            cadnano_v2_design = json.load(f)
-            f.close()
-        elif json_dict is not None:
-            cadnano_v2_design = json_dict
-        else:
-            raise ValueError('must have json_dict None and filename/directory not None, or vice versa')
-
-        num_bases = len(cadnano_v2_design['vstrands'][0]['scaf'])
-        grid_type = Grid.square
-        if num_bases % 21 == 0:
-            grid_type = Grid.honeycomb
-
-        min_row, min_col = None, None
-        for cadnano_helix in cadnano_v2_design['vstrands']:
-            col, row = cadnano_helix['col'], cadnano_helix['row']
-            min_row = row if min_row is None else min_row
-            min_col = col if min_col is None else min_col
-            min_row = row if row < min_row else min_row
-            min_col = col if col < min_col else min_col
-
-        helices = OrderedDict({})
-        for cadnano_helix in cadnano_v2_design['vstrands']:
-            col, row = cadnano_helix['col'], cadnano_helix['row']
-            num = cadnano_helix['num']
-            helix = Helix(idx=num, max_offset=num_bases, grid_position=(col, row))
-            helices[num] = helix
-
-        # We do a DFS on strands
-        seen: Dict[str, dict] = {'scaf': {}, 'stap': {}}
-        strands: List[Strand] = []
-        cadnano_helices = OrderedDict({})
-        for cadnano_helix in cadnano_v2_design['vstrands']:
-            helix_num = cadnano_helix['num']
-            cadnano_helices[helix_num] = cadnano_helix
-
-        for cadnano_helix in cadnano_v2_design['vstrands']:
-            helix_num = cadnano_helix['num']
-            for strand_type in ['scaf', 'stap']:
-                for base_id in range(num_bases):
-                    if (helix_num, base_id) in seen[strand_type]:
-                        continue
-
-                    strand = Design._cadnano_v2_import_explore_strand(cadnano_helices,
-                                                                      strand_type,
-                                                                      seen[strand_type], helix_num,
-                                                                      base_id)
-                    if strand is not None:
-                        strands.append(strand)
-
-        design: Design = Design(grid=grid_type, helices=helices, strands=strands)
-        # DD: Tristan, I commented this out because I think it's unnecessary given the way the Design
-        # constructor works, and because I'm now implementing this feature:
-        # https://github.com/UC-Davis-molecular-computing/scadnano-python-package/issues/121
-        # which means we may not have a well-defined helices_view_order on the whole design if groups
-        # are used
-        # design.set_helices_view_order([num for num in helices])
-
-        return design
-
-    def _all_modifications(self) -> Set[Modification]:
-        # List of all modifications.
-        mods_5p = {strand.modification_5p for strand in self.strands if
-                   strand.modification_5p is not None}
-        mods_3p = {strand.modification_3p for strand in self.strands if
-                   strand.modification_3p is not None}
-        mods_int = {mod for strand in self.strands for mod in strand.modifications_int.values()}
-        return mods_5p | mods_3p | mods_int
-
-    def strand(self, helix: int, offset: int) -> StrandBuilder:
-        """Used for chained method building by calling
-        :py:meth:`Design.strand` to build the :any:`Strand` domain by domain, in order from 5' to 3'.
-        For example
-
-        .. code-block:: Python
-
-            design.strand(0, 7).to(10).cross(1).to(5).cross(2).to(15)
-
-        This creates a :any:`Strand` in this :any:`Design` equivalent to
-
-        .. code-block:: Python
-
-            design.add_strand(Strand([
-                sc.Domain(0, True, 7, 10),
-                sc.Domain(1, False, 5, 10),
-                sc.Domain(2, True, 5, 15),
-            ]))
-
-        Loopouts can also be included:
-
-        .. code-block:: Python
-
-            design.strand(0, 7).to(10).cross(1).to(5).loopout(2, 3).to(15)
-
-        This creates a :any:`Strand` in this :any:`Design` equivalent to
-
-        .. code-block:: Python
-
-            design.add_strand(Strand([
-                sc.Domain(0, True, 7, 10),
-                sc.Domain(1, False, 5, 10),
-                sc.Loopout(3),
-                sc.Domain(2, True, 5, 15),
-            ]))
-
-        Each call to
-        :py:meth:`Design.strand`,
-        :py:meth:`StrandBuilder.cross`,
-        :py:meth:`StrandBuilder.loopout`,
-        :py:meth:`StrandBuilder.to`
-        :py:meth:`StrandBuilder.update_to`,
-        returns a :any:`StrandBuilder` object.
-
-        Each call to
-        :py:meth:`StrandBuilder.to`,
-        :py:meth:`StrandBuilder.update_to`,
-        or
-        :py:meth:`StrandBuilder.loopout`
-        modifies the :any:`Design` by replacing the Strand with an updated version.
-
-        See the documentation for :any:`StrandBuilder` for the methods available to call in this way.
-
-        :param helix: starting :any:`Helix`
-        :param offset: starting offset on `helix`
-        :return: :any:`StrandBuilder` object representing the partially completed :any:`Strand`
-        """
-        return StrandBuilder(self, helix, offset)
-
-    def assign_m13_to_scaffold(self, rotation: int = 5587, variant: M13Variant = M13Variant.p7249) -> None:
-        """Assigns the scaffold to be the sequence of M13: :py:func:`m13` with the given `rotation`
-        and :any:`M13Variant`.
-
-        Raises :any:`IllegalDesignError` if the number of scaffolds is not exactly 1.
-        """
-        scaffold = None
-        num_scafs = 0
-        for strand in self.strands:
-            if strand.is_scaffold:
-                num_scafs += 1
-                if scaffold is None:
-                    scaffold = strand
-        if num_scafs == 0:
-            raise IllegalDesignError(
-                'Tried to assign DNA to scaffold, but there is no scaffold strand.\n'
-                'You must set strand.is_scaffold to True for exactly one strand.')
-        elif num_scafs > 1:
-            raise IllegalDesignError(
-                'Tried to assign DNA to scaffold, but there are multiple scaffold strands.\n'
-                'You must set strand.is_scaffold to True for exactly one strand.')
-        if scaffold is None:
-            raise AssertionError('we counted; there is exactly one scaffold')
-        self.assign_dna(scaffold, m13(rotation, variant))
-
-    @staticmethod
-    def _get_multiple_of_x_sup_closest_to_y(x: int, y: int) -> int:
-        return y if y % x == 0 else y + (x - y % x)
-
-    @staticmethod
-    def _cadnano_v2_place_strand_segment(helix_dct: Dict[str, Any], domain: Domain,
-                                         strand_type: str = 'scaf') -> None:
-        """Converts a strand region with no crossover to cadnano v2.
-        """
-        # Insertions and deletions
-        for deletion in domain.deletions:
-            helix_dct['skip'][deletion] = -1
-        for loop_where, loop_nb in domain.insertions:
-            helix_dct['loop'][loop_where] = loop_nb
-
-        start, end, forward = domain.start, domain.end, domain.forward
-        strand_helix = helix_dct['num']
-
-        for i_base in range(start, end):
-            if forward:
-                from_helix, from_base = strand_helix, i_base - 1
-                to_helix, to_base = strand_helix, i_base + 1
-            else:
-                from_helix, from_base = strand_helix, i_base + 1
-                to_helix, to_base = strand_helix, i_base - 1
-
-            if i_base == start:
-                if forward:
-                    helix_dct[strand_type][i_base][2:] = [to_helix, to_base]
-                else:
-                    helix_dct[strand_type][i_base][:2] = [from_helix, from_base]
-            elif i_base < end - 1:
-                helix_dct[strand_type][i_base] = [from_helix, from_base, to_helix, to_base]
-            else:
-                if forward:
-                    helix_dct[strand_type][i_base][:2] = [from_helix, from_base]
-                else:
-                    helix_dct[strand_type][i_base][2:] = [to_helix, to_base]
-        return
-
-    @staticmethod
-    def _cadnano_v2_place_crossover(helix_from_dct: Dict[str, Any], helix_to_dct: Dict[str, Any],
-                                    domain_from: Domain, domain_to: Domain,
-                                    strand_type: str = 'scaf') -> None:
-        """Converts a crossover to cadnano v2 format.
-        Returns a conversion table from ids in the structure self.helices to helices ids
-        as given by helix.idx.
-        """
-
-        helix_from = helix_from_dct['num']
-        start_from, end_from, forward_from = domain_from.start, domain_from.end, domain_from.forward
-
-        helix_to = helix_to_dct['num']
-        start_to, end_to = domain_to.start, domain_to.end
-
-        if forward_from:
-            helix_from_dct[strand_type][end_from - 1][2:] = [helix_to, end_to - 1]
-            helix_to_dct[strand_type][end_to - 1][:2] = [helix_from, end_from - 1]
-        else:
-            helix_from_dct[strand_type][start_from][2:] = [helix_to, start_to]
-            helix_to_dct[strand_type][start_to][:2] = [helix_from, start_from]
-
-    @staticmethod
-    def _cadnano_v2_color_of_stap(color: Color, domain: Domain) -> List[int]:
-        base_id = domain.start if domain.forward else domain.end - 1
-        cadnano_color = color.to_cadnano_v2_int_hex()
-        return [base_id, cadnano_color]
-
-    def _cadnano_v2_place_strand(self, strand: Strand, dct: dict,
-                                 helices_ids_reverse: Dict[int, int]) -> None:
-        """Place a scadnano strand in cadnano v2.
-        """
-        strand_type = 'stap'
-        if hasattr(strand, is_scaffold_key) and strand.is_scaffold:
-            strand_type = 'scaf'
-
-        for i, domain in enumerate(strand.domains):
-            if isinstance(domain, Loopout):
-                raise ValueError(f'cannot convert Strand {strand} to cadnanov2 format, since it has Loopouts')
-
-            which_helix_id = helices_ids_reverse[domain.helix]
-            which_helix = dct['vstrands'][which_helix_id]
-
-            if strand_type == 'stap':
-                color = strand.color if strand.color is not None else Color(0, 0, 0)
-                which_helix['stap_colors'].append(self._cadnano_v2_color_of_stap(color, domain))
-
-            self._cadnano_v2_place_strand_segment(which_helix, domain, strand_type)
-
-            if i != len(strand.domains) - 1:
-                next_domain = strand.domains[i + 1]
-                if isinstance(next_domain, Loopout):
-                    raise ValueError(
-                        f'cannot convert Strand {strand} to cadnanov2 format, since it has Loopouts')
-                next_helix_id = helices_ids_reverse[next_domain.helix]
-                next_helix = dct['vstrands'][next_helix_id]
-                self._cadnano_v2_place_crossover(which_helix, next_helix,
-                                                 domain, next_domain, strand_type)
-
-        # if the strand is circular, we need to close the loop
-        if strand.circular:
-            first_domain = strand.first_bound_domain()
-            first_helix = dct['vstrands'][first_domain.helix]
-            first_start, first_end, first_forward = first_domain.start, first_domain.end, first_domain.forward
-
-            last_domain = strand.last_bound_domain()
-            last_helix = dct['vstrands'][last_domain.helix]
-            last_start, last_end, last_forward = last_domain.start, last_domain.end, last_domain.forward
-
-            the_base_from = last_end - 1
-            the_base_to = first_start
-
-            if not last_forward:
-                the_base_from = last_start
-
-            if not first_forward:
-                the_base_to = first_end - 1
-
-            if first_helix[strand_type][the_base_to][:2] == [-1, -1]:
-                first_helix[strand_type][the_base_to][:2] = [last_helix['num'], the_base_from]
-            else:
-                first_helix[strand_type][the_base_to][2:] = [last_helix['num'], the_base_from]
-
-            if last_helix[strand_type][the_base_from][:2] == [-1, -1]:
-                last_helix[strand_type][the_base_from][:2] = [first_helix['num'], the_base_to]
-            else:
-                last_helix[strand_type][the_base_from][2:] = [first_helix['num'], the_base_to]
-
-    def _cadnano_v2_fill_blank(self, dct: dict, num_bases: int, design_grid: Grid) -> Dict[int, int]:
-        """Creates blank cadnanov2 helices in and initialized all their fields.
-        """
-        helices_ids_reverse = {}
-        for i, helix in self.helices.items():
-            helix_dct: Dict[str, Any] = OrderedDict()
-            helix_dct['num'] = helix.idx
-
-            if design_grid == Grid.square or design_grid == Grid.honeycomb:
-                assert helix.grid_position is not None
-                helix_dct['row'] = helix.grid_position[1]
-                helix_dct['col'] = helix.grid_position[0]
-
-            helix_dct['scaf'] = []
-            helix_dct['stap'] = []
-            helix_dct['loop'] = []
-            helix_dct['skip'] = []
-
-            for _ in range(num_bases):
-                helix_dct['scaf'].append([-1, -1, -1, -1])
-                helix_dct['stap'].append([-1, -1, -1, -1])
-                helix_dct['loop'].append(0)
-                helix_dct['skip'].append(0)
-
-            helix_dct['stap_colors'] = []
-            helix_dct['scafLoop'] = []
-            helix_dct['stap_loop'] = []
-
-            helices_ids_reverse[helix_dct['num']] = i
-            dct['vstrands'].append(helix_dct)
-        return helices_ids_reverse
-
-    def to_cadnano_v2(self) -> Dict[str, Any]:
-        """Converts the design to the cadnano v2 format.
-        Please see the spec `misc/cadnano-format-specs/v2.txt` for more info on that format.
-        """
-        dct: Dict[str, Any] = OrderedDict()
-        dct['vstrands'] = []
-
-        '''Check if helix group are used or if only one grid is used'''
-        if self._has_default_groups():
-            design_grid = self.grid
-        else:
-            grid_used = {}
-            assert len(self.groups) > 0
-            grid_type = Grid.none
-            for group_name in self.groups:
-                grid_used[self.groups[group_name].grid] = True
-                grid_type = self.groups[group_name].grid
-            if len(grid_used) > 1:
-                raise ValueError('Designs using helix groups can be exported to cadnano v2 \
-                    only if all groups share the same grid type.')
-            else:
-                design_grid = grid_type
-
-        '''Figuring out the type of grid.
-        In cadnano v2, all helices have the same max offset 
-        called `num_bases` and the type of grid is determined as follows:
-            if num_bases % 32 == 0: then we are on grid square
-            if num_bases % 21 == 0: then we are on grid honey
-        '''
-        num_bases = 0
-        for helix in self.helices.values():
-            if helix.max_offset is None:
-                raise ValueError('must have helix.max_offset set')
-            num_bases = max(num_bases, helix.max_offset)
-
-        if design_grid == Grid.square:
-            num_bases = self._get_multiple_of_x_sup_closest_to_y(32, num_bases)
-        elif design_grid == Grid.honeycomb:
-            num_bases = self._get_multiple_of_x_sup_closest_to_y(21, num_bases)
-        else:
-            raise NotImplementedError('We can export to cadnano v2 `square` and `honeycomb` grids only.')
-
-        '''Figuring out if helices numbers have good parity.
-        In cadnano v2, only even helices have the scaffold go forward, only odd helices
-        have the scaffold go backward.
-
-        '''
-        for strand in self.strands:
-            for domain in strand.domains:
-                if isinstance(domain, Loopout):
-                    raise ValueError(
-                        'We cannot handle designs with Loopouts as it is not a cadnano v2 concept')
-                right_direction: bool
-                if hasattr(strand, is_scaffold_key) and strand.is_scaffold:
-                    right_direction = (domain.helix % 2 == int(not domain.forward))
-                else:
-                    right_direction = not (domain.helix % 2 == int(not domain.forward))
-
-                if not right_direction:
-                    raise ValueError('We can only convert designs where even helices have the scaffold'
-                                     'going forward and odd helices have the scaffold going backward see '
-                                     f'the spec v2.txt Note 4. {domain}')
-
-        '''Filling the helices with blank.
-        '''
-        helices_ids_reverse = self._cadnano_v2_fill_blank(dct, num_bases, design_grid)
-        '''Putting the scaffold in place.
-        '''
-
-        for strand in self.strands:
-            self._cadnano_v2_place_strand(strand, dct, helices_ids_reverse)
-
-        return dct
-
-    def set_helices_view_order(self, helices_view_order: List[int]) -> None:
-        """
-        Sets helices_view_order.
-
-        :param helices_view_order: new view order of helices
-        """
-        if not self._has_default_groups():
-            raise ValueError('cannot call set_helices_view_order on a Design that uses HelixGroups')
-        group = self._default_group()
-        group.helices_view_order = helices_view_order
-        _check_helices_view_order_is_bijection(helices_view_order,
-                                               self.helices_idxs_in_group(default_group_name))
-
-    def _default_group(self) -> HelixGroup:
-        if not self._has_default_groups():
-            raise ValueError('cannot call _default_group on a Design that uses HelixGroups')
-        groups_list = list(self.groups.values())
-        return groups_list[0]
-
-    def _set_helices_grid_positions_or_positions(self) -> None:
-        for name, group in self.groups.items():
-            for idx in self.helices_idxs_in_group(name):
-                helix = self.helices[idx]
-                if group.grid != Grid.none and helix.grid_position is None:
-                    helix.grid_position = (0, group.helices_view_order_inverse(idx))
-                elif group.grid == Grid.none and helix.position is None:
-                    y_delta = self.geometry.distance_between_helices()
-                    y = y_delta * group.helices_view_order_inverse(idx)
-                    helix.position = Position3D(x=0, y=y, z=0)
-
-    def _set_helices_min_max_offsets(self, update: bool) -> None:
-        """update = whether to overwrite existing Helix.max_offset and Helix.min_offset.
-        Don't do this when Design is first created, but do it later when updating."""
-        for helix in self.helices.values():
-
-            if update or helix.max_offset is None:
-                max_offset = None if len(helix.domains) == 0 else helix.domains[0].end
-                for domain in helix.domains:
-                    max_offset = max(max_offset, domain.end)
-                helix.max_offset = max_offset
-
-            if update or helix.min_offset is None:
-                min_offset = None if len(helix.domains) == 0 else helix.domains[0].start
-                for domain in helix.domains:
-                    min_offset = min(min_offset, domain.start)
-                if min_offset is None or min_offset > 0:
-                    min_offset = 0
-                helix.min_offset = min_offset
-
-    def strands_starting_on_helix(self, helix: int) -> List[Strand]:
-        """Return list of :any:`Strand`'s that begin (have their 5' end)
-        on the :any:`Helix` with index `helix`."""
-        return [strand for strand in self.strands
-                if isinstance(strand.domains[0], Domain) and strand.domains[0].helix == helix]
-
-    def strands_ending_on_helix(self, helix: int) -> List[Strand]:
-        """Return list of :any:`Strand`'s that finish (have their 3' end)
-        on the :any:`Helix` with index `helix`."""
-        return [strand for strand in self.strands
-                if isinstance(strand.domains[-1], Domain) and strand.domains[-1].helix == helix]
-
-    def _check_legal_design(self) -> None:
-        self._check_helix_offsets()
-        self._check_strands_reference_helices_legally()
-        self._check_loopouts_not_consecutive_or_singletons_or_zero_length()
-        self._check_loopouts_not_first_or_last_substrand()
-        self._check_strands_overlap_legally()
-        self._warn_if_strand_names_not_unique()
-
-    # TODO: come up with reasonable default behavior when no strands are on helix and max_offset not given
-    def _check_helix_offsets(self) -> None:
-        for helix in self.helices.values():
-            if helix.min_offset is not None \
-                    and helix.max_offset is not None \
-                    and helix.min_offset >= helix.max_offset:
-                err_msg = f'for helix {helix.idx}, ' \
-                          f'helix.min_offset = {helix.min_offset} must be strictly less than ' \
-                          f'helix.max_offset = {helix.max_offset}'
-                raise IllegalDesignError(err_msg)
-
-    def _check_strands_overlap_legally(self, domain_to_check: Optional[Domain] = None) -> None:
-        """If `Domain_to_check` is None, check all.
-        Otherwise only check pairs where one is domain_to_check."""
-
-        def err_msg(d1: Domain, d2: Domain, h_idx: int) -> str:
-            return f"two domains overlap on helix {h_idx}: " \
-                   f"\n{d1}\n  and\n{d2}\n  but have the same direction"
-
-        # ensure that if two strands overlap on the same helix,
-        # they point in opposite directions
-        for helix_idx, domains in enumerate(helix.domains for helix in self.helices.values()):
-            if domain_to_check is not None and domain_to_check.helix != helix_idx:
-                # TODO: if necessary, we can be more efficient by only checking this one domain
-                continue
-
-            if len(domains) == 0:
-                continue
-
-            # check all consecutive domains on the same helix, sorted by start/end indices
-            offsets_data = []
-            for domain in domains:
-                offsets_data.append((domain.start, True, domain))
-                offsets_data.append((domain.end, False, domain))
-            offsets_data.sort(key=lambda offset_data: offset_data[0])
-
-            current_domains: List[Domain] = []
-            for offset, is_start, domain in offsets_data:
-                if is_start:
-                    if len(current_domains) >= 2:
-                        if offset >= current_domains[1].end:
-                            del current_domains[1]
-                    if len(current_domains) >= 1:
-                        if offset >= current_domains[0].end:
-                            del current_domains[0]
-                    current_domains.append(domain)
-                    if len(current_domains) > 2:
-                        domain0, domain1, domain2 = current_domains[0:3]
-                        for d_first, d_second in [(domain0, domain1), (domain1, domain2), (domain0, domain2)]:
-                            if d_first.forward == d_second.forward:
-                                raise IllegalDesignError(err_msg(d_first, d_second, helix_idx))
-                        raise AssertionError(
-                            f"since current_domains = {current_domains} has at least three domains, "
-                            f"I expected to find a pair of illegally overlapping domains")
-                    elif len(current_domains) == 2:
-                        d_first, d_second = current_domains
-                        if d_first.forward == d_second.forward:
-                            raise IllegalDesignError(err_msg(d_first, d_second, helix_idx))
-
-    def _check_loopouts_not_consecutive_or_singletons_or_zero_length(self) -> None:
-        for strand in self.strands:
-            Design._check_loopout_not_singleton(strand)
-            Design._check_two_consecutive_loopouts(strand)
-            Design._check_loopouts_length(strand)
-
-    def _check_loopouts_not_first_or_last_substrand(self) -> None:
-        for strand in self.strands:
-            if isinstance(strand.first_domain(), Loopout):
-                raise StrandError(strand, 'strand cannot have a Loopout as its first domain')
-            if isinstance(strand.last_domain(), Loopout):
-                raise StrandError(strand, 'strand cannot have a Loopout as its last domain')
-
-    @staticmethod
-    def _check_loopout_not_singleton(strand: Strand) -> None:
-        if len(strand.domains) == 1 and isinstance(strand.first_domain(), Loopout):
-            raise StrandError(strand, 'strand cannot have a single Loopout as its only domain')
-
-    @staticmethod
-    def _check_two_consecutive_loopouts(strand: Strand) -> None:
-        for domain1, domain2 in _pairwise(strand.domains):
-            if isinstance(domain1, Loopout) and isinstance(domain2, Loopout):
-                raise StrandError(strand, 'cannot have two consecutive Loopouts in a strand')
-
-    @staticmethod
-    def _check_loopouts_length(strand: Strand) -> None:
-        for loopout in strand.domains:
-            if isinstance(loopout, Loopout) and loopout.length <= 0:
-                raise StrandError(strand, f'loopout length must be positive but is {loopout.length}')
-
-    def _check_strands_reference_helices_legally(self) -> None:
-        # ensure each strand refers to an existing helix
-        for strand in self.strands:
-            self._check_strand_references_legal_helices(strand)
-            self._check_strand_has_legal_offsets_in_helices(strand)
-
-    def _check_strand_references_legal_helices(self, strand: Strand) -> None:
-        for domain in strand.domains:
-            if isinstance(domain, Domain) and domain.helix not in self.helices:
-                err_msg = f"domain {domain} refers to nonexistent Helix index {domain.helix}; " \
-                          f"here is the list of valid helices: {self._helices_to_string()}"
-                raise StrandError(strand, err_msg)
-
-    def _check_strand_has_legal_offsets_in_helices(self, strand: Strand) -> None:
-        for domain in strand.domains:
-            if isinstance(domain, Domain):
-                helix = self.helices[domain.helix]
-                if helix.min_offset is not None and domain.start < helix.min_offset:
-                    err_msg = f"domain {domain} has start offset {domain.start}, " \
-                              f"beyond the end of " \
-                              f"Helix {domain.helix} that has min_offset = {helix.min_offset}"
-                    raise StrandError(strand, err_msg)
-                if helix.max_offset is not None and domain.end > helix.max_offset:
-                    err_msg = f"domain {domain} has end offset {domain.end}, " \
-                              f"beyond the end of " \
-                              f"Helix {domain.helix} that has max_offset = {helix.max_offset}"
-                    raise StrandError(strand, err_msg)
-
-        # ensure helix_idx's are never negative twice in a row
-        for domain1, domain2 in _pairwise(strand.domains):
-            if isinstance(domain1, Loopout) and isinstance(domain2, Loopout):
-                err_msg = f"Loopouts {domain1} and {domain2} are consecutive on strand {strand}. " \
-                          f"At least one of any consecutive pair must be a Domain, not a Loopout."
-                raise StrandError(strand, err_msg)
-
-    def set_helix_idx(self, old_idx: int, new_idx: int) -> None:
-        if new_idx in self.helices:
-            raise IllegalDesignError(f'cannot assign idx {new_idx} to helix {old_idx}; '
-                                     'another helix already has that index')
-        helix: Helix = self.helices[old_idx]
-        del self.helices[old_idx]
-        self.helices[new_idx] = helix
-        helix.idx = new_idx
-        for domain in helix.domains:
-            domain.helix = new_idx
-
-    def domain_at(self, helix: int, offset: int, forward: bool) -> Optional[Domain]:
-        """
-        Return :any:`Domain` that overlaps `offset` on helix with idx `helix` and has
-        :py:data:`Domain.forward` = ``True``, or ``None`` if there is no such :any:`Domain`.
-
-        :param helix: TODO
-        :param offset: TODO
-        :param forward: TODO
-        :return: TODO
-        """
-        for domain in self.domains_at(helix, offset):
-            if domain.forward == forward:
-                return domain
-        return None
-
-    def domains_at(self, helix: int, offset: int) -> List[Domain]:
-        """Return list of :any:`Domain`'s that overlap `offset` on helix with idx `helix`.
-
-        If constructed properly, this list should have 0, 1, or 2 elements."""
-        domains_on_helix = self.helices[helix].domains
-        # TODO: replace this with a faster algorithm using binary search
-        domains_on_helix = [domain for domain in domains_on_helix if
-                            domain.contains_offset(offset)]
-        if len(domains_on_helix) not in [0, 1, 2]:
-            raise AssertionError(f'There should be at most 2 domains on helix {helix}, '
-                                 f'but there are {len(domains_on_helix)}:\n{domains_on_helix}')
-        return domains_on_helix
-
-    # TODO: add_strand and insert_domain should check for existing deletions/insertion parallel strands
-    def add_strand(self, strand: Strand) -> None:
-        """Add `strand` to this design."""
-        self._check_strand_references_legal_helices(strand)
-        self.strands.append(strand)
-        for domain in strand.domains:
-            if isinstance(domain, Domain):
-                self.helices[domain.helix].domains.append(domain)
-                self._check_strands_overlap_legally(domain_to_check=domain)
-        if self.automatically_assign_color:
-            self._assign_color_to_strand(strand)
-
-    def remove_strand(self, strand: Strand) -> None:
-        """Remove `strand` from this design."""
-        self.strands.remove(strand)
-        for domain in strand.domains:
-            if isinstance(domain, Domain):
-                self.helices[domain.helix].domains.remove(domain)
-
-    def append_domain(self, strand: Strand, domain: Union[Domain, Loopout]) -> None:
-        """
-        Same as :any:`Design.insert_domain`, but inserts at end.
-
-        :param strand: strand to append `domain` to
-        :param domain: :any:`Domain` or :any:`Loopout` to append to :any:`Strand`
-        """
-        self.insert_domain(strand, len(strand.domains), domain)
-
-    def insert_domain(self, strand: Strand, order: int, domain: Union[Domain, Loopout]) -> None:
-        """Insert `Domain` into `strand` at index given by `order`. Uses same indexing as Python lists,
-        e.g., ``design.insert_domain(strand, domain, 0)``
-        inserts ``domain`` as the new first :any:`Domain`."""
-        if isinstance(domain, Domain) and domain.helix not in self.helices:
-            err_msg = f"domain {domain} refers to nonexistent Helix index {domain.helix}; " \
-                      f"here is the list of valid helices: {self._helices_to_string()}"
-            raise StrandError(strand, err_msg)
-
-        assert strand in self.strands
-        strand.insert_domain(order, domain)
-        self._check_strand_references_legal_helices(strand)
-        self._check_loopouts_not_consecutive_or_singletons_or_zero_length()
-        if isinstance(domain, Domain):
-            self.helices[domain.helix].domains.append(domain)
-            self._check_strands_overlap_legally(domain_to_check=domain)
-
-    def remove_domain(self, strand: Strand, domain: Union[Domain, Loopout]) -> None:
-        """Remove `Domain` from `strand`."""
-        assert strand in self.strands
-        strand.remove_domain(domain)
-        if isinstance(domain, Domain):
-            self.helices[domain.helix].domains.remove(domain)
-
-    def _build_domains_on_helix_lists(self) -> None:
-        for helix in self.helices.values():
-            helix._domains = []
-        for strand in self.strands:
-            for domain in strand.domains:
-                if isinstance(domain, Domain):
-                    if domain.helix in self.helices:
-                        self.helices[domain.helix].domains.append(domain)
-                    else:
-                        msg = f"domain's helix is {domain.helix} but no helix has that index; here " \
-                              f"is the list of helix indices: {self._helices_to_string()}"
-                        raise StrandError(strand=strand, the_cause=msg)
-
-    def _helices_to_string(self) -> str:
-        return ', '.join(map(str, self.helices.keys()))
-
-    @_docstring_parameter(default_extension=default_scadnano_file_extension)
-    def to_json(self, suppress_indent: bool = True) -> str:
-        """Return string representing this Design, suitable for reading by scadnano if written to
-        a JSON file ending in extension .{default_extension}"""
-        # if isinstance(self, DNAOrigamiDesign):
-        #     scaf = None
-        #     for strand in self.strands:
-        #         if strand.is_scaffold == True:
-        #             scaf = strand
-        #             break
-        #     if self.scaffold is None:
-        #         msg = 'No scaffold specified for Design. You can delay assigning the scaffold ' \
-        #               'until after creating the Design object, but you must assign a scaffold ' \
-        #               'using the method Strand.set_scaffold() before calling to_json().'
-        #         if scaf is not None:
-        #             msg += f'There is a strand marked as a scaffold. Try calling set_scaffold with it as ' \
-        #                    f'a parameter:\n{scaf}'
-        #         raise IllegalDesignError(msg)
-        return _json_encode(self, suppress_indent)
-
-    # TODO: create version of add_deletion and add_insertion that simply changes the major tick distance
-    #  on the helix at that position, as well as updating the end offset of the domain (and subsequent
-    #  domains on the same helix)
-
-    def add_deletion(self, helix: int, offset: int) -> None:
-        """Adds a deletion to every :class:`scadnano.Strand` at the given helix and base offset."""
-        domains = self.domains_at(helix, offset)
-        if len(domains) == 0:
-            raise IllegalDesignError(f"no domains are at helix {helix} offset {offset}")
-        for domain in domains:
-            if domain.contains_offset(offset):
-                domain.deletions.append(offset)
-
-    def add_insertion(self, helix: int, offset: int, length: int) -> None:
-        """Adds an insertion with the given length to every :class:`scadnano.Strand`
-        at the given helix and base offset, with the given length."""
-        domains = self.domains_at(helix, offset)
-        if len(domains) == 0:
-            raise IllegalDesignError(f"no domains are at helix {helix} offset {offset}")
-        for domain in domains:
-            if domain.contains_offset(offset):
-                domain.insertions.append((offset, length))
-
-    def set_start(self, domain: Domain, start: int) -> None:
-        """Sets ``Domain.start`` to `start`."""
-        assert domain in (domain for strand in self.strands for domain in strand.domains)
-        domain.set_start(start)
-        self._check_strands_overlap_legally(domain)
-
-    def set_end(self, domain: Domain, end: int) -> None:
-        """Sets ``Domain.end`` to `end`."""
-        assert domain in (domain for strand in self.strands for domain in strand.domains)
-        domain.set_end(end)
-        self._check_strands_overlap_legally(domain)
-
-    def move_strand_offsets(self, delta: int) -> None:
-        """Moves all strands backward (if `delta` < 0) or forward (if `delta` > 0) by `delta`."""
-        for strand in self.strands:
-            for domain in strand.bound_domains():
-                domain.start += delta
-                domain.end += delta
-        self._check_strands_overlap_legally()
-
-    def move_strands_on_helices(self, delta: int) -> None:
-        """Moves all strands up (if `delta` < 0) or down (if `delta` > 0) by the number of helices given by
-        `delta`."""
-        for strand in self.strands:
-            for domain in strand.bound_domains():
-                domain.helix += delta
-        self._check_strands_reference_helices_legally()
-
-    def assign_dna(self, strand: Strand, sequence: str, assign_complement: bool = True,
-                   domain: Union[Domain, Loopout] = None, check_length: bool = False) -> None:
-        """
-        Assigns `sequence` as DNA sequence of `strand`.
-
-        If any :class:`scadnano.Strand` is bound to `strand`,
-        it is assigned the reverse Watson-Crick complement of the relevant portion,
-        and any remaining portions of the other strand that have not already been assigned a DNA sequence
-        are assigned to be the symbol :py:data:`DNA_base_wildcard`.
-
-        Before assigning, `sequence` is first forced to be the same length as `strand` as follows:
-        If `sequence` is longer, it is truncated.
-        If `sequence` is shorter, it is padded with :py:data:`DNA_base_wildcard`'s.
-        This can be disabled by setting `check_length` to True, in which case the method raises an
-        :any:`IllegalDesignError` if the lengths do not match.
-
-        All whitespace in `sequence` is removed, and lowercase bases
-        'a', 'c', 'g', 't' are converted to uppercase.
-
-        :param strand:
-            :any:`Strand` to assign DNA sequence to
-        :param sequence:
-            string of DNA bases to assign
-        :param assign_complement:
-            Whether to assign the complement DNA sequence to any :any:`Strand` that
-            is bound to this one (default True)
-        :param domain:
-            :any:`Domain` on `strand` to assign. If ``None``, then the whole :any:`Strand` is
-            given a DNA sequence. Otherwise, only `domain` is assigned, and the rest of the :any:`Domain`'s
-            on `strand` are left alone (either keeping their DNA sequence, or being assigned
-            :py:const:`DNA_base_wildcard` if no DNA sequence was previously assigned.)
-            If `domain` is specified, then ``len(sequence)`` must be least than or equal to the number
-            of bases on `domain`. (i.e., ``domain.dna_length()``)
-        :param check_length:
-            If True, raises :any:`IllegalDesignError` if length of :any:`Strand` or :any:`Domain` being
-            assigned to does not match the length of the DNA sequence.
-        :raises IllegalDesignError:
-            If `check_length` is True and the length of :any:`Strand` or :any:`Domain` being
-            assigned to does not match the length of the DNA sequence.
-        """
-        start = 0
-        if domain is not None:
-            pos = strand.domains.index(domain)
-            start = sum(prev_dom.dna_length() for prev_dom in strand.domains[:pos])
-            if domain.dna_length() < len(sequence):
-                raise IllegalDesignError(f'cannot assign sequence {sequence} to strand domain '
-                                         f'\n{domain}\n'
-                                         f'The number of bases on the domain is {domain.dna_length()} '
-                                         f'but the length of the sequence is {len(sequence)}. The length of '
-                                         f'the sequence must be at most the number of bases on the domain.')
-            elif domain.dna_length() > len(sequence) and check_length:
-                raise IllegalDesignError(f'cannot assign sequence {sequence} to strand domain '
-                                         f'\n{domain}\n'
-                                         f'The number of bases on the domain is {domain.dna_length()} '
-                                         f'but the length of the sequence is {len(sequence)}. Since the '
-                                         f'parameter `check_length` is set to True, these lengths must '
-                                         f'be exactly equal.')
-        elif check_length and len(sequence) != strand.dna_length():
-            raise IllegalDesignError(f'cannot assign sequence {sequence} to strand '
-                                     f'\n{strand}\n'
-                                     f'The number of bases on the strand is {strand.dna_length()} '
-                                     f'but the length of the sequence is {len(sequence)}. Since the '
-                                     f'parameter `check_length` is set to True, these lengths must '
-                                     f'be exactly equal.')
-
-        padded_sequence = _pad_and_remove_whitespace_and_uppercase(sequence, strand, start)
-
-        if strand is None:
-            raise IllegalDesignError('strand cannot be None to assign DNA to it')
-        if strand not in self.strands:
-            raise StrandError(strand, 'strand is not in the given Design')
-
-        if strand.dna_sequence is None:
-            merged_sequence = padded_sequence
-        else:
-            try:
-                merged_sequence = _string_merge_wildcard(strand.dna_sequence, padded_sequence,
-                                                         DNA_base_wildcard)
-            except ValueError:
-                first_domain = strand.first_domain()
-                msg = f'strand starting at helix {first_domain.helix}, offset {first_domain.offset_5p()} has ' \
-                      f'length ' \
-                      f'{strand.dna_length()} and already has a DNA sequence assignment of length ' \
-                      f'{len(strand.dna_sequence)}, which is \n' \
-                      f'{strand.dna_sequence}, ' \
-                      f'but you tried to assign a different sequence of length {len(padded_sequence)} to ' \
-                      f'it, which is\n{padded_sequence}.'
-                raise IllegalDesignError(msg)
-
-        strand.set_dna_sequence(merged_sequence)
-
-        if not assign_complement:
-            return
-
-        for other_strand in self.strands:
-            # note that possibly strand==other_strand; it might bind to itself at some point and we want to
-            # allow a partial assignment to one domain to automatically assign the complement to the
-            # bound domain.
-            # However, if there are no wildcards in the assigned sequence we can safely skip strand.
-            if strand == other_strand \
-                    and strand.dna_sequence is not None \
-                    and DNA_base_wildcard not in strand.dna_sequence:
-                continue
-            if other_strand.overlaps(strand):
-                # we do this even if other_strand has a complete DNA sequence,
-                # because we get complementarity checks this way
-                other_strand.assign_dna_complement_from(strand)
-
-    def to_idt_bulk_input_format(self,
-                                 delimiter: str = ',',
-                                 key: Optional[KeyFunction[Strand]] = None,
-                                 warn_duplicate_name: bool = False,
-                                 only_strands_with_idt: bool = False,
-                                 export_scaffold: bool = False,
-                                 export_non_modified_strand_version: bool = False) -> str:
-        """Called by :py:meth:`Design.write_idt_bulk_input_file` to determine what string to write to
-        the file. This function can be used to get the string directly without creating a file.
-
-        Parameters have the same meaning as in :py:meth:`Design.write_idt_bulk_input_file`.
-
-        :return:
-            string that is written to the file in the method :py:meth:`Design.write_idt_bulk_input_file`.
-        """
-        strands_to_export = self._idt_strands_to_export(key=key, warn_duplicate_name=warn_duplicate_name,
-                                                        only_strands_with_idt=only_strands_with_idt,
-                                                        export_scaffold=export_scaffold,
-                                                        export_non_modified_strand_version=export_non_modified_strand_version)
-        print('hello')
-        idt_lines: List[str] = []
-        for strand in strands_to_export:
-            if strand.idt is None and only_strands_with_idt:
-                raise AssertionError(f'cannot export strand {strand} to IDT because it has no IDT field; '
-                                     f'since only_strands_with_idt is True, '
-                                     f'this strand should have been filtered out by _idt_strands_to_export')
-            if strand.idt is not None:
-                scale = strand.idt.scale
-                purification = strand.idt.scale
-            else:
-                scale = default_idt_scale
-                purification = default_idt_purification
-            idt_lines.append(delimiter.join(
-                [strand.idt_export_name(), strand.idt_dna_sequence(),
-                 scale, purification]
-            ))
-
-        idt_string = '\n'.join(idt_lines)
-        return idt_string
-
-    def _idt_strands_to_export(self, *,
-                               key: Optional[KeyFunction[Strand]] = None,  # for sorting strands
-                               warn_duplicate_name: bool,
-                               only_strands_with_idt: bool = False,
-                               export_scaffold: bool = False,
-                               export_non_modified_strand_version: bool = False) -> List[Strand]:
-        # gets list of strands to export for IDT export functions
-        added_strands: Dict[str, Strand] = {}  # dict: name -> strand
-        for strand in self.strands:
-            # skip scaffold unless requested to export
-            if strand.is_scaffold and not export_scaffold:
-                continue
-
-            # skip strands with no IDT field unless requested to export
-            if strand.idt is None and only_strands_with_idt:
-                continue
-
-            # figure out what name to export
-            name = strand.idt_export_name()
-
-            if name in added_strands:
-                existing_strand = added_strands[name]
-                self._check_strands_with_same_name_agree_on_other_idt_fields(strand, existing_strand, name,
-                                                                             warn_duplicate_name)
-
-            added_strands[name] = strand
-            if export_non_modified_strand_version:
-                added_strands[name + '_nomods'] = strand.no_modifications_version()
-
-        strands = list(added_strands.values())
-        if key is not None:
-            strands.sort(key=key)
-        return strands
-
-    @staticmethod
-    def _check_strands_with_same_name_agree_on_other_idt_fields(strand: Strand, existing_strand: Strand,
-                                                                name: str, warn_duplicate_name: bool) -> None:
-        # Handle the case that two strands being exported, strand and existing_strand
-        # (the latter was encountered first) have the same name
-        # This is allowed in case one wants to draw multiple copies of the same strand
-        # in scadnano without having to worry about setting their idt fields differently.
-        # But then we need to check that they agree on everything being exported.
-        if existing_strand.name is not None:
-            assert existing_strand.name == name
-        domain = strand.first_domain()
-        existing_domain = existing_strand.first_domain()
-        if warn_duplicate_name:
-            print(
-                f'WARNING: two strands with same IDT name {name}:\n'
-                f'  strand 1: helix {domain.helix}, 5\' end at offset {domain.offset_5p()}\n'
-                f'  strand 2: helix {existing_domain.helix}, 5\' end at offset '
-                f'{existing_domain.offset_5p()}\n')
-        if strand.dna_sequence != existing_strand.dna_sequence:
-            raise IllegalDesignError(
-                f'two strands with same IDT name {name} but different sequences:\n'
-                f'  strand 1: helix {domain.helix}, 5\' end at offset {domain.offset_5p()}, '
-                f'sequence: {strand.dna_sequence}\n'
-                f'  strand 2: helix {existing_domain.helix}, 5\' end at offset '
-                f'{existing_domain.offset_5p()}, '
-                f'sequence: {existing_strand.dna_sequence}\n')
-        elif strand.idt is not None \
-                and existing_strand.idt is not None:
-            if strand.idt.scale != existing_strand.idt.scale:
-                raise IllegalDesignError(
-                    f'two strands with same name {name} but different IDT scales:\n'
-                    f'  strand 1: helix {domain.helix}, 5\' end at offset {domain.offset_5p()}, '
-                    f'scale: {strand.idt.scale}\n'
-                    f'  strand 2: helix {existing_domain.helix}, 5\' end at offset '
-                    f'{existing_domain.offset_5p()}, '
-                    f'scale: {existing_strand.idt.scale}\n')
-            elif strand.idt.purification != existing_strand.idt.purification:
-                raise IllegalDesignError(
-                    f'two strands with same name {name} but different purifications:\n'
-                    f'  strand 1: helix {domain.helix}, 5\' end at offset {domain.offset_5p()}, '
-                    f'purification: {strand.idt.purification}\n'
-                    f'  strand 2: helix {existing_domain.helix}, 5\' end at offset '
-                    f'{existing_domain.offset_5p()}, '
-                    f'purification: {existing_strand.idt.purification}\n')
-
-    def write_idt_bulk_input_file(self, *, directory: str = '.', filename: str = None,
-                                  key: Optional[KeyFunction[Strand]] = None,
-                                  extension: Optional[str] = None,
-                                  delimiter: str = ',',
-                                  warn_duplicate_name: bool = True,
-                                  only_strands_with_idt: bool = False,
-                                  export_scaffold: bool = False,
-                                  export_non_modified_strand_version: bool = False) -> None:
-        """Write ``.idt`` text file encoding the strands of this :any:`Design` with the field
-        :any:`Strand.idt`, suitable for pasting into the "Bulk Input" field of IDT
-        (Integrated DNA Technologies, Coralville, IA, https://www.idtdna.com/),
-        with the output file having the same name as the running script but with ``.py`` changed to ``.idt``,
-        unless `filename` is explicitly specified.
-        For instance, if the script is named ``my_origami.py``,
-        then the sequences will be written to ``my_origami.idt``.
-        If `filename` is not specified but `extension` is, then that extension is used instead of ``idt``.
-        At least one of `filename` or `extension` must be ``None``.
-
-        The string written is that returned by :meth:`Design.to_idt_bulk_input_format`.
-
-        :param directory:
-            specifies a directory in which to place the file, either absolute or relative to
-            the current working directory. Default is the current working directory.
-        :param filename:
-            optinoal custom filename to use (instead of currently running script)
-        :param key:
-            `key function <https://docs.python.org/3/howto/sorting.html#key-functions>`_ used to determine
-            order in which to output strand sequences. Some useful defaults are provided by
-            :py:meth:`strand_order_key_function`
-        :param extension:
-            alternate filename extension to use (instead of idt)
-        :param delimiter:
-            is the symbol to delimit the four IDT fields name,sequence,scale,purification.
-        :param warn_duplicate_name:
-            if ``True`` prints a warning when two different :any:`Strand`'s have the same
-            :py:attr:`IDTField.name` and the same :any:`Strand.dna_sequence`. An :any:`IllegalDesignError` is
-            raised (regardless of the value of this parameter)
-            if two different :any:`Strand`'s have the same name but different sequences, IDT scales, or IDT
-            purifications.
-        :param only_strands_with_idt:
-            If False (the default), all non-scaffold sequences are output, with reasonable default values
-            chosen if the field :py:data:`Strand.idt` is missing.
-            (though scaffold is included if `export_scaffold` is True).
-            If True, then strands lacking the field :any:`Strand.idt` will not be exported.
-        :param export_scaffold:
-            If False (the default), scaffold sequences are not exported.
-            If True, scaffold sequences on strands output according to `only_strands_with_idt`
-            (i.e., scaffolds will be exported, unless they lack the field :any:`Strand.idt` and
-            `only_strands_with_idt` is True).
-        :param export_non_modified_strand_version:
-            For any :any:`Strand` with a :any:`Modification`, also export a version of the :any:`Strand`
-            without any modifications. The name for this :any:`Strand` is the original name with
-            '_nomods' appended to it.
-        """
-        contents = self.to_idt_bulk_input_format(delimiter=delimiter,
-                                                 key=key,
-                                                 warn_duplicate_name=warn_duplicate_name,
-                                                 only_strands_with_idt=only_strands_with_idt,
-                                                 export_scaffold=export_scaffold,
-                                                 export_non_modified_strand_version=export_non_modified_strand_version)
-        if extension is None:
-            extension = 'idt'
-        _write_file_same_name_as_running_python_script(contents, extension, directory, filename)
-
-    def write_idt_plate_excel_file(self, *, directory: str = '.', filename: str = None,
-                                   key: Optional[KeyFunction[Strand]] = None,
-                                   warn_duplicate_name: bool = False,
-                                   only_strands_with_idt: bool = False,
-                                   export_scaffold: bool = False,
-                                   use_default_plates: bool = True, warn_using_default_plates: bool = True,
-                                   plate_type: PlateType = PlateType.wells96,
-                                   export_non_modified_strand_version: bool = False) -> None:
-        """
-        Write ``.xls`` (Microsoft Excel) file encoding the strands of this :any:`Design` with the field
-        :py:data:`Strand.idt`, suitable for uploading to IDT
-        (Integrated DNA Technologies, Coralville, IA, https://www.idtdna.com/)
-        to describe a 96-well or 384-well plate
-        (https://www.idtdna.com/site/order/plate/index/dna/),
-        with the output file having the same name as the running script but with ``.py`` changed to ``.xls``,
-        unless `filename` is explicitly specified.
-        For instance, if the script is named ``my_origami.py``,
-        then the sequences will be written to ``my_origami.xls``.
-
-        :param directory:
-            specifies a directory in which to place the file, either absolute or relative to
-            the current working directory. Default is the current working directory.
-        :param filename:
-            custom filename if default (explained above) is not desired
-        :param key:
-            `key function <https://docs.python.org/3/howto/sorting.html#key-functions>`_ used to determine
-            order in which to output strand sequences. Some useful defaults are provided by
-            :py:meth:`strand_order_key_function`
-        :param warn_duplicate_name:
-            if ``True`` prints a warning when two different :any:`Strand`'s have the same
-            :py:attr:`IDTField.name` and the same :any:`Strand.dna_sequence`. An :any:`IllegalDesignError` is
-            raised (regardless of the value of this parameter)
-            if two different :any:`Strand`'s have the same name but different sequences, IDT scales, or IDT
-            purifications.
-        :param only_strands_with_idt:
-            If False (the default), all non-scaffold sequences are output, with reasonable default values
-            chosen if the field :py:data:`Strand.idt` is missing.
-            (though scaffold is included if `export_scaffold` is True).
-            If True, then strands lacking the field :any:`Strand.idt` will not be exported.
-            If False, then `use_default_plates` must be True.
-        :param export_scaffold:
-            If False (the default), scaffold sequences are not exported.
-            If True, scaffold sequences on strands output according to `only_strands_with_idt`
-            (i.e., scaffolds will be exported, unless they lack the field :any:`Strand.idt` and
-            `only_strands_with_idt` is True).
-        :param use_default_plates:
-            Use default values for plate and well (ignoring those in idt fields, which may be None).
-            If False, each Strand to export must have the field :py:data:`Strand.idt`, so in particular
-            the parameter `only_strands_with_idt` must be True.
-        :param warn_using_default_plates:
-            specifies whether, if `use_default_plates` is True, to print a warning for strands whose
-            :py:data:`Strand.idt` has the fields :py:data:`IDTFields.plate` and :py:data:`IDTFields.well`,
-            since `use_default_plates` directs these fields to be ignored.
-        :param plate_type:
-            a :any:`PlateType` specifying whether to use a 96-well plate or a 384-well plate
-            if the `use_default_plates` parameter is ``True``.
-            Ignored if `use_default_plates` is ``False``, because in that case the wells are explicitly set
-            by the user, who is free to use coordinates for either plate type.
-        :param export_non_modified_strand_version:
-            For any :any:`Strand` with a :any:`Modification`, also export a version of the :any:`Strand`
-            without any modifications. The name for this :any:`Strand` is the original name with
-            '_nomods' appended to it.
-        """
-
-        strands_to_export = self._idt_strands_to_export(key=key, warn_duplicate_name=warn_duplicate_name,
-                                                        only_strands_with_idt=only_strands_with_idt,
-                                                        export_scaffold=export_scaffold,
-                                                        export_non_modified_strand_version=export_non_modified_strand_version)
-
-        if not use_default_plates:
-            if not only_strands_with_idt:
-                raise ValueError('parameters use_default_plates and only_strands_with_idt '
-                                 'cannot both be False')
-            self._write_plates_assuming_explicit_plates_in_each_strand(directory, filename, strands_to_export)
-        else:
-            self._write_plates_default(directory=directory, filename=filename,
-                                       strands_to_export=strands_to_export,
-                                       plate_type=plate_type,
-                                       warn_using_default_plates=warn_using_default_plates)
-
-    def _write_plates_assuming_explicit_plates_in_each_strand(self, directory: str, filename: Optional[str],
-                                                              strands_to_export: List[Strand]) -> None:
-        plates = list({strand.idt.plate for strand in strands_to_export if strand.idt is not None if
-                       strand.idt.plate is not None})
-        if len(plates) == 0:
-            raise ValueError('Cannot write a a plate file since no plate data exists in any Strands '
-                             'in the design.\n'
-                             'Set the option use_default_plates=True in '
-                             "Design.write_idt_plate_excel_file\nif you don't want to enter plate "
-                             'and well positions for each Strand you wish to write to the Excel file.')
-        plates.sort()
-        filename_plate, workbook = self._setup_excel_file(directory, filename)
-        for plate in plates:
-            worksheet = self._add_new_excel_plate_sheet(plate, workbook)
-
-            strands_in_plate = [strand for strand in strands_to_export if
-                                strand.idt is not None and strand.idt.plate == plate]
-
-            strands_in_plate.sort(key=lambda s: (int(s.idt.well[1:]), s.idt.well[0]))  # type: ignore
-
-            for row, strand in enumerate(strands_in_plate):
-                if strand.idt is None:
-                    raise ValueError(f'cannot export strand {strand} to IDT because it has no idt field')
-                worksheet.write(row + 1, 0, strand.idt.well)
-                worksheet.write(row + 1, 1, strand.idt_export_name())
-                worksheet.write(row + 1, 2, strand.idt_dna_sequence())
-
-            workbook.save(filename_plate)
-
-    @staticmethod
-    def _add_new_excel_plate_sheet(plate_name: str, workbook: Any) -> Any:
-        worksheet = workbook.add_sheet(plate_name)
-        worksheet.write(0, 0, 'Well Position')
-        worksheet.write(0, 1, 'Name')
-        worksheet.write(0, 2, 'Sequence')
-        return worksheet
-
-    @staticmethod
-    def _setup_excel_file(directory: str, filename: Optional[str]) -> Tuple[str, Any]:
-        import xlwt  # type: ignore
-        plate_extension = f'xls'
-        if filename is None:
-            filename_plate = _get_filename_same_name_as_running_python_script(
-                directory, plate_extension, filename)
-        else:
-            filename_plate = _create_directory_and_set_filename(directory, filename)
-        workbook = xlwt.Workbook()
-        return filename_plate, workbook
-
-    def _write_plates_default(self, directory: str, filename: Optional[str], strands_to_export: List[Strand],
-                              plate_type: PlateType = PlateType.wells96,
-                              warn_using_default_plates: bool = True) -> None:
-        plate_coord = _PlateCoordinate(plate_type=plate_type)
-        plate = 1
-        excel_row = 1
-        filename_plate, workbook = self._setup_excel_file(directory, filename)
-        worksheet = self._add_new_excel_plate_sheet(f'plate{plate}', workbook)
-
-        for strand in strands_to_export:
-            if strand.idt is not None:
-                if warn_using_default_plates and strand.idt.plate is not None:
-                    print(
-                        f"WARNING: strand {strand} has plate entry {strand.idt.plate}, "
-                        f"which is being ignored since we are using default plate/well addressing")
-                if warn_using_default_plates and strand.idt.well is not None:
-                    print(
-                        f"WARNING: strand {strand} has well entry {strand.idt.well}, "
-                        f"which is being ignored since we are using default plate/well addressing")
-
-            well = plate_coord.well()
-            worksheet.write(excel_row, 0, well)
-            worksheet.write(excel_row, 1, strand.idt_export_name())
-            worksheet.write(excel_row, 2, strand.idt_dna_sequence())
-            plate_coord.increment()
-            if plate != plate_coord.plate():
-                workbook.save(filename_plate)
-                plate = plate_coord.plate()
-                worksheet = self._add_new_excel_plate_sheet(f'plate{plate}', workbook)
-                excel_row = 1
-            else:
-                excel_row += 1
-
-        workbook.save(filename_plate)
-
-    @_docstring_parameter(default_extension=default_scadnano_file_extension)
-    def write_scadnano_file(self, directory: str = '.', filename: str = None, extension: str = None) -> None:
-        """Write ``.{default_extension}`` file representing this :any:`Design`,
-        suitable for reading by scadnano,
-        with the output file having the same name as the running script but with ``.py`` changed to
-        ``.{default_extension}``,
-        unless `filename` is explicitly specified.
-        For instance, if the script is named ``my_origami.py``,
-        then the design will be written to ``my_origami.{default_extension}``.
-        If `extension` is specified (but `filename` is not), then the design will be written to
-        ``my_origami.<extension>``
-
-        `directory` specifies a directory in which to place the file, either absolute or relative to
-        the current working directory. Default is the current working directory.
-
-        The string written is that returned by :meth:`Design.to_json`.
-
-        :param directory: directory in which to put file (default: current working directory)
-        :param filename: filename (default: name of script with ``.py`` replaced by
-            ``.{default_extension}``).
-            Mutually exclusive with `extension`
-        :param extension: extension for filename (default: ``.{default_extension}``)
-            Mutually exclusive with `filename`
-        """
-        self._check_legal_design()
-        contents = self.to_json()
-        if filename is not None and extension is not None:
-            raise ValueError('at least one of filename or extension must be None')
-        if extension is None:
-            extension = default_scadnano_file_extension
-        _write_file_same_name_as_running_python_script(contents, extension, directory, filename)
-
-    def export_cadnano_v2(self, directory: str = '.', filename: Optional[str] = None) -> None:
-        """Write ``.json`` file representing this :any:`Design`, suitable for reading by cadnano v2,
-        with the output file having the same name as the running script but with ``.py`` changed to ``.json``,
-        unless `filename` is explicitly specified.
-        For instance, if the script is named ``my_origami.py``,
-        then the design will be written to ``my_origami.json``.
-
-        `directory` specifies a directory in which to place the file, either absolute or relative to
-        the current working directory. Default is the current working directory.
-
-        The string written is that returned by :meth:`Design.to_cadnano_v2`.
-        """
-        content_serializable = OrderedDict({})
-        content_serializable['name'] = _get_filename_same_name_as_running_python_script(directory, 'json',
-                                                                                        filename)
-        content_serializable_final = self.to_cadnano_v2()
-        content_serializable.update(content_serializable_final)
-
-        encoder = _SuppressableIndentEncoder
-        contents = json.dumps(content_serializable, cls=encoder, indent=2)
-
-        _write_file_same_name_as_running_python_script(contents, 'json', directory, filename)
-
-    def add_nick(self, helix: int, offset: int, forward: bool, new_color: bool = True) -> None:
-        """Add nick to :any:`Domain` on :any:`Helix` with index `helix`,
-        in direction given by `forward`, at offset `offset`. The two :any:`Domain`'s created by this nick
-        will have 5'/3' ends at offsets `offset` and `offset-1`.
-
-        For example, if there is a :any:`Domain` with
-        :py:data:`Domain.helix` = ``0``,
-        :py:data:`Domain.forward` = ``True``,
-        :py:data:`Domain.start` = ``0``,
-        :py:data:`Domain.end` = ``10``,
-        then calling ``add_nick(helix=0, offset=5, forward=True)`` will split it into two :any:`Domain`'s,
-        with one domains having the fields
-        :py:data:`Domain.helix` = ``0``,
-        :py:data:`Domain.forward` = ``True``,
-        :py:data:`Domain.start` = ``0``,
-        :py:data:`Domain.end` = ``5``,
-        (recall that :py:data:`Domain.end` is exclusive, meaning that the largest offset on this
-        :any:`Domain` is 4 = ``offset-1``)
-        and the other domain having the fields
-        :py:data:`Domain.helix` = ``0``,
-        :py:data:`Domain.forward` = ``True``,
-        :py:data:`Domain.start` = ``5``,
-        :py:data:`Domain.end` = ``10``.
-
-        If the :any:`Strand` is circular, then it will be made linear with the 5' and 3' ends at the
-        nick position, modified in place. Otherwise, this :any:`Strand` will be deleted from the design,
-        and two new :any:`Strand`'s will be added.
-
-        :param helix: index of helix where nick will occur
-        :param offset: offset to nick (nick will be between offset and offset-1)
-        :param forward: forward or reverse :any:`Domain` on `helix` at `offset`?
-        :param new_color: whether to assign a new color to one of the :any:`Strand`'s resulting from the
-                          nick.
-                          If False, both :any:`Strand`'s created have the same color as the original
-                          If True, one :any:`Strand` keeps the same color as the original and the other
-                          is assigned a new color
-        """
-        for domain_to_remove in self.domains_at(helix, offset):
-            if domain_to_remove.forward == forward:
-                break
-        else:
-            raise IllegalDesignError(f'no domain at helix {helix} in direction '
-                                     f'{"forward" if forward else "reverse"} at offset {offset}')
-        strand = domain_to_remove.strand()
-        domains = strand.domains
-        order = domains.index(domain_to_remove)
-        domains_before = domains[:order]
-        domains_after = domains[order + 1:]
-        domain_left: Domain[DomainLabel] = Domain(helix, forward, domain_to_remove.start, offset)
-        domain_right: Domain[DomainLabel] = Domain(helix, forward, offset, domain_to_remove.end)
-
-        # "before" and "after" mean in the 5' --> 3' direction, i.e., if a reverse domain:
-        # <--------]
-        # nicked like this:
-        # <---]<---]
-        # The before domain is on the right and the after domain is on the left.
-        #
-        # If nicking a forward domain:
-        # [-------->
-        # nicked like this:
-        # [--->[--->
-        # The before domain is on the left and the after domain is on the right.
-        if domain_to_remove.forward:
-            domain_to_add_before = domain_left
-            domain_to_add_after = domain_right
-        else:
-            domain_to_add_before = domain_right
-            domain_to_add_after = domain_left
-
-        seq_before_whole: Optional[str]
-        seq_after_whole: Optional[str]
-        if strand.dna_sequence is not None:
-            seq_before: str = ''.join(domain.dna_sequence() for domain in domains_before)  # type: ignore
-            seq_after: str = ''.join(domain.dna_sequence() for domain in domains_after)  # type: ignore
-            seq_on_domain_left: str = domain_to_remove.dna_sequence_in(  # type: ignore
-                domain_to_remove.start,
-                offset - 1)
-            seq_on_domain_right: str = domain_to_remove.dna_sequence_in(offset,  # type: ignore
-                                                                        domain_to_remove.end - 1)
-            if domain_to_remove.forward:
-                seq_on_domain_before = seq_on_domain_left
-                seq_on_domain_after = seq_on_domain_right
-            else:
-                seq_on_domain_before = seq_on_domain_right
-                seq_on_domain_after = seq_on_domain_left
-            seq_before_whole = seq_before + seq_on_domain_before
-            seq_after_whole = seq_on_domain_after + seq_after
-        else:
-            seq_before_whole = None
-            seq_after_whole = None
-
-        domains_before = domains_before + cast(List[Union[Domain, Loopout]], [domain_to_add_before])  # noqa
-        domains_after = cast(List[Union[Domain, Loopout]], [domain_to_add_after]) + domains_after  # noqa
-
-        if strand.circular:
-            # if strand is circular, we modify its domains in place
-            domains = domains_after + domains_before
-            strand.set_domains(domains)
-
-            # DNA sequence was rotated, so re-assign it
-            if seq_before_whole is not None and seq_after_whole is not None:
-                seq = seq_before_whole + seq_after_whole
-                strand.set_dna_sequence(seq)
-
-            strand.set_linear()
-
-        else:
-            # if strand is not circular, we delete it and create two new strands
-            self.strands.remove(strand)
-
-            idt_present = strand.idt is not None
-            strand_before: Strand[StrandLabel, DomainLabel] = Strand(
-                domains=domains_before,
-                dna_sequence=seq_before_whole,
-                color=strand.color,
-                idt=strand.idt if idt_present else None,
-            )
-
-            color_after = next(self.color_cycler) if new_color else strand.color
-            strand_after: Strand[StrandLabel, DomainLabel] = Strand(
-                domains=domains_after,
-                dna_sequence=seq_after_whole,
-                color=color_after,
-            )
-
-            self.strands.extend([strand_before, strand_after])
-
-        helix_domains = self.helices[helix].domains
-        idx_domain_to_remove = helix_domains.index(domain_to_remove)
-        helix_domains[idx_domain_to_remove] = domain_left
-        helix_domains.insert(idx_domain_to_remove + 1, domain_right)
-
-    def ligate(self, helix: int, offset: int, forward: bool) -> None:
-        """
-        Reverse operation of :py:meth:`Design.add_nick`.
-        "Ligates" a nick between two adjacent :any:`Domain`'s in the same direction on a :any:`Helix`
-        with index `helix`,
-        in direction given by `forward`, at offset `offset`.
-
-        For example, if there are a :any:`Domain`'s with
-        :py:data:`Domain.helix` = ``0``,
-        :py:data:`Domain.forward` = ``True``,
-        :py:data:`Domain.start` = ``0``,
-        :py:data:`Domain.end` = ``5``,
-        (recall that :py:data:`Domain.end` is exclusive, meaning that the largest offset on this
-        :any:`Domain` is 4 = ``offset-1``)
-        and the other domain having the fields
-        :py:data:`Domain.helix` = ``0``,
-        :py:data:`Domain.forward` = ``True``,
-        :py:data:`Domain.start` = ``5``,
-        :py:data:`Domain.end` = ``10``.
-        then calling ``ligate(helix=0, offset=5, forward=True)`` will combine them into one :any:`Domain`,
-        having the fields
-        :py:data:`Domain.helix` = ``0``,
-        :py:data:`Domain.forward` = ``True``,
-        :py:data:`Domain.start` = ``0``,
-        :py:data:`Domain.end` = ``10``.
-
-        If the :any:`Domain`'s are on the same :any:`Strand` (i.e., they are the 5' and 3' ends of that
-        :any:`Strand`, which is necessarily linear), then the :any:`Strand` is made is circular in place,
-        Otherwise, the two :any:`Strand`'s of each :any:`Domain` will be joined into one,
-        replacing the previous strand on the 5'-most side of the nick (i.e., the one whose 3' end
-        terminated at the nick), and deleting the other strand.
-
-        :param helix: index of helix where nick will be ligated
-        :param offset: offset to ligate (nick to ligate must be between offset and offset-1)
-        :param forward: forward or reverse :any:`Domain` on `helix` at `offset`?
-        """
-        for dom_right in self.domains_at(helix, offset):
-            if dom_right.forward == forward:
-                break
-        else:
-            raise IllegalDesignError(f'no domain at helix {helix} in direction '
-                                     f'{"forward" if forward else "reverse"} at offset {offset}')
-        for dom_left in self.domains_at(helix, offset - 1):
-            if dom_left.forward == forward:
-                break
-        else:
-            raise IllegalDesignError(f'no domain at helix {helix} in direction '
-                                     f'{"forward" if forward else "reverse"} at offset {offset}')
-        if dom_right.start != offset:
-            raise IllegalDesignError(f'to ligate at offset {offset}, it must be the start offset of a domain,'
-                                     f'but there is no domain at helix {helix} in direction '
-                                     f'{"forward" if forward else "reverse"} with start offset = {offset}')
-        if dom_left.end != offset:
-            raise IllegalDesignError(f'to ligate at offset {offset}, it must be the end offset of a domain,'
-                                     f'but there is no domain at helix {helix} in direction '
-                                     f'{"forward" if forward else "reverse"} with end offset = {offset}')
-
-        strand_left = dom_left.strand()
-        strand_right = dom_right.strand()
-
-        dom_new: Domain[DomainLabel] = Domain(helix=helix, forward=forward, start=dom_left.start,
-                                              end=dom_right.end,
-                                              deletions=dom_left.deletions + dom_right.deletions,
-                                              insertions=dom_left.insertions + dom_right.insertions,
-                                              name=dom_left.name, label=dom_left.label)
-
-        # normalize 5'/3' distinction; below refers to which Strand has the 5'/3' end that will be ligated
-        # So strand_5p is the one whose 3' end will be the 3' end of the whole new Strand
-        # strand_5p and dom_5p are the ones on the 5' side of the nick,
-        # CAUTION: they are on the 3' side of the nick,
-        # i.e., the 3' end of strand_5p will be the 3' end of the new strand
-        # e.g.,
-        #
-        #  strand_3p  strand_5p
-        # [--------->[--------->
-        #    dom_3p     dom_5p
-        #
-        # or
-        #
-        #  strand_5p  strand_3p
-        # <---------]<---------]
-        #    dom_5p     dom_3p
-        if not forward:
-            dom_5p = dom_left
-            dom_3p = dom_right
-            strand_5p = strand_left
-            strand_3p = strand_right
-        else:
-            dom_5p = dom_right
-            dom_3p = dom_left
-            strand_5p = strand_right
-            strand_3p = strand_left
-
-        if strand_left is strand_right:
-            # join domains and make strand circular
-            strand = strand_left
-            assert strand.first_bound_domain() is dom_5p
-            assert strand.last_bound_domain() is dom_3p
-            strand.domains[0] = dom_new  # set first domain equal to new joined domain
-            strand.domains.pop()  # remove last domain
-            strand.set_circular()
-            for domain in strand.domains:
-                domain._parent_strand = strand
-
-        else:
-            # join strands
-            strand_3p.domains.pop()
-            strand_3p.domains.append(dom_new)
-            strand_3p.domains.extend(strand_5p.domains[1:])
-            strand_3p.is_scaffold = strand_left.is_scaffold or strand_right.is_scaffold
-            strand_3p.set_modification_3p(strand_5p.modification_3p)
-            for idx, mod in strand_5p.modifications_int.items():
-                new_idx = idx + strand_3p.dna_length()
-                strand_3p.set_modification_internal(new_idx, mod)
-            if strand_3p.dna_sequence is not None and strand_5p.dna_sequence is not None:
-                strand_3p.dna_sequence += strand_5p.dna_sequence
-            if strand_5p.is_scaffold and not strand_3p.is_scaffold and strand_5p.color is not None:
-                strand_3p.set_color(strand_5p.color)
-            self.strands.remove(strand_5p)
-            for domain in strand_3p.domains:
-                domain._parent_strand = strand_3p
-
-        helix_domains = self.helices[helix].domains
-        idx_domain_to_remove_left = helix_domains.index(dom_left)
-        helix_domains[idx_domain_to_remove_left] = dom_new
-        helix_domains.remove(dom_right)
-
-    def add_half_crossover(self, helix: int, helix2: int, offset: int, forward: bool,
-                           offset2: int = None, forward2: bool = None) -> None:
-        """
-        Add a half crossover from helix `helix` at offset `offset` to `helix2`, on the strand
-        with :py:data:`Strand.forward` = `forward`.
-
-        Unlike :py:meth:`Design.add_full_crossover`, which automatically adds a nick between the two
-        half-crossovers, to call this method, there must *already* be nicks adjacent to the given
-        offsets on the given helices. (either on the left or right side)
-
-        If the crossover is within a :any:`Strand`, i.e., between its 5' and ' ends, the :any:`Strand`
-        will simply be made circular, modifying it in place. Otherwise, the old two :any:`Strand`'s will be
-        deleted, and a new :any:`Strand` added.
-
-        :param helix: index of one helix of half crossover
-        :param helix2: index of other helix of half crossover
-        :param offset: offset on `helix` at which to add half crossover
-        :param forward: direction of :any:`Strand` on `helix` to which to add half crossover
-        :param offset2: offset on `helix2` at which to add half crossover.
-            If not specified, defaults to `offset`
-        :param forward2: direction of :any:`Strand` on `helix2` to which to add half crossover.
-            If not specified, defaults to the negation of `forward`
-        """
-        if offset2 is None:
-            offset2 = offset
-        if forward2 is None:
-            forward2 = not forward
-        domain1 = self.domain_at(helix, offset, forward)
-        domain2 = self.domain_at(helix2, offset2, forward2)
-        if domain1 is None:
-            raise IllegalDesignError(
-                f"Cannot add half crossover at (helix={helix}, offset={offset}). "
-                f"There is no Domain there.")
-        if domain2 is None:
-            raise IllegalDesignError(
-                f"Cannot add half crossover at (helix={helix2}, offset={offset2}). "
-                f"There is no Domain there.")
-        strand1 = domain1.strand()
-        strand2 = domain2.strand()
-
-        if strand1 == strand2:
-            strand1.set_circular()
-            return
-            # raise IllegalDesignError(f"Cannot add crossover from "
-            #                          f"(helix={helix}, offset={offset}) to "
-            #                          f"(helix={helix2}, offset={offset2}) "
-            #                          f"because that would join two Domains "
-            #                          f"already on the same Strand! "
-            #                          f"Currently circular Strands are not supported. "
-            #                          f"Instead, try adding nicks first, or rearrange the order of "
-            #                          f"crossover addition, to ensure that all strands are "
-            #                          f"non-circular, even in intermediate stages.")
-
-        if domain1.offset_3p() == offset and domain2.offset_5p() == offset2:
-            strand_first = strand1
-            strand_last = strand2
-        elif domain1.offset_5p() == offset and domain2.offset_3p() == offset2:
-            strand_first = strand2
-            strand_last = strand1
-        else:
-            raise IllegalDesignError("Cannot add half crossover. Must have one domain have its "
-                                     "5' end at the given offset and the other with its 3' end at the "
-                                     "given offset, but this is not the case.")
-
-        new_domains = strand_first.domains + strand_last.domains
-        if strand_first.dna_sequence is None and strand_last.dna_sequence is None:
-            new_dna = None
-        elif strand_first.dna_sequence is not None and strand_last.dna_sequence is not None:
-            new_dna = strand_first.dna_sequence + strand_last.dna_sequence
-        else:
-            raise IllegalDesignError(
-                'cannot add crossover between two strands if one has a DNA sequence '
-                'and the other does not')
-        new_strand: Strand[StrandLabel, DomainLabel] = Strand(domains=new_domains, color=strand_first.color,
-                                                              dna_sequence=new_dna, idt=strand_first.idt,
-                                                              is_scaffold=strand1.is_scaffold or strand2.is_scaffold)
-
-        # put new strand in place where strand_first was
-        strand_first_idx = self.strands.index(strand_first)
-        self.strands[strand_first_idx] = new_strand
-        self.strands.remove(strand_last)
-
-    def add_full_crossover(self, helix: int, helix2: int, offset: int, forward: bool,
-                           offset2: int = None, forward2: bool = None) -> None:
-        """
-        Adds two half-crossovers, one at `offset` and another at `offset`-1.
-        Other arguments have the same meaning as in :py:meth:`Design.add_half_crossover`.
-        A nick is automatically added on helix `helix` between
-        `offset` and `offset`-1 if one is not already present,
-        and similarly for `offset2` on helix `helix2`.
-
-        :param helix: index of one helix of half crossover
-        :param helix2: index of other helix of half crossover
-        :param offset: offset on `helix` at which to add half crossover
-        :param forward: direction of :any:`Strand` on `helix` to which to add half crossover
-        :param offset2: offset on `helix2` at which to add half crossover.
-            If not specified, defaults to `offset`
-        :param forward2: direction of :any:`Strand` on `helix2` to which to add half crossover.
-            If not specified, defaults to the negation of `forward`
-        """
-        if offset2 is None:
-            offset2 = offset
-        if forward2 is None:
-            forward2 = not forward
-        for helix_, forward_, offset_ in [(helix, forward, offset), (helix2, forward2, offset2)]:
-            self._prepare_nicks_for_full_crossover(helix_, forward_, offset_)
-        self.add_half_crossover(helix=helix, helix2=helix2, offset=offset - 1, offset2=offset2 - 1,
-                                forward=forward, forward2=forward2)
-        self.add_half_crossover(helix=helix, helix2=helix2, offset=offset, offset2=offset2,
-                                forward=forward, forward2=forward2)
-
-    def _prepare_nicks_for_full_crossover(self, helix: int, forward: bool, offset: int) -> None:
-        domain_right = self.domain_at(helix, offset, forward)
-        if domain_right is None:
-            raise IllegalDesignError(f'You tried to create a full crossover at '
-                                     f'(helix={helix}, offset={offset}) '
-                                     f'but there is no Strand there.')
-        domain_left = self.domain_at(helix, offset - 1, forward)
-        if domain_left is None:
-            raise IllegalDesignError(f'You tried to create a full crossover at '
-                                     f'(helix={helix}, offset={offset}) '
-                                     f'but there is no Strand at offset {offset - 1}.')
-        if domain_left == domain_right:
-            self.add_nick(helix, offset, forward)
-        else:
-            assert domain_left.end == domain_right.start
-
-    def inline_deletions_insertions(self) -> None:
-        """
-        Converts deletions and insertions by "inlining" them. Insertions and deletions are removed,
-        and their domains have their lengths altered. Also, major tick marks on the helices will be
-        shifted to preserve their adjacency to bases already present. For example, if there are major
-        tick marks at 0, 8, 18, 24, and a deletion between 0 and 8:
-
-        .. code-block:: none
-
-            0      8         18    24    30
-            |--X---|---------|-----|------
-
-        then the domain is shortened by 1,
-        the tick marks become 0, 7, 15, 23,
-        and the helix's maximum offset is shrunk by 1:
-
-        .. code-block:: none
-
-            0     7         17    23    29
-            |-----|---------|-----|------
-
-        Similarly, if there are insertions (in the example below, the "2" represents an insertion of length 2,
-        which represents 3 total bases), they are expanded
-
-        .. code-block:: none
-
-            0      8         18    24    30
-            |--2---|---------|-----|------
-
-        then the domain is lengthened by 3:
-
-        .. code-block:: none
-
-            0        10        20    26    32
-            |--------|---------|-----|------
-
-        And it works if there are both insertions and deletions:
-
-        .. code-block:: none
-
-            0      8         18    24    30
-            |--2---|---------|--X--|------
-
-        then the domain is lengthened by 3:
-
-        .. code-block:: none
-
-            0        10        20   25    31
-            |--------|---------|----|------
-
-        We assume that a major tick mark appears just to the LEFT of the offset it encodes,
-        so the minimum and maximum offsets for tick marks are respectively the helix's minimum offset
-        and 1 plus its maximum offset, the latter being just to the right of the last offset on the helix.
-        """
-        for helix in self.helices.values():
-            self._inline_deletions_insertions_on_helix(helix)
-
-    def _inline_deletions_insertions_on_helix(self, helix: Helix) -> None:
-        ###################################################
-        # first gather information before changing anything
-
-        # gather all mods on helix
-        deletions = [deletion for domain in helix.domains for deletion in domain.deletions]
-        insertions = [insertion for domain in helix.domains for insertion in domain.insertions]
-
-        # change max offset
-        delta_length = sum(length for (offset, length) in insertions) - len(deletions)
-
-        # combined collection of deletions/insertions into one dict mapping offset --> None/len, where
-        # value of -1 indicates deletion, and otherwise is length of insertion
-        dels_ins = dict()
-        for deletion in deletions:
-            dels_ins[deletion] = -1
-        for insertion in insertions:
-            dels_ins[insertion[0]] = insertion[1]
-
-        # put offsets in sorted order
-        dels_ins_offsets_sorted = sorted(dels_ins.keys())
-
-        # fix helix major ticks
-        group = self.groups[helix.group]
-        major_ticks = sorted(helix.calculate_major_ticks(group.grid))
-
-        ###################################################
-        # now that info is gathered, start changing things
-
-        if helix.max_offset is not None:
-            helix.max_offset += delta_length
-
-        if len(major_ticks) > 0:
-            major_tick_idx = 0
-            delta_acc = 0  # accumulated delta; insertions add to this and deletions subtract from it
-            for offset in dels_ins_offsets_sorted:
-                # go to first major tick great than offset, updating passed ones by delta_acc
-                while major_tick_idx < len(major_ticks) and major_ticks[major_tick_idx] <= offset:
-                    major_ticks[major_tick_idx] += delta_acc
-                    major_tick_idx += 1
-                delta_acc += dels_ins[offset]
-            # if necessary, update major ticks beyond last ins/del
-            while major_tick_idx < len(major_ticks):
-                major_ticks[major_tick_idx] += delta_acc
-                major_tick_idx += 1
-            # TODO: check if regularly spaced and reaching both ends, and if so set helix.major_tick_distance
-            helix.major_ticks = major_ticks
-
-        # fix domain start/end offsets
-        domains = sorted(helix.domains, key=lambda domain: domain.start)
-        delta_acc = 0
-        for domain in domains:
-            domain.start += delta_acc
-            delta_acc += domain.dna_length() - domain.visual_length()
-            domain.end += delta_acc
-            domain.deletions = []
-            domain.insertions = []
-
-    def reverse_all(self) -> None:
-        """
-        Reverses "polarity" of every :any:`Strand` in this :any:`Design`.
-
-        No attempt is made to make any assigned DNA sequences match by reversing or rearranging them.
-        Every :any:`Strand` keeps the same DNA sequence it had before (unreversed), if one was assigned.
-        It is recommended to assign/reassign DNA sequences *after* doing this operation.
-        """
-        for strand in self.strands:
-            strand.reverse()
-
-    def set_major_tick_distance(self, major_tick_distance: int) -> None:
-        for helix in self.helices.values():
-            helix.major_tick_distance = major_tick_distance
-
-    def _ensure_helices_distinct_objects(self) -> None:
-        pair = _find_index_pair_same_object(self.helices)
-        if pair is not None:
-            i, j = pair
-            raise IllegalDesignError('helices must all be distinct objects, but those at indices '
-                                     f'{i} and {j} are the same object')
-
-    def _ensure_strands_distinct_objects(self) -> None:
-        pair = _find_index_pair_same_object(self.strands)
-        if pair is not None:
-            i, j = pair
-            raise IllegalDesignError('strands must all be distinct objects, but those at indices '
-                                     f'{i} and {j} are the same object')
-
-    def _ensure_helix_groups_exist(self) -> None:
-        for helix in self.helices.values():
-            if helix.group not in self.groups.keys():
-                raise IllegalDesignError(f'helix {helix.idx} has group {helix.group}, which does not '
-                                         f'exist in the design. The valid groups are '
-                                         f'{", ".join(self.groups.keys())}')
-
-    def _has_default_groups(self) -> bool:
-        return len(self.groups) == 1 and default_group_name in self.groups
-
-    def _assign_default_helices_view_orders_to_groups(self) -> None:
-        for name, group in self.groups.items():
-            if group.helices_view_order is None:
-                helices_in_group = {idx: helix for idx, helix in self.helices.items() if helix.group == name}
-                group._assign_default_helices_view_order(helices_in_group)  # noqa
-
-    def _warn_if_strand_names_not_unique(self) -> None:
-        names = [strand.name for strand in self.strands if strand.name is not None]
-        if len(names) > len(set(names)):
-            for name1, name2 in itertools.combinations(names, 2):
-                if name1 == name2:
-                    print(f'WARNING: there are two strands with name {name1}')
-
-    def strand_with_name(self, name: str) -> Optional[Strand]:
-        """
-        :param name: name of a :any:`Strand`.
-        :return: the :any:`Strand` with name `name`, or None if no :any:`Strand` in the :any:`Design` has
-                 that name.
-        """
-        for strand in self.strands:
-            if strand.name == name:
-                return strand
-        return None
-
-
-def _find_index_pair_same_object(elts: Union[List, Dict]) -> Optional[Tuple]:
-    # return pair of indices representing same object in elts, or None if they do not exist
-    # input can be list or dict; if dict, returns pair of keys mapping to same object
-    if isinstance(elts, list):
-        elts = dict(enumerate(elts))
-    for i, j in itertools.combinations(elts.keys(), 2):
-        if elts[i] is elts[j]:
-            return i, j
-    return None
-
-
-def _name_of_this_script() -> str:
-    """Return name of the currently running script, WITHOUT the .py extension."""
-    return os.path.basename(sys.argv[0])[:-3]
-
-
-def _write_file_same_name_as_running_python_script(contents: str, extension: str, directory: str = '.',
-                                                   filename: Optional[str] = None) -> None:
-    relative_filename = _get_filename_same_name_as_running_python_script(directory, extension, filename)
-    with open(relative_filename, 'w') as out_file:
-        out_file.write(contents)
-
-
-def _get_filename_same_name_as_running_python_script(directory: str, extension: str,
-                                                     filename: Optional[str]) -> str:
-    if filename is None:
-        filename = _name_of_this_script() + f'.{extension}'
-    relative_filename = _create_directory_and_set_filename(directory, filename)
-    return relative_filename
-
-
-def _create_directory_and_set_filename(directory: str, filename: str) -> str:
-    if not os.path.exists(directory):
-        os.makedirs(directory)
-    relative_filename = os.path.join(directory, filename)
-    return relative_filename
+"""
+The :mod:`scadnano` Python module is a library for describing synthetic DNA nanostructures
+(e.g., DNA origami).
+Installation instructions are at the
+`GitHub repository <https://github.com/UC-Davis-molecular-computing/scadnano-python-package#installation>`_.
+
+The scadnano project is developed and maintained by the UC Davis Molecular Computing group.
+Note that `cadnano <https://cadnano.org>`_ is a separate project,
+developed and maintained by the `Douglas lab <https://bionano.ucsf.edu/>`_ at UCSF.
+
+This module is used to write Python scripts creating files readable
+by `scadnano <https://scadnano.org/>`_, a web application useful for displaying
+and manually editing synthetic DNA nanostructures.
+The purpose of this module is to help automate some of the task of creating DNA designs,
+as well as making large-scale changes to them that are easier to describe programmatically than
+to do by hand in scadnano.
+
+If you find scadnano useful in a scientific project, please cite its associated paper:
+
+ | scadnano: A browser-based, scriptable tool for designing DNA nanostructures.
+ | David Doty, Benjamin L Lee, and Tristan Stérin.
+ | DNA 2020: *Proceedings of the 26th International Conference on DNA Computing and Molecular Programming*
+ | [ `paper <https://doi.org/10.4230/LIPIcs.DNA.2020.9>`_ | `BibTeX <https://web.cs.ucdavis.edu/~doty/papers/scadnano.bib>`_ ]
+
+This document describes the API for the scadnano Python package,
+see the `repository <https://github.com/UC-Davis-molecular-computing/scadnano-python-package>`_
+for additional documentation, such as installation instructions.
+There is separate documentation for the
+`scadnano web interface <https://github.com/UC-Davis-molecular-computing/scadnano>`_.
+
+This library uses typing hints from the Python typing library.
+(https://docs.python.org/3/library/typing.html)
+Each function and method indicate intended types of the parameters.
+However, due to Python's design, these types are not enforced at runtime.
+It is suggested to use a static analysis tool such as that provided by an IDE such as PyCharm
+(https://www.jetbrains.com/pycharm/)
+to see warnings when the typing rules are violated. 
+Such warnings probably indicate an erroneous usage.
+
+Most of the classes in this module are Python dataclasses
+(https://docs.python.org/3/library/dataclasses.html)
+whose fields show up in the documentation.
+Their types are listed in parentheses after the name of the class;
+for example :any:`Color` has ``int`` fields :py:data:`Color.r`, :py:data:`Color.g`, :py:data:`Color.b`.
+In general it is safe to read these fields directly, but not to write to them directly.
+Setter methods (named ``set_<fieldname>``) are provided for fields where it makes sense to set it to another
+value than it had originally.
+However, due to Python naming conventions for dataclass fields and property setters,
+it is not straightforward to enforce that the fields cannot be written, 
+so the user must take care not to set them.
+"""
+
+# needed to use forward annotations: https://docs.python.org/3/whatsnew/3.7.html#whatsnew37-pep563
+# commented out for now to support Py3.6, which does not support this feature
+# from __future__ import annotations
+
+__version__ = "0.16.0"  # version line; WARNING: do not remove or change this line or comment
+
+import dataclasses
+from abc import abstractmethod, ABC, ABCMeta
+import json
+import enum
+import itertools
+import re
+from builtins import ValueError
+from dataclasses import dataclass, field, InitVar, replace
+from typing import Tuple, List, Sequence, Iterable, Set, Dict, Union, Optional, FrozenSet, Type, cast, Any, \
+    TypeVar, Generic, Callable
+from collections import defaultdict, OrderedDict, Counter
+import sys
+import os.path
+
+default_scadnano_file_extension = 'sc'
+"""Default filename extension when writing a scadnano file."""
+
+VStrands = Dict[int, Dict[str, Any]]
+
+
+def _pairwise(iterable: Iterable) -> Iterable:
+    """s -> (s0,s1), (s1,s2), (s2, s3), ..."""
+    a, b = itertools.tee(iterable)
+    next(b, None)
+    return zip(a, b)
+
+
+# for putting evaluated expressions in docstrings
+# https://stackoverflow.com/questions/10307696/how-to-put-a-variable-into-python-docstring
+# not currently used since this way of implementing it is has a bug with using the .. codeblock:: directive
+def _docstring_parameter(*sub: Any, **kwargs: Any) -> Any:
+    def dec(obj: Any) -> Any:
+        obj.__doc__ = obj.__doc__.format(*sub, **kwargs)
+        return obj
+
+    return dec
+
+
+##############################################################################
+# JSON serialization
+# There are external libraries to handle JSON
+# in Python, but I want this to be a simple, single-file library, so we just
+# implement what we need below.
+
+
+class _JSONSerializable(ABC):
+
+    @abstractmethod
+    def to_json_serializable(self, suppress_indent: bool = True, **kwargs: Any) -> Any:
+        raise NotImplementedError()
+
+
+def _json_encode(obj: _JSONSerializable, suppress_indent: bool = True) -> str:
+    encoder = _SuppressableIndentEncoder if suppress_indent else json.JSONEncoder
+    serializable = obj.to_json_serializable(suppress_indent=suppress_indent)
+    return json.dumps(serializable, cls=encoder, indent=2)
+
+
+class NoIndent:
+    # Value wrapper. Placing a value in this will stop it from being indented when converting to JSON
+    # using _SuppressableIndentEncoder
+
+    def __init__(self, value: Any) -> None:
+        self.value = value
+
+
+class _SuppressableIndentEncoder(json.JSONEncoder):
+    def __init__(self, *args: Any, **kwargs: Any) -> None:
+        self.unique_id = 0
+        super(_SuppressableIndentEncoder, self).__init__(*args, **kwargs)
+        self.kwargs = dict(kwargs)
+        del self.kwargs['indent']
+        self._replacement_map: Dict[int, str] = {}
+
+    def default(self, obj: Any) -> Any:
+        if isinstance(obj, NoIndent):
+            # key = uuid.uuid1().hex # this caused problems with Brython.
+            key = self.unique_id
+            self.unique_id += 1
+            self._replacement_map[key] = json.dumps(obj.value, **self.kwargs)
+            return f"@@{key}@@"
+        else:
+            return super().default(obj)
+
+    def encode(self, obj: Any) -> Any:
+        result = super().encode(obj)
+        return re.sub(r'"@@(\d+)@@"', lambda m: self._replacement_map[int(m[1])], result)
+
+
+#
+# END JSON serialization
+##############################################################################
+
+
+##############################################################################
+# Colors
+# As with JSON serialization, there are external libraries to handle colors
+# in Python, but I want this to be a simple, single-file library, so we just
+# implement what we need below.
+
+@dataclass
+class Color(_JSONSerializable):
+    r: Optional[int] = None
+    """
+    Red component: 0-255.
+    
+    Optional if :py:data:`Color.hex` is given."""
+
+    g: Optional[int] = None
+    """Green component: 0-255.
+    
+    Optional if :py:data:`Color.hex` is given."""
+
+    b: Optional[int] = None
+    """Blue component: 0-255.
+    
+    Optional if :py:data:`Color.hex` is given."""
+
+    hex_string: InitVar[str] = None
+    """Hex color preceded by # sign, e.g., "#ff0000" is red.
+    
+    Optional if :py:data:`Color.r`, :py:data:`Color.g`, :py:data:`Color.b` are all given."""
+
+    def __post_init__(self, hex_string: str) -> None:
+        if hex_string is None:
+            assert (self.r is not None and self.g is not None and self.b is not None)
+        else:
+            assert (self.r is None and self.g is None and self.b is None)
+            hex_string = hex_string.lstrip('#')
+            self.r = int(hex_string[0:2], 16)
+            self.g = int(hex_string[2:4], 16)
+            self.b = int(hex_string[4:6], 16)
+
+    def to_json_serializable(self, suppress_indent: bool = True, **kwargs: Any) -> str:
+        # Return object representing this Color that is JSON serializable.
+        # return NoIndent(self.__dict__) if suppress_indent else self.__dict__
+        return f'#{self.r:02x}{self.g:02x}{self.b:02x}'
+
+    def to_cadnano_v2_int_hex(self) -> int:
+        return int(f'{self.r:02x}{self.g:02x}{self.b:02x}', 16)
+
+    @classmethod
+    def from_cadnano_v2_int_hex(cls, hex_int: int) -> 'Color':
+        hex_str = "0x{:06x}".format(hex_int)
+        return Color(hex_string=hex_str[2:])
+
+
+# https://medium.com/@rjurney/kellys-22-colours-of-maximum-contrast-58edb70c90d1
+_kelly_colors = [  # 'F2F3F4', #almost white so it's no good
+    '222222', 'F3C300', '875692', 'F38400', 'A1CAF1', 'BE0032', 'C2B280', '848482',
+    '008856', 'E68FAC', '0067A5', 'F99379', '604E97', 'F6A600', 'B3446C', 'DCD300', '882D17',
+    '8DB600', '654522', 'E25822', '2B3D26']
+
+
+class ColorCycler:
+    """
+    Calling ``next(color_cycler)`` on a ColorCycler named ``color_cycler``
+    returns a the next :any:`Color` from a fixed size list,
+    cycling after reaching the end of the list.
+
+    To choose new colors, set ``color_cycler.colors`` to a new list of :any:`Color`'s.
+    """
+
+    # These are copied from cadnano:
+    # https://github.com/sdouglas/cadnano2/blob/master/views/styles.py#L97
+    _colors: List[Color] = [Color(50, 184, 108),
+                            Color(204, 0, 0),
+                            Color(247, 67, 8),
+                            Color(247, 147, 30),
+                            Color(170, 170, 0),
+                            Color(87, 187, 0),
+                            Color(0, 114, 0),
+                            Color(3, 182, 162),
+                            # Color(23, 0, 222), # don't like this because it looks too much like scaffold
+                            Color(50, 0, 150),  # this one is better contrast with scaffold
+                            Color(184, 5, 108),
+                            Color(51, 51, 51),
+                            Color(115, 0, 222),
+                            Color(136, 136, 136)]
+    """List of colors to cycle through."""
+
+    # _colors = [Color(hex_string=kelly_color) for kelly_color in _kelly_colors]
+    # """List of colors to cycle through."""
+
+    def __init__(self) -> None:
+        self._current_color_idx = 0
+        # random order
+        order = [3, 11, 0, 12, 8, 1, 10, 6, 5, 9, 4, 7, 2]
+        # order = range(len(self._colors))
+        colors_shuffled: List[Color] = list(self._colors)
+        for i, color in zip(order, self._colors):
+            colors_shuffled[i] = color
+        self._colors: List[Color] = colors_shuffled
+
+    def __iter__(self) -> 'ColorCycler':
+        # need to make ColorCycler an iterator
+        return self
+
+    def __next__(self) -> Color:
+        color = self.current_color()
+        self._current_color_idx = (self._current_color_idx + 1) % len(self._colors)
+        return color
+
+    def current_color(self) -> Color:
+        return self._colors[self._current_color_idx]
+
+    def __hash__(self) -> int:
+        return hash(self.current_color())
+
+    def __eq__(self, other: Any) -> bool:
+        if not isinstance(other, ColorCycler):
+            return False
+        return self._current_color_idx == other._current_color_idx
+
+    def __str__(self) -> str:
+        return repr(self)
+
+    def __repr__(self) -> str:
+        return f'ColorCycler({self.current_color()})'
+
+    @property
+    def colors(self) -> List[Color]:
+        """The colors that are cycled through when calling ``next()`` on some :any:`ColorCycler`."""
+        return list(self._colors)
+
+    @colors.setter
+    def colors(self, newcolors: Iterable[Color]) -> None:
+        self._colors = list(newcolors)
+        self._current_color_idx = 0
+
+
+default_scaffold_color = Color(0, 102, 204)
+"""Default color for scaffold strand(s)."""
+
+default_strand_color = Color(0, 0, 0)
+"""Default color for non-scaffold strand(s)."""
+
+default_cadnano_strand_color = Color(hex_string='#BFBFBF')
+
+
+#
+# END Colors
+##############################################################################
+
+
+@enum.unique
+class Grid(str, enum.Enum):
+    """
+    Represents default patterns for laying out helices in the side view.
+    Each :any:`Grid` except :py:data:`Grid.none` has an interpretation of a "grid position",
+    which is a 2D integer coordinate (`h`, `v`).
+    (scadnano also allows a 3rd coordinate (`h`, `v`, `b`) specifying a "base offset" at which to position
+    the start of the :any:`Helix`, which is not relevant for the side view but will eventually be
+    supported to adjust the main view.)
+    """
+
+    square = "square"
+    """
+    Square lattice. 
+    Increasing `h` moves right and increasing `v` moves down. 
+    (i.e., "computer screen coordinates" rather than Cartesian coordinates where positive `y` is up.)
+    """
+
+    hex = "hex"
+    """
+    Hexagonal lattice. Uses the *"odd-q horizontal layout"* coordinate system described here: 
+    https://www.redblobgames.com/grids/hexagons/. 
+    Incrementing `v` moves down.
+    Incrementing `h` moves down and to the right if `h` is even, 
+    and moves up and to the right if `h` is odd.
+    """
+
+    honeycomb = "honeycomb"
+    """
+    Honeycomb lattice. This consists of all the hex lattice positions except where 
+    honeycomb lattice disallows grid positions (`h`, `v`) with 
+    `v` even and `h` a multiple of 3 or
+    `v` odd and `h` = 1 + a multiple of 3.  
+    
+    However, we use the same convention as cadnano for encoding hex coordinates see `misc/cadnano-format-specs/v2.txt`.
+    That convention is different from simply excluding coordinates from the hex lattice.
+
+    """
+
+    none = "none"
+    """No fixed grid."""
+
+
+# convenience names for users
+square = Grid.square
+hexagonal = Grid.hex  # should not use identifier "hex" because that's a Python built-in function
+honeycomb = Grid.honeycomb
+
+##########################################################################
+# constants
+
+default_idt_scale = "25nm"
+default_idt_purification = "STD"
+
+
+def default_major_tick_distance(grid: Grid) -> int:
+    return 7 if grid in (Grid.hex, Grid.honeycomb) else 8
+
+
+default_pitch: float = 0.0
+default_roll: float = 0.0
+default_yaw: float = 0.0
+
+default_group_name = 'default_group'
+
+_floating_point_tolerance = 0.00000001
+
+# XXX: code below related to SVG positions is not currently needed in the scripting library,
+# but I want to make sure these conventions are documented somewhere, so they are just commented out for now.
+#
+# base_width_svg: float = 10.0
+# """Width of a single base in the SVG main view of scadnano."""
+#
+# base_height_svg: float = 10.0
+# """Height of a single base in the SVG main view of scadnano."""
+#
+# distance_between_helices_nm: float = 2.5
+# """Distance between centers of helices in nanometers.
+# See :py:data:`distance_between_helices_svg` for explanation of this value."""
+#
+# base_width_nm: float = 0.34
+# """Width of a single DNA base in nanometers."""
+#
+# distance_between_helices_svg: float = base_width_svg * distance_between_helices_nm / base_width_nm
+# """Distance between tops of two consecutive helices (using default positioning rules).
+#
+# This is set to (:const:`base_width_svg` * 2.5/0.34) based on the following calculation,
+# to attempt to make the DNA structure appear to scale in 2D drawings:
+# The width of one base pair of double-stranded DNA bp is 0.34 nm. In a DNA origami,
+# AFM images let us estimate that the average distance between adjacent double helices is 2.5 nm.
+# (A DNA double-helix is only 2 nm wide, but the helices electrostatically repel each other so the spacing
+# in a DNA origami or an other DNA nanostructure with many parallel DNA helices---e.g., single-stranded tile
+# lattices---is larger than 2 nm.)
+# Thus the distance between the helices is 2.5/0.34 ~ 7.5 times the width of a single DNA base.
+# """
+
+DNA_base_wildcard: str = '?'
+"""Symbol to insert when a DNA sequence has been assigned to a strand through complementarity, but
+some regions of the strand are not bound to the strand that was just assigned. Also used in case the
+DNA sequence assigned to a strand is too short; the sequence is padded with :any:`DNA_base_wildcard` to 
+make its length the same as the length of the strand."""
+
+
+def _rotate_string(string: str, rotation: int) -> str:
+    rotation = rotation % len(string)
+    return string[rotation:] + string[:rotation]
+
+
+class M13Variant(enum.Enum):
+    """Variants of M13mp18 viral genome. "Standard" variant is p7249. Other variants are longer."""
+
+    p7249 = "p7249"
+    """"Standard" variant of M13mp18; 7249 bases long, available from, for example
+    
+    https://www.tilibit.com/collections/scaffold-dna/products/single-stranded-scaffold-dna-type-p7249
+    
+    https://www.neb.com/products/n4040-m13mp18-single-stranded-dna
+    
+    http://www.bayoubiolabs.com/biochemicat/vectors/pUCM13/
+    """
+
+    p7560 = "p7560"
+    """Variant of M13mp18 that is 7560 bases long. Available from, for example
+    
+    https://www.tilibit.com/collections/scaffold-dna/products/single-stranded-scaffold-dna-type-p7560
+    """
+
+    p8064 = "p8064"
+    """Variant of M13mp18 that is 8064 bases long. Available from, for example
+    
+    https://www.tilibit.com/collections/scaffold-dna/products/single-stranded-scaffold-dna-type-p8064
+    """
+
+
+def m13(rotation: int = 5587, variant: M13Variant = M13Variant.p7249) -> str:
+    """
+    The M13mp18 DNA sequence (commonly called simply M13).
+    
+    By default, starts from cyclic rotation 5587 
+    (with 0-based indexing;  commonly this is called rotation 5588, which assumes that indexing begins at 1), 
+    as defined in
+    `GenBank <https://www.ncbi.nlm.nih.gov/nuccore/X02513.1>`_.
+    
+    By default, returns the "standard" variant of consisting of 7249 bases, sold by companies such as  
+    `Tilibit <https://cdn.shopify.com/s/files/1/1299/5863/files/Product_Sheet_single-stranded_scaffold_DNA_type_7249_M1-10.pdf?14656642867652657391>`_.
+    and
+    `New England Biolabs <https://www.neb.com/~/media/nebus/page%20images/tools%20and%20resources/interactive%20tools/dna%20sequences%20and%20maps/m13mp18_map.pdf>`_
+    
+    The actual M13 DNA strand itself is circular, 
+    so assigning this sequence to the scaffold :any:`Strand` in a :any:`Design`
+    means that the "5' end" of the scaffold :any:`Strand` 
+    (which is a fiction since the actual circular DNA strand has no endpoint) 
+    will have the sequence starting at position 5587 starting at the displayed 5' in scadnano,
+    assigned until the displayed 3' end. 
+    Assuming the displayed scaffold :any:`Strand` has length :math:`n < 7249`, then a loopout of length 
+    :math:`7249 - n` consisting of the undisplayed bases will be present in the actual DNA structure.
+    For a more detailed discussion of why this particular rotation of M13 is chosen,
+    see 
+    `Supplementary Note S8 <http://www.dna.caltech.edu/Papers/DNAorigami-supp1.linux.pdf>`_ 
+    in
+    [`Folding DNA to create nanoscale shapes and patterns. Paul W. K. Rothemund, Nature 440:297-302 (2006) <http://www.nature.com/nature/journal/v440/n7082/abs/nature04586.html>`_].
+    
+    :param rotation: rotation of circular strand. Valid values are 0 through length-1.
+    :param variant: variant of M13 strand to use
+    :return: M13 strand sequence
+    """  # noqa (suppress PEP warning)
+    seq = _m13_variants[variant]
+    return _rotate_string(seq, rotation)
+
+
+_7249 = re.sub(r'\s', '', '''
+AATGCTACTACTATTAGTAGAATTGATGCCACCTTTTCAGCTCGCGCCCCAAATGAAAATATAGCTAAACAGGTTATTGACCATTTGCGAAATGTATCTA
+ATGGTCAAACTAAATCTACTCGTTCGCAGAATTGGGAATCAACTGTTATATGGAATGAAACTTCCAGACACCGTACTTTAGTTGCATATTTAAAACATGT
+TGAGCTACAGCATTATATTCAGCAATTAAGCTCTAAGCCATCCGCAAAAATGACCTCTTATCAAAAGGAGCAATTAAAGGTACTCTCTAATCCTGACCTG
+TTGGAGTTTGCTTCCGGTCTGGTTCGCTTTGAAGCTCGAATTAAAACGCGATATTTGAAGTCTTTCGGGCTTCCTCTTAATCTTTTTGATGCAATCCGCT
+TTGCTTCTGACTATAATAGTCAGGGTAAAGACCTGATTTTTGATTTATGGTCATTCTCGTTTTCTGAACTGTTTAAAGCATTTGAGGGGGATTCAATGAA
+TATTTATGACGATTCCGCAGTATTGGACGCTATCCAGTCTAAACATTTTACTATTACCCCCTCTGGCAAAACTTCTTTTGCAAAAGCCTCTCGCTATTTT
+GGTTTTTATCGTCGTCTGGTAAACGAGGGTTATGATAGTGTTGCTCTTACTATGCCTCGTAATTCCTTTTGGCGTTATGTATCTGCATTAGTTGAATGTG
+GTATTCCTAAATCTCAACTGATGAATCTTTCTACCTGTAATAATGTTGTTCCGTTAGTTCGTTTTATTAACGTAGATTTTTCTTCCCAACGTCCTGACTG
+GTATAATGAGCCAGTTCTTAAAATCGCATAAGGTAATTCACAATGATTAAAGTTGAAATTAAACCATCTCAAGCCCAATTTACTACTCGTTCTGGTGTTT
+CTCGTCAGGGCAAGCCTTATTCACTGAATGAGCAGCTTTGTTACGTTGATTTGGGTAATGAATATCCGGTTCTTGTCAAGATTACTCTTGATGAAGGTCA
+GCCAGCCTATGCGCCTGGTCTGTACACCGTTCATCTGTCCTCTTTCAAAGTTGGTCAGTTCGGTTCCCTTATGATTGACCGTCTGCGCCTCGTTCCGGCT
+AAGTAACATGGAGCAGGTCGCGGATTTCGACACAATTTATCAGGCGATGATACAAATCTCCGTTGTACTTTGTTTCGCGCTTGGTATAATCGCTGGGGGT
+CAAAGATGAGTGTTTTAGTGTATTCTTTTGCCTCTTTCGTTTTAGGTTGGTGCCTTCGTAGTGGCATTACGTATTTTACCCGTTTAATGGAAACTTCCTC
+ATGAAAAAGTCTTTAGTCCTCAAAGCCTCTGTAGCCGTTGCTACCCTCGTTCCGATGCTGTCTTTCGCTGCTGAGGGTGACGATCCCGCAAAAGCGGCCT
+TTAACTCCCTGCAAGCCTCAGCGACCGAATATATCGGTTATGCGTGGGCGATGGTTGTTGTCATTGTCGGCGCAACTATCGGTATCAAGCTGTTTAAGAA
+ATTCACCTCGAAAGCAAGCTGATAAACCGATACAATTAAAGGCTCCTTTTGGAGCCTTTTTTTTGGAGATTTTCAACGTGAAAAAATTATTATTCGCAAT
+TCCTTTAGTTGTTCCTTTCTATTCTCACTCCGCTGAAACTGTTGAAAGTTGTTTAGCAAAATCCCATACAGAAAATTCATTTACTAACGTCTGGAAAGAC
+GACAAAACTTTAGATCGTTACGCTAACTATGAGGGCTGTCTGTGGAATGCTACAGGCGTTGTAGTTTGTACTGGTGACGAAACTCAGTGTTACGGTACAT
+GGGTTCCTATTGGGCTTGCTATCCCTGAAAATGAGGGTGGTGGCTCTGAGGGTGGCGGTTCTGAGGGTGGCGGTTCTGAGGGTGGCGGTACTAAACCTCC
+TGAGTACGGTGATACACCTATTCCGGGCTATACTTATATCAACCCTCTCGACGGCACTTATCCGCCTGGTACTGAGCAAAACCCCGCTAATCCTAATCCT
+TCTCTTGAGGAGTCTCAGCCTCTTAATACTTTCATGTTTCAGAATAATAGGTTCCGAAATAGGCAGGGGGCATTAACTGTTTATACGGGCACTGTTACTC
+AAGGCACTGACCCCGTTAAAACTTATTACCAGTACACTCCTGTATCATCAAAAGCCATGTATGACGCTTACTGGAACGGTAAATTCAGAGACTGCGCTTT
+CCATTCTGGCTTTAATGAGGATTTATTTGTTTGTGAATATCAAGGCCAATCGTCTGACCTGCCTCAACCTCCTGTCAATGCTGGCGGCGGCTCTGGTGGT
+GGTTCTGGTGGCGGCTCTGAGGGTGGTGGCTCTGAGGGTGGCGGTTCTGAGGGTGGCGGCTCTGAGGGAGGCGGTTCCGGTGGTGGCTCTGGTTCCGGTG
+ATTTTGATTATGAAAAGATGGCAAACGCTAATAAGGGGGCTATGACCGAAAATGCCGATGAAAACGCGCTACAGTCTGACGCTAAAGGCAAACTTGATTC
+TGTCGCTACTGATTACGGTGCTGCTATCGATGGTTTCATTGGTGACGTTTCCGGCCTTGCTAATGGTAATGGTGCTACTGGTGATTTTGCTGGCTCTAAT
+TCCCAAATGGCTCAAGTCGGTGACGGTGATAATTCACCTTTAATGAATAATTTCCGTCAATATTTACCTTCCCTCCCTCAATCGGTTGAATGTCGCCCTT
+TTGTCTTTGGCGCTGGTAAACCATATGAATTTTCTATTGATTGTGACAAAATAAACTTATTCCGTGGTGTCTTTGCGTTTCTTTTATATGTTGCCACCTT
+TATGTATGTATTTTCTACGTTTGCTAACATACTGCGTAATAAGGAGTCTTAATCATGCCAGTTCTTTTGGGTATTCCGTTATTATTGCGTTTCCTCGGTT
+TCCTTCTGGTAACTTTGTTCGGCTATCTGCTTACTTTTCTTAAAAAGGGCTTCGGTAAGATAGCTATTGCTATTTCATTGTTTCTTGCTCTTATTATTGG
+GCTTAACTCAATTCTTGTGGGTTATCTCTCTGATATTAGCGCTCAATTACCCTCTGACTTTGTTCAGGGTGTTCAGTTAATTCTCCCGTCTAATGCGCTT
+CCCTGTTTTTATGTTATTCTCTCTGTAAAGGCTGCTATTTTCATTTTTGACGTTAAACAAAAAATCGTTTCTTATTTGGATTGGGATAAATAATATGGCT
+GTTTATTTTGTAACTGGCAAATTAGGCTCTGGAAAGACGCTCGTTAGCGTTGGTAAGATTCAGGATAAAATTGTAGCTGGGTGCAAAATAGCAACTAATC
+TTGATTTAAGGCTTCAAAACCTCCCGCAAGTCGGGAGGTTCGCTAAAACGCCTCGCGTTCTTAGAATACCGGATAAGCCTTCTATATCTGATTTGCTTGC
+TATTGGGCGCGGTAATGATTCCTACGATGAAAATAAAAACGGCTTGCTTGTTCTCGATGAGTGCGGTACTTGGTTTAATACCCGTTCTTGGAATGATAAG
+GAAAGACAGCCGATTATTGATTGGTTTCTACATGCTCGTAAATTAGGATGGGATATTATTTTTCTTGTTCAGGACTTATCTATTGTTGATAAACAGGCGC
+GTTCTGCATTAGCTGAACATGTTGTTTATTGTCGTCGTCTGGACAGAATTACTTTACCTTTTGTCGGTACTTTATATTCTCTTATTACTGGCTCGAAAAT
+GCCTCTGCCTAAATTACATGTTGGCGTTGTTAAATATGGCGATTCTCAATTAAGCCCTACTGTTGAGCGTTGGCTTTATACTGGTAAGAATTTGTATAAC
+GCATATGATACTAAACAGGCTTTTTCTAGTAATTATGATTCCGGTGTTTATTCTTATTTAACGCCTTATTTATCACACGGTCGGTATTTCAAACCATTAA
+ATTTAGGTCAGAAGATGAAATTAACTAAAATATATTTGAAAAAGTTTTCTCGCGTTCTTTGTCTTGCGATTGGATTTGCATCAGCATTTACATATAGTTA
+TATAACCCAACCTAAGCCGGAGGTTAAAAAGGTAGTCTCTCAGACCTATGATTTTGATAAATTCACTATTGACTCTTCTCAGCGTCTTAATCTAAGCTAT
+CGCTATGTTTTCAAGGATTCTAAGGGAAAATTAATTAATAGCGACGATTTACAGAAGCAAGGTTATTCACTCACATATATTGATTTATGTACTGTTTCCA
+TTAAAAAAGGTAATTCAAATGAAATTGTTAAATGTAATTAATTTTGTTTTCTTGATGTTTGTTTCATCATCTTCTTTTGCTCAGGTAATTGAAATGAATA
+ATTCGCCTCTGCGCGATTTTGTAACTTGGTATTCAAAGCAATCAGGCGAATCCGTTATTGTTTCTCCCGATGTAAAAGGTACTGTTACTGTATATTCATC
+TGACGTTAAACCTGAAAATCTACGCAATTTCTTTATTTCTGTTTTACGTGCAAATAATTTTGATATGGTAGGTTCTAACCCTTCCATTATTCAGAAGTAT
+AATCCAAACAATCAGGATTATATTGATGAATTGCCATCATCTGATAATCAGGAATATGATGATAATTCCGCTCCTTCTGGTGGTTTCTTTGTTCCGCAAA
+ATGATAATGTTACTCAAACTTTTAAAATTAATAACGTTCGGGCAAAGGATTTAATACGAGTTGTCGAATTGTTTGTAAAGTCTAATACTTCTAAATCCTC
+AAATGTATTATCTATTGACGGCTCTAATCTATTAGTTGTTAGTGCTCCTAAAGATATTTTAGATAACCTTCCTCAATTCCTTTCAACTGTTGATTTGCCA
+ACTGACCAGATATTGATTGAGGGTTTGATATTTGAGGTTCAGCAAGGTGATGCTTTAGATTTTTCATTTGCTGCTGGCTCTCAGCGTGGCACTGTTGCAG
+GCGGTGTTAATACTGACCGCCTCACCTCTGTTTTATCTTCTGCTGGTGGTTCGTTCGGTATTTTTAATGGCGATGTTTTAGGGCTATCAGTTCGCGCATT
+AAAGACTAATAGCCATTCAAAAATATTGTCTGTGCCACGTATTCTTACGCTTTCAGGTCAGAAGGGTTCTATCTCTGTTGGCCAGAATGTCCCTTTTATT
+ACTGGTCGTGTGACTGGTGAATCTGCCAATGTAAATAATCCATTTCAGACGATTGAGCGTCAAAATGTAGGTATTTCCATGAGCGTTTTTCCTGTTGCAA
+TGGCTGGCGGTAATATTGTTCTGGATATTACCAGCAAGGCCGATAGTTTGAGTTCTTCTACTCAGGCAAGTGATGTTATTACTAATCAAAGAAGTATTGC
+TACAACGGTTAATTTGCGTGATGGACAGACTCTTTTACTCGGTGGCCTCACTGATTATAAAAACACTTCTCAGGATTCTGGCGTACCGTTCCTGTCTAAA
+ATCCCTTTAATCGGCCTCCTGTTTAGCTCCCGCTCTGATTCTAACGAGGAAAGCACGTTATACGTGCTCGTCAAAGCAACCATAGTACGCGCCCTGTAGC
+GGCGCATTAAGCGCGGCGGGTGTGGTGGTTACGCGCAGCGTGACCGCTACACTTGCCAGCGCCCTAGCGCCCGCTCCTTTCGCTTTCTTCCCTTCCTTTC
+TCGCCACGTTCGCCGGCTTTCCCCGTCAAGCTCTAAATCGGGGGCTCCCTTTAGGGTTCCGATTTAGTGCTTTACGGCACCTCGACCCCAAAAAACTTGA
+TTTGGGTGATGGTTCACGTAGTGGGCCATCGCCCTGATAGACGGTTTTTCGCCCTTTGACGTTGGAGTCCACGTTCTTTAATAGTGGACTCTTGTTCCAA
+ACTGGAACAACACTCAACCCTATCTCGGGCTATTCTTTTGATTTATAAGGGATTTTGCCGATTTCGGAACCACCATCAAACAGGATTTTCGCCTGCTGGG
+GCAAACCAGCGTGGACCGCTTGCTGCAACTCTCTCAGGGCCAGGCGGTGAAGGGCAATCAGCTGTTGCCCGTCTCACTGGTGAAAAGAAAAACCACCCTG
+GCGCCCAATACGCAAACCGCCTCTCCCCGCGCGTTGGCCGATTCATTAATGCAGCTGGCACGACAGGTTTCCCGACTGGAAAGCGGGCAGTGAGCGCAAC
+GCAATTAATGTGAGTTAGCTCACTCATTAGGCACCCCAGGCTTTACACTTTATGCTTCCGGCTCGTATGTTGTGTGGAATTGTGAGCGGATAACAATTTC
+ACACAGGAAACAGCTATGACCATGATTACGAATTCGAGCTCGGTACCCGGGGATCCTCTAGAGTCGACCTGCAGGCATGCAAGCTTGGCACTGGCCGTCG
+TTTTACAACGTCGTGACTGGGAAAACCCTGGCGTTACCCAACTTAATCGCCTTGCAGCACATCCCCCTTTCGCCAGCTGGCGTAATAGCGAAGAGGCCCG
+CACCGATCGCCCTTCCCAACAGTTGCGCAGCCTGAATGGCGAATGGCGCTTTGCCTGGTTTCCGGCACCAGAAGCGGTGCCGGAAAGCTGGCTGGAGTGC
+GATCTTCCTGAGGCCGATACTGTCGTCGTCCCCTCAAACTGGCAGATGCACGGTTACGATGCGCCCATCTACACCAACGTGACCTATCCCATTACGGTCA
+ATCCGCCGTTTGTTCCCACGGAGAATCCGACGGGTTGTTACTCGCTCACATTTAATGTTGATGAAAGCTGGCTACAGGAAGGCCAGACGCGAATTATTTT
+TGATGGCGTTCCTATTGGTTAAAAAATGAGCTGATTTAACAAAAATTTAATGCGAATTTTAACAAAATATTAACGTTTACAATTTAAATATTTGCTTATA
+CAATCTTCCTGTTTTTGGGGCTTTTCTGATTATCAACCGGGGTACATATGATTGACATGCTAGTTTTACGATTACCGTTCATCGATTCTCTTGTTTGCTC
+CAGACTCTCAGGCAATGACCTGATAGCCTTTGTAGATCTCTCAAAAATAGCTACCCTCTCCGGCATTAATTTATCAGCTAGAACGGTTGAATATCATATT
+GATGGTGATTTGACTGTCTCCGGCCTTTCTCACCCTTTTGAATCTTTACCTACACATTACTCAGGCATTGCATTTAAAATATATGAGGGTTCTAAAAATT
+TTTATCCTTGCGTTGAAATAAAGGCTTCTCCCGCAAAAGTATTACAGGGTCATAATGTTTTTGGTACAACCGATTTAGCTTTATGCTCTGAGGCTTTATT
+GCTTAATTTTGCTAATTCTTTGCCTTGCCTGTATGATTTATTGGATGTT
+''')
+
+_7560 = re.sub(r'\s', '', '''
+AGCTTGGCACTGGCCGTCGTTTTACAACGTCGTGACTGGGAAAACCCTGGCGTTACCCAACTTAATCGCCTTGCAGCACATCCCCCTTTCGCCAGCTGGC 
+GTAATAGCGAAGAGGCCCGCACCGATCGCCCTTCCCAACAGTTGCGCAGCCTGAATGGCGAATGGCGCTTTGCCTGGTTTCCGGCACCAGAAGCGGTGCC
+GGAAAGCTGGCTGGAGTGCGATCTTCCTGAGGCCGATACTGTCGTCGTCCCCTCAAACTGGCAGATGCACGGTTACGATGCGCCCATCTACACCAACGTG
+ACCTATCCCATTACGGTCAATCCGCCGTTTGTTCCCACGGAGAATCCGACGGGTTGTTACTCGCTCACATTTAATGTTGATGAAAGCTGGCTACAGGAAG
+GCCAGACGCGAATTATTTTTGATGGCGTTCCTATTGGTTAAAAAATGAGCTGATTTAACAAAAATTTAATGCGAATTTTAACAAAATATTAACGTTTACA
+ATTTAAATATTTGCTTATACAATCTTCCTGTTTTTGGGGCTTTTCTGATTATCAACCGGGGTACATATGATTGACATGCTAGTTTTACGATTACCGTTCA
+TCGATTCTCTTGTTTGCTCCAGACTCTCAGGCAATGACCTGATAGCCTTTGTAGATCTCTCAAAAATAGCTACCCTCTCCGGCATTAATTTATCAGCTAG
+AACGGTTGAATATCATATTGATGGTGATTTGACTGTCTCCGGCCTTTCTCACCCTTTTGAATCTTTACCTACACATTACTCAGGCATTGCATTTAAAATA
+TATGAGGGTTCTAAAAATTTTTATCCTTGCGTTGAAATAAAGGCTTCTCCCGCAAAAGTATTACAGGGTCATAATGTTTTTGGTACAACCGATTTAGCTT
+TATGCTCTGAGGCTTTATTGCTTAATTTTGCTAATTCTTTGCCTTGCCTGTATGATTTATTGGATGTTAATGCTACTACTATTAGTAGAATTGATGCCAC
+CTTTTCAGCTCGCGCCCCAAATGAAAATATAGCTAAACAGGTTATTGACCATTTGCGAAATGTATCTAATGGTCAAACTAAATCTACTCGTTCGCAGAAT
+TGGGAATCAACTGTTATATGGAATGAAACTTCCAGACACCGTACTTTAGTTGCATATTTAAAACATGTTGAGCTACAGCATTATATTCAGCAATTAAGCT
+CTAAGCCATCCGCAAAAATGACCTCTTATCAAAAGGAGCAATTAAAGGTACTCTCTAATCCTGACCTGTTGGAGTTTGCTTCCGGTCTGGTTCGCTTTGA
+AGCTCGAATTAAAACGCGATATTTGAAGTCTTTCGGGCTTCCTCTTAATCTTTTTGATGCAATCCGCTTTGCTTCTGACTATAATAGTCAGGGTAAAGAC
+CTGATTTTTGATTTATGGTCATTCTCGTTTTCTGAACTGTTTAAAGCATTTGAGGGGGATTCAATGAATATTTATGACGATTCCGCAGTATTGGACGCTA
+TCCAGTCTAAACATTTTACTATTACCCCCTCTGGCAAAACTTCTTTTGCAAAAGCCTCTCGCTATTTTGGTTTTTATCGTCGTCTGGTAAACGAGGGTTA
+TGATAGTGTTGCTCTTACTATGCCTCGTAATTCCTTTTGGCGTTATGTATCTGCATTAGTTGAATGTGGTATTCCTAAATCTCAACTGATGAATCTTTCT
+ACCTGTAATAATGTTGTTCCGTTAGTTCGTTTTATTAACGTAGATTTTTCTTCCCAACGTCCTGACTGGTATAATGAGCCAGTTCTTAAAATCGCATAAG
+GTAATTCACAATGATTAAAGTTGAAATTAAACCATCTCAAGCCCAATTTACTACTCGTTCTGGTGTTTCTCGTCAGGGCAAGCCTTATTCACTGAATGAG
+CAGCTTTGTTACGTTGATTTGGGTAATGAATATCCGGTTCTTGTCAAGATTACTCTTGATGAAGGTCAGCCAGCCTATGCGCCTGGTCTGTACACCGTTC
+ATCTGTCCTCTTTCAAAGTTGGTCAGTTCGGTTCCCTTATGATTGACCGTCTGCGCCTCGTTCCGGCTAAGTAACATGGAGCAGGTCGCGGATTTCGACA
+CAATTTATCAGGCGATGATACAAATCTCCGTTGTACTTTGTTTCGCGCTTGGTATAATCGCTGGGGGTCAAAGATGAGTGTTTTAGTGTATTCTTTTGCC
+TCTTTCGTTTTAGGTTGGTGCCTTCGTAGTGGCATTACGTATTTTACCCGTTTAATGGAAACTTCCTCATGAAAAAGTCTTTAGTCCTCAAAGCCTCTGT
+AGCCGTTGCTACCCTCGTTCCGATGCTGTCTTTCGCTGCTGAGGGTGACGATCCCGCAAAAGCGGCCTTTAACTCCCTGCAAGCCTCAGCGACCGAATAT
+ATCGGTTATGCGTGGGCGATGGTTGTTGTCATTGTCGGCGCAACTATCGGTATCAAGCTGTTTAAGAAATTCACCTCGAAAGCAAGCTGATAAACCGATA
+CAATTAAAGGCTCCTTTTGGAGCCTTTTTTTTGGAGATTTTCAACGTGAAAAAATTATTATTCGCAATTCCTTTAGTTGTTCCTTTCTATTCTCACTCCG
+CTGAAACTGTTGAAAGTTGTTTAGCAAAATCCCATACAGAAAATTCATTTACTAACGTCTGGAAAGACGACAAAACTTTAGATCGTTACGCTAACTATGA
+GGGCTGTCTGTGGAATGCTACAGGCGTTGTAGTTTGTACTGGTGACGAAACTCAGTGTTACGGTACATGGGTTCCTATTGGGCTTGCTATCCCTGAAAAT
+GAGGGTGGTGGCTCTGAGGGTGGCGGTTCTGAGGGTGGCGGTTCTGAGGGTGGCGGTACTAAACCTCCTGAGTACGGTGATACACCTATTCCGGGCTATA
+CTTATATCAACCCTCTCGACGGCACTTATCCGCCTGGTACTGAGCAAAACCCCGCTAATCCTAATCCTTCTCTTGAGGAGTCTCAGCCTCTTAATACTTT
+CATGTTTCAGAATAATAGGTTCCGAAATAGGCAGGGGGCATTAACTGTTTATACGGGCACTGTTACTCAAGGCACTGACCCCGTTAAAACTTATTACCAG
+TACACTCCTGTATCATCAAAAGCCATGTATGACGCTTACTGGAACGGTAAATTCAGAGACTGCGCTTTCCATTCTGGCTTTAATGAGGATTTATTTGTTT
+GTGAATATCAAGGCCAATCGTCTGACCTGCCTCAACCTCCTGTCAATGCTGGCGGCGGCTCTGGTGGTGGTTCTGGTGGCGGCTCTGAGGGTGGTGGCTC
+TGAGGGTGGCGGTTCTGAGGGTGGCGGCTCTGAGGGAGGCGGTTCCGGTGGTGGCTCTGGTTCCGGTGATTTTGATTATGAAAAGATGGCAAACGCTAAT
+AAGGGGGCTATGACCGAAAATGCCGATGAAAACGCGCTACAGTCTGACGCTAAAGGCAAACTTGATTCTGTCGCTACTGATTACGGTGCTGCTATCGATG
+GTTTCATTGGTGACGTTTCCGGCCTTGCTAATGGTAATGGTGCTACTGGTGATTTTGCTGGCTCTAATTCCCAAATGGCTCAAGTCGGTGACGGTGATAA
+TTCACCTTTAATGAATAATTTCCGTCAATATTTACCTTCCCTCCCTCAATCGGTTGAATGTCGCCCTTTTGTCTTTGGCGCTGGTAAACCATATGAATTT
+TCTATTGATTGTGACAAAATAAACTTATTCCGTGGTGTCTTTGCGTTTCTTTTATATGTTGCCACCTTTATGTATGTATTTTCTACGTTTGCTAACATAC
+TGCGTAATAAGGAGTCTTAATCATGCCAGTTCTTTTGGGTATTCCGTTATTATTGCGTTTCCTCGGTTTCCTTCTGGTAACTTTGTTCGGCTATCTGCTT
+ACTTTTCTTAAAAAGGGCTTCGGTAAGATAGCTATTGCTATTTCATTGTTTCTTGCTCTTATTATTGGGCTTAACTCAATTCTTGTGGGTTATCTCTCTG
+ATATTAGCGCTCAATTACCCTCTGACTTTGTTCAGGGTGTTCAGTTAATTCTCCCGTCTAATGCGCTTCCCTGTTTTTATGTTATTCTCTCTGTAAAGGC
+TGCTATTTTCATTTTTGACGTTAAACAAAAAATCGTTTCTTATTTGGATTGGGATAAATAATATGGCTGTTTATTTTGTAACTGGCAAATTAGGCTCTGG
+AAAGACGCTCGTTAGCGTTGGTAAGATTCAGGATAAAATTGTAGCTGGGTGCAAAATAGCAACTAATCTTGATTTAAGGCTTCAAAACCTCCCGCAAGTC
+GGGAGGTTCGCTAAAACGCCTCGCGTTCTTAGAATACCGGATAAGCCTTCTATATCTGATTTGCTTGCTATTGGGCGCGGTAATGATTCCTACGATGAAA
+ATAAAAACGGCTTGCTTGTTCTCGATGAGTGCGGTACTTGGTTTAATACCCGTTCTTGGAATGATAAGGAAAGACAGCCGATTATTGATTGGTTTCTACA
+TGCTCGTAAATTAGGATGGGATATTATTTTTCTTGTTCAGGACTTATCTATTGTTGATAAACAGGCGCGTTCTGCATTAGCTGAACATGTTGTTTATTGT
+CGTCGTCTGGACAGAATTACTTTACCTTTTGTCGGTACTTTATATTCTCTTATTACTGGCTCGAAAATGCCTCTGCCTAAATTACATGTTGGCGTTGTTA
+AATATGGCGATTCTCAATTAAGCCCTACTGTTGAGCGTTGGCTTTATACTGGTAAGAATTTGTATAACGCATATGATACTAAACAGGCTTTTTCTAGTAA
+TTATGATTCCGGTGTTTATTCTTATTTAACGCCTTATTTATCACACGGTCGGTATTTCAAACCATTAAATTTAGGTCAGAAGATGAAATTAACTAAAATA
+TATTTGAAAAAGTTTTCTCGCGTTCTTTGTCTTGCGATTGGATTTGCATCAGCATTTACATATAGTTATATAACCCAACCTAAGCCGGAGGTTAAAAAGG
+TAGTCTCTCAGACCTATGATTTTGATAAATTCACTATTGACTCTTCTCAGCGTCTTAATCTAAGCTATCGCTATGTTTTCAAGGATTCTAAGGGAAAATT
+AATTAATAGCGACGATTTACAGAAGCAAGGTTATTCACTCACATATATTGATTTATGTACTGTTTCCATTAAAAAAGGTAATTCAAATGAAATTGTTAAA
+TGTAATTAATTTTGTTTTCTTGATGTTTGTTTCATCATCTTCTTTTGCTCAGGTAATTGAAATGAATAATTCGCCTCTGCGCGATTTTGTAACTTGGTAT
+TCAAAGCAATCAGGCGAATCCGTTATTGTTTCTCCCGATGTAAAAGGTACTGTTACTGTATATTCATCTGACGTTAAACCTGAAAATCTACGCAATTTCT
+TTATTTCTGTTTTACGTGCAAATAATTTTGATATGGTAGGTTCTAACCCTTCCATTATTCAGAAGTATAATCCAAACAATCAGGATTATATTGATGAATT
+GCCATCATCTGATAATCAGGAATATGATGATAATTCCGCTCCTTCTGGTGGTTTCTTTGTTCCGCAAAATGATAATGTTACTCAAACTTTTAAAATTAAT
+AACGTTCGGGCAAAGGATTTAATACGAGTTGTCGAATTGTTTGTAAAGTCTAATACTTCTAAATCCTCAAATGTATTATCTATTGACGGCTCTAATCTAT
+TAGTTGTTAGTGCTCCTAAAGATATTTTAGATAACCTTCCTCAATTCCTTTCAACTGTTGATTTGCCAACTGACCAGATATTGATTGAGGGTTTGATATT
+TGAGGTTCAGCAAGGTGATGCTTTAGATTTTTCATTTGCTGCTGGCTCTCAGCGTGGCACTGTTGCAGGCGGTGTTAATACTGACCGCCTCACCTCTGTT
+TTATCTTCTGCTGGTGGTTCGTTCGGTATTTTTAATGGCGATGTTTTAGGGCTATCAGTTCGCGCATTAAAGACTAATAGCCATTCAAAAATATTGTCTG
+TGCCACGTATTCTTACGCTTTCAGGTCAGAAGGGTTCTATCTCTGTTGGCCAGAATGTCCCTTTTATTACTGGTCGTGTGACTGGTGAATCTGCCAATGT
+AAATAATCCATTTCAGACGATTGAGCGTCAAAATGTAGGTATTTCCATGAGCGTTTTTCCTGTTGCAATGGCTGGCGGTAATATTGTTCTGGATATTACC
+AGCAAGGCCGATAGTTTGAGTTCTTCTACTCAGGCAAGTGATGTTATTACTAATCAAAGAAGTATTGCTACAACGGTTAATTTGCGTGATGGACAGACTC
+TTTTACTCGGTGGCCTCACTGATTATAAAAACACTTCTCAGGATTCTGGCGTACCGTTCCTGTCTAAAATCCCTTTAATCGGCCTCCTGTTTAGCTCCCG
+CTCTGATTCTAACGAGGAAAGCACGTTATACGTGCTCGTCAAAGCAACCATAGTACGCGCCCTGTAGCGGCGCATTAAGCGCGGCGGGTGTGGTGGTTAC
+GCGCAGCGTGACCGCTACACTTGCCAGCGCCCTAGCGCCCGCTCCTTTCGCTTTCTTCCCTTCCTTTCTCGCCACGTTCGCCGGCTTTCCCCGTCAAGCT
+CTAAATCGGGGGCTCCCTTTAGGGTTCCGATTTAGTGCTTTACGGCACCTCGACCCCAAAAAACTTGATTTGGGTGATGGTTCACGTAGTGGGCCATCGC
+CCTGATAGACGGTTTTTCGCCCTTTGACGTTGGAGTCCACGTTCTTTAATAGTGGACTCTTGTTCCAAACTGGAACAACACTCAACCCTATCTCGGGCTA
+TTCTTTTGATTTATAAGGGATTTTGCCGATTTCGGAACCACCATCAAACAGGATTTTCGCCTGCTGGGGCAAACCAGCGTGGACCGCTTGCTGCAACTCT
+CTCAGGGCCAGGCGGTGAAGGGCAATCAGCTGTTGCCCGTCTCACTGGTGAAAAGAAAAACCACCCTGGCGCCCAATACGCAAACCGCCTCTCCCCGCGC
+GTTGGCCGATTCATTAATGCAGCTGGCACGACAGGTTTCCCGACTGGAAAGCGGGCAGTGAGCGCAACGCAATTAATGTGAGTTAGCTCACTCATTAGGC
+ACCCCAGGCTTTACACTTTATGCTTCCGGCTCGTATGTTGTGTGGAATTGTGAGCGGATAACAATTTCACACAGGAAACAGCTATGACCATGATTACGAA
+TTCGAGCTCGGTACCCGGGGATCCTCCGTCTTTATCGAGGTAACAAGCACCACGTAGCTTAAGCCCTGTTTACTCATTACACCAACCAGGAGGTCAGAGT
+TCGGAGAAATGATTTATGTGAAATGCGTCAGCCGATTCAAGGCCCCTATATTCGTGCCCACCGACGAGTTGCTTACAGATGGCAGGGCCGCACTGTCGGT
+ATCATAGAGTCACTCCAGGGCGAGCGTAAATAGATTAGAAGCGGGGTTATTTTGGCGGGACATTGTCATAAGGTTGACAATTCAGCACTAAGGACACTTA
+AGTCGTGCGCATGAATTCACAACCACTTAGAAGAACATCCACCCTGGCTTCTCCTGAGAA
+''')
+
+_8064 = re.sub(r'\s', '', '''
+GGCAATGACCTGATAGCCTTTGTAGATCTCTCAAAAATAGCTACCCTCTCCGGCATTAATTTATCAGCTAGAACGGTTGAATATCATATTGATGGTGATT
+TGACTGTCTCCGGCCTTTCTCACCCTTTTGAATCTTTACCTACACATTACTCAGGCATTGCATTTAAAATATATGAGGGTTCTAAAAATTTTTATCCTTG
+CGTTGAAATAAAGGCTTCTCCCGCAAAAGTATTACAGGGTCATAATGTTTTTGGTACAACCGATTTAGCTTTATGCTCTGAGGCTTTATTGCTTAATTTT
+GCTAATTCTTTGCCTTGCCTGTATGATTTATTGGATGTTAATGCTACTACTATTAGTAGAATTGATGCCACCTTTTCAGCTCGCGCCCCAAATGAAAATA
+TAGCTAAACAGGTTATTGACCATTTGCGAAATGTATCTAATGGTCAAACTAAATCTACTCGTTCGCAGAATTGGGAATCAACTGTTATATGGAATGAAAC
+TTCCAGACACCGTACTTTAGTTGCATATTTAAAACATGTTGAGCTACAGCATTATATTCAGCAATTAAGCTCTAAGCCATCCGCAAAAATGACCTCTTAT
+CAAAAGGAGCAATTAAAGGTACTCTCTAATCCTGACCTGTTGGAGTTTGCTTCCGGTCTGGTTCGCTTTGAAGCTCGAATTAAAACGCGATATTTGAAGT
+CTTTCGGGCTTCCTCTTAATCTTTTTGATGCAATCCGCTTTGCTTCTGACTATAATAGTCAGGGTAAAGACCTGATTTTTGATTTATGGTCATTCTCGTT
+TTCTGAACTGTTTAAAGCATTTGAGGGGGATTCAATGAATATTTATGACGATTCCGCAGTATTGGACGCTATCCAGTCTAAACATTTTACTATTACCCCC
+TCTGGCAAAACTTCTTTTGCAAAAGCCTCTCGCTATTTTGGTTTTTATCGTCGTCTGGTAAACGAGGGTTATGATAGTGTTGCTCTTACTATGCCTCGTA
+ATTCCTTTTGGCGTTATGTATCTGCATTAGTTGAATGTGGTATTCCTAAATCTCAACTGATGAATCTTTCTACCTGTAATAATGTTGTTCCGTTAGTTCG
+TTTTATTAACGTAGATTTTTCTTCCCAACGTCCTGACTGGTATAATGAGCCAGTTCTTAAAATCGCATAAGGTAATTCACAATGATTAAAGTTGAAATTA
+AACCATCTCAAGCCCAATTTACTACTCGTTCTGGTGTTTCTCGTCAGGGCAAGCCTTATTCACTGAATGAGCAGCTTTGTTACGTTGATTTGGGTAATGA
+ATATCCGGTTCTTGTCAAGATTACTCTTGATGAAGGTCAGCCAGCCTATGCGCCTGGTCTGTACACCGTTCATCTGTCCTCTTTCAAAGTTGGTCAGTTC
+GGTTCCCTTATGATTGACCGTCTGCGCCTCGTTCCGGCTAAGTAACATGGAGCAGGTCGCGGATTTCGACACAATTTATCAGGCGATGATACAAATCTCC
+GTTGTACTTTGTTTCGCGCTTGGTATAATCGCTGGGGGTCAAAGATGAGTGTTTTAGTGTATTCTTTTGCCTCTTTCGTTTTAGGTTGGTGCCTTCGTAG
+TGGCATTACGTATTTTACCCGTTTAATGGAAACTTCCTCATGAAAAAGTCTTTAGTCCTCAAAGCCTCTGTAGCCGTTGCTACCCTCGTTCCGATGCTGT
+CTTTCGCTGCTGAGGGTGACGATCCCGCAAAAGCGGCCTTTAACTCCCTGCAAGCCTCAGCGACCGAATATATCGGTTATGCGTGGGCGATGGTTGTTGT
+CATTGTCGGCGCAACTATCGGTATCAAGCTGTTTAAGAAATTCACCTCGAAAGCAAGCTGATAAACCGATACAATTAAAGGCTCCTTTTGGAGCCTTTTT
+TTTGGAGATTTTCAACGTGAAAAAATTATTATTCGCAATTCCTTTAGTTGTTCCTTTCTATTCTCACTCCGCTGAAACTGTTGAAAGTTGTTTAGCAAAA
+TCCCATACAGAAAATTCATTTACTAACGTCTGGAAAGACGACAAAACTTTAGATCGTTACGCTAACTATGAGGGCTGTCTGTGGAATGCTACAGGCGTTG
+TAGTTTGTACTGGTGACGAAACTCAGTGTTACGGTACATGGGTTCCTATTGGGCTTGCTATCCCTGAAAATGAGGGTGGTGGCTCTGAGGGTGGCGGTTC
+TGAGGGTGGCGGTTCTGAGGGTGGCGGTACTAAACCTCCTGAGTACGGTGATACACCTATTCCGGGCTATACTTATATCAACCCTCTCGACGGCACTTAT
+CCGCCTGGTACTGAGCAAAACCCCGCTAATCCTAATCCTTCTCTTGAGGAGTCTCAGCCTCTTAATACTTTCATGTTTCAGAATAATAGGTTCCGAAATA
+GGCAGGGGGCATTAACTGTTTATACGGGCACTGTTACTCAAGGCACTGACCCCGTTAAAACTTATTACCAGTACACTCCTGTATCATCAAAAGCCATGTA
+TGACGCTTACTGGAACGGTAAATTCAGAGACTGCGCTTTCCATTCTGGCTTTAATGAGGATTTATTTGTTTGTGAATATCAAGGCCAATCGTCTGACCTG
+CCTCAACCTCCTGTCAATGCTGGCGGCGGCTCTGGTGGTGGTTCTGGTGGCGGCTCTGAGGGTGGTGGCTCTGAGGGTGGCGGTTCTGAGGGTGGCGGCT
+CTGAGGGAGGCGGTTCCGGTGGTGGCTCTGGTTCCGGTGATTTTGATTATGAAAAGATGGCAAACGCTAATAAGGGGGCTATGACCGAAAATGCCGATGA
+AAACGCGCTACAGTCTGACGCTAAAGGCAAACTTGATTCTGTCGCTACTGATTACGGTGCTGCTATCGATGGTTTCATTGGTGACGTTTCCGGCCTTGCT
+AATGGTAATGGTGCTACTGGTGATTTTGCTGGCTCTAATTCCCAAATGGCTCAAGTCGGTGACGGTGATAATTCACCTTTAATGAATAATTTCCGTCAAT
+ATTTACCTTCCCTCCCTCAATCGGTTGAATGTCGCCCTTTTGTCTTTGGCGCTGGTAAACCATATGAATTTTCTATTGATTGTGACAAAATAAACTTATT
+CCGTGGTGTCTTTGCGTTTCTTTTATATGTTGCCACCTTTATGTATGTATTTTCTACGTTTGCTAACATACTGCGTAATAAGGAGTCTTAATCATGCCAG
+TTCTTTTGGGTATTCCGTTATTATTGCGTTTCCTCGGTTTCCTTCTGGTAACTTTGTTCGGCTATCTGCTTACTTTTCTTAAAAAGGGCTTCGGTAAGAT
+AGCTATTGCTATTTCATTGTTTCTTGCTCTTATTATTGGGCTTAACTCAATTCTTGTGGGTTATCTCTCTGATATTAGCGCTCAATTACCCTCTGACTTT
+GTTCAGGGTGTTCAGTTAATTCTCCCGTCTAATGCGCTTCCCTGTTTTTATGTTATTCTCTCTGTAAAGGCTGCTATTTTCATTTTTGACGTTAAACAAA
+AAATCGTTTCTTATTTGGATTGGGATAAATAATATGGCTGTTTATTTTGTAACTGGCAAATTAGGCTCTGGAAAGACGCTCGTTAGCGTTGGTAAGATTC
+AGGATAAAATTGTAGCTGGGTGCAAAATAGCAACTAATCTTGATTTAAGGCTTCAAAACCTCCCGCAAGTCGGGAGGTTCGCTAAAACGCCTCGCGTTCT
+TAGAATACCGGATAAGCCTTCTATATCTGATTTGCTTGCTATTGGGCGCGGTAATGATTCCTACGATGAAAATAAAAACGGCTTGCTTGTTCTCGATGAG
+TGCGGTACTTGGTTTAATACCCGTTCTTGGAATGATAAGGAAAGACAGCCGATTATTGATTGGTTTCTACATGCTCGTAAATTAGGATGGGATATTATTT
+TTCTTGTTCAGGACTTATCTATTGTTGATAAACAGGCGCGTTCTGCATTAGCTGAACATGTTGTTTATTGTCGTCGTCTGGACAGAATTACTTTACCTTT
+TGTCGGTACTTTATATTCTCTTATTACTGGCTCGAAAATGCCTCTGCCTAAATTACATGTTGGCGTTGTTAAATATGGCGATTCTCAATTAAGCCCTACT
+GTTGAGCGTTGGCTTTATACTGGTAAGAATTTGTATAACGCATATGATACTAAACAGGCTTTTTCTAGTAATTATGATTCCGGTGTTTATTCTTATTTAA
+CGCCTTATTTATCACACGGTCGGTATTTCAAACCATTAAATTTAGGTCAGAAGATGAAATTAACTAAAATATATTTGAAAAAGTTTTCTCGCGTTCTTTG
+TCTTGCGATTGGATTTGCATCAGCATTTACATATAGTTATATAACCCAACCTAAGCCGGAGGTTAAAAAGGTAGTCTCTCAGACCTATGATTTTGATAAA
+TTCACTATTGACTCTTCTCAGCGTCTTAATCTAAGCTATCGCTATGTTTTCAAGGATTCTAAGGGAAAATTAATTAATAGCGACGATTTACAGAAGCAAG
+GTTATTCACTCACATATATTGATTTATGTACTGTTTCCATTAAAAAAGGTAATTCAAATGAAATTGTTAAATGTAATTAATTTTGTTTTCTTGATGTTTG
+TTTCATCATCTTCTTTTGCTCAGGTAATTGAAATGAATAATTCGCCTCTGCGCGATTTTGTAACTTGGTATTCAAAGCAATCAGGCGAATCCGTTATTGT
+TTCTCCCGATGTAAAAGGTACTGTTACTGTATATTCATCTGACGTTAAACCTGAAAATCTACGCAATTTCTTTATTTCTGTTTTACGTGCAAATAATTTT
+GATATGGTAGGTTCTAACCCTTCCATTATTCAGAAGTATAATCCAAACAATCAGGATTATATTGATGAATTGCCATCATCTGATAATCAGGAATATGATG
+ATAATTCCGCTCCTTCTGGTGGTTTCTTTGTTCCGCAAAATGATAATGTTACTCAAACTTTTAAAATTAATAACGTTCGGGCAAAGGATTTAATACGAGT
+TGTCGAATTGTTTGTAAAGTCTAATACTTCTAAATCCTCAAATGTATTATCTATTGACGGCTCTAATCTATTAGTTGTTAGTGCTCCTAAAGATATTTTA
+GATAACCTTCCTCAATTCCTTTCAACTGTTGATTTGCCAACTGACCAGATATTGATTGAGGGTTTGATATTTGAGGTTCAGCAAGGTGATGCTTTAGATT
+TTTCATTTGCTGCTGGCTCTCAGCGTGGCACTGTTGCAGGCGGTGTTAATACTGACCGCCTCACCTCTGTTTTATCTTCTGCTGGTGGTTCGTTCGGTAT
+TTTTAATGGCGATGTTTTAGGGCTATCAGTTCGCGCATTAAAGACTAATAGCCATTCAAAAATATTGTCTGTGCCACGTATTCTTACGCTTTCAGGTCAG
+AAGGGTTCTATCTCTGTTGGCCAGAATGTCCCTTTTATTACTGGTCGTGTGACTGGTGAATCTGCCAATGTAAATAATCCATTTCAGACGATTGAGCGTC
+AAAATGTAGGTATTTCCATGAGCGTTTTTCCTGTTGCAATGGCTGGCGGTAATATTGTTCTGGATATTACCAGCAAGGCCGATAGTTTGAGTTCTTCTAC
+TCAGGCAAGTGATGTTATTACTAATCAAAGAAGTATTGCTACAACGGTTAATTTGCGTGATGGACAGACTCTTTTACTCGGTGGCCTCACTGATTATAAA
+AACACTTCTCAGGATTCTGGCGTACCGTTCCTGTCTAAAATCCCTTTAATCGGCCTCCTGTTTAGCTCCCGCTCTGATTCTAACGAGGAAAGCACGTTAT
+ACGTGCTCGTCAAAGCAACCATAGTACGCGCCCTGTAGCGGCGCATTAAGCGCGGCGGGTGTGGTGGTTACGCGCAGCGTGACCGCTACACTTGCCAGCG
+CCCTAGCGCCCGCTCCTTTCGCTTTCTTCCCTTCCTTTCTCGCCACGTTCGCCGGCTTTCCCCGTCAAGCTCTAAATCGGGGGCTCCCTTTAGGGTTCCG
+ATTTAGTGCTTTACGGCACCTCGACCCCAAAAAACTTGATTTGGGTGATGGTTCACGTAGTGGGCCATCGCCCTGATAGACGGTTTTTCGCCCTTTGACG
+TTGGAGTCCACGTTCTTTAATAGTGGACTCTTGTTCCAAACTGGAACAACACTCAACCCTATCTCGGGCTATTCTTTTGATTTATAAGGGATTTTGCCGA
+TTTCGGAACCACCATCAAACAGGATTTTCGCCTGCTGGGGCAAACCAGCGTGGACCGCTTGCTGCAACTCTCTCAGGGCCAGGCGGTGAAGGGCAATCAG
+CTGTTGCCCGTCTCACTGGTGAAAAGAAAAACCACCCTGGCGCCCAATACGCAAACCGCCTCTCCCCGCGCGTTGGCCGATTCATTAATGCAGCTGGCAC
+GACAGGTTTCCCGACTGGAAAGCGGGCAGTGAGCGCAACGCAATTAATGTGAGTTAGCTCACTCATTAGGCACCCCAGGCTTTACACTTTATGCTTCCGG
+CTCGTATGTTGTGTGGAATTGTGAGCGGATAACAATTTCACACAGGAAACAGCTATGACCATGATTACGAATTCGAGCTCGGTACCCGGGGATCCTCAAC
+TGTGAGGAGGCTCACGGACGCGAAGAACAGGCACGCGTGCTGGCAGAAACCCCCGGTATGACCGTGAAAACGGCCCGCCGCATTCTGGCCGCAGCACCAC
+AGAGTGCACAGGCGCGCAGTGACACTGCGCTGGATCGTCTGATGCAGGGGGCACCGGCACCGCTGGCTGCAGGTAACCCGGCATCTGATGCCGTTAACGA
+TTTGCTGAACACACCAGTGTAAGGGATGTTTATGACGAGCAAAGAAACCTTTACCCATTACCAGCCGCAGGGCAACAGTGACCCGGCTCATACCGCAACC
+GCGCCCGGCGGATTGAGTGCGAAAGCGCCTGCAATGACCCCGCTGATGCTGGACACCTCCAGCCGTAAGCTGGTTGCGTGGGATGGCACCACCGACGGTG
+CTGCCGTTGGCATTCTTGCGGTTGCTGCTGACCAGACCAGCACCACGCTGACGTTCTACAAGTCCGGCACGTTCCGTTATGAGGATGTGCTCTGGCCGGA
+GGCTGCCAGCGACGAGACGAAAAAACGGACCGCGTTTGCCGGAACGGCAATCAGCATCGTTTAACTTTACCCTTCATCACTAAAGGCCGCCTGTGCGGCT
+TTTTTTACGGGATTTTTTTATGTCGATGTACACAACCGCCCAACTGCTGGCGGCAAATGAGCAGAAATTTAAGTTTGATCCGCTGTTTCTGCGTCTCTTT
+TTCCGTGAGAGCTATCCCTTCACCACGGAGAAAGTCTATCTCTCACAAATTCCGGGACTGGTAAACATGGCGCTGTACGTTTCGCCGATTGTTTCCGGTG
+AGGTTATCCGTTCCCGTGGCGGCTCCACCTCTGAAAGCTTGGCACTGGCCGTCGTTTTACAACGTCGTGACTGGGAAAACCCTGGCGTTACCCAACTTAA
+TCGCCTTGCAGCACATCCCCCTTTCGCCAGCTGGCGTAATAGCGAAGAGGCCCGCACCGATCGCCCTTCCCAACAGTTGCGCAGCCTGAATGGCGAATGG
+CGCTTTGCCTGGTTTCCGGCACCAGAAGCGGTGCCGGAAAGCTGGCTGGAGTGCGATCTTCCTGAGGCCGATACTGTCGTCGTCCCCTCAAACTGGCAGA
+TGCACGGTTACGATGCGCCCATCTACACCAACGTGACCTATCCCATTACGGTCAATCCGCCGTTTGTTCCCACGGAGAATCCGACGGGTTGTTACTCGCT
+CACATTTAATGTTGATGAAAGCTGGCTACAGGAAGGCCAGACGCGAATTATTTTTGATGGCGTTCCTATTGGTTAAAAAATGAGCTGATTTAACAAAAAT
+TTAATGCGAATTTTAACAAAATATTAACGTTTACAATTTAAATATTTGCTTATACAATCTTCCTGTTTTTGGGGCTTTTCTGATTATCAACCGGGGTACA
+TATGATTGACATGCTAGTTTTACGATTACCGTTCATCGATTCTCTTGTTTGCTCCAGACTCTCA
+''')
+
+_m13_variants = {
+    M13Variant.p7249: _7249,
+    M13Variant.p7560: _7560,
+    M13Variant.p8064: _8064,
+}
+
+##################
+# keys
+
+# Design keys
+version_key = 'version'
+grid_key = 'grid'
+helices_key = 'helices'
+strands_key = 'strands'
+scaffold_key = 'scaffold'
+helices_view_order_key = 'helices_view_order'
+is_origami_key = 'is_origami'
+design_modifications_key = 'modifications_in_design'
+geometry_key = 'geometry'
+groups_key = 'groups'
+
+# Geometry keys
+rise_per_base_pair_key = 'rise_per_base_pair'
+legacy_rise_per_base_pair_keys = ['z_step']
+helix_radius_key = 'helix_radius'
+bases_per_turn_key = 'bases_per_turn'
+minor_groove_angle_key = 'minor_groove_angle'
+inter_helix_gap_key = 'inter_helix_gap'
+
+# Helix keys
+idx_on_helix_key = 'idx'
+max_offset_key = 'max_offset'
+min_offset_key = 'min_offset'
+grid_position_key = 'grid_position'
+position_key = 'position'
+legacy_position_keys = ['origin']
+major_tick_distance_key = 'major_tick_distance'
+major_ticks_key = 'major_ticks'
+major_tick_start_key = 'major_tick_start'
+major_tick_periodic_distances_key = 'major_tick_periodic_distances'
+group_key = 'group'
+
+# Position keys
+position_x_key = 'x'
+position_y_key = 'y'
+position_z_key = 'z'
+pitch_key = 'pitch'
+roll_key = 'roll'
+yaw_key = 'yaw'
+position_origin_key = 'origin'
+
+# Strand keys
+strand_name_key = 'name'
+circular_key = 'circular'
+color_key = 'color'
+dna_sequence_key = 'sequence'
+legacy_dna_sequence_keys = ['dna_sequence']  # support legacy names for these ideas
+domains_key = 'domains'
+legacy_domains_keys = ['substrands']  # support legacy names for these ideas
+idt_key = 'idt'
+is_scaffold_key = 'is_scaffold'
+modification_5p_key = '5prime_modification'
+modification_3p_key = '3prime_modification'
+modifications_int_key = 'internal_modifications'
+strand_label_key = 'label'
+
+# Domain keys
+domain_name_key = 'name'
+helix_idx_key = 'helix'
+forward_key = 'forward'
+legacy_forward_keys = ['right']  # support legacy names for these ideas
+start_key = 'start'
+end_key = 'end'
+deletions_key = 'deletions'
+insertions_key = 'insertions'
+domain_label_key = 'label'
+
+# Loopout keys
+loopout_key = 'loopout'
+
+# Modification keys
+mod_location_key = 'location'
+mod_display_text_key = 'display_text'
+mod_id_key = 'id'
+mod_idt_text_key = 'idt_text'
+mod_font_size_key = 'font_size'
+mod_display_connector_key = 'display_connector'
+mod_allowed_bases_key = 'allowed_bases'
+
+# IDT keys
+idt_scale_key = 'scale'
+idt_purification_key = 'purification'
+idt_plate_key = 'plate'
+idt_well_key = 'well'
+# legacy; not written anymore as part of idt, but may be read from older versions of the JSON if
+# the Strand has no name but the IDT field does have a name
+idt_name_key = 'name'
+
+# end keys
+##################
+
+# end constants
+##########################################################################
+
+
+##########################################################################
+# modification abstract base classes
+
+_default_modification_id = "WARNING: no id assigned to modification"
+
+
+@dataclass(frozen=True, eq=True)
+class Modification(_JSONSerializable):
+    """Base class of modifications (to DNA sequences, e.g., biotin or Cy3).
+    Use :any:`Modification3Prime`, :any:`Modification5Prime`, or :any:`ModificationInternal`
+    to instantiate."""
+
+    display_text: str
+    """Short text to display in the web interface as an "icon"
+    visually representing the modification, e.g., ``'B'`` for biotin or ``'Cy3'`` for Cy3."""
+
+    id: str = _default_modification_id
+    """
+    Representation as a string; used to write in :any:`Strand` json representation,
+    while the full description of the modification is written under a global key in the :any:`Design`.
+    If not specified, but :py:data:`Modification.idt_text` is specified, then it will be set equal to that.
+    """
+
+    idt_text: Optional[str] = None
+    """IDT text string specifying this modification (e.g., '/5Biosg/' for 5' biotin). optional"""
+
+    def __post_init__(self) -> None:
+        if self.id == _default_modification_id and self.idt_text is not None:
+            object.__setattr__(self, 'id', self.idt_text)
+
+    def to_json_serializable(self, suppress_indent: bool = True, **kwargs: Any) -> Dict[str, Any]:
+        ret = {mod_display_text_key: self.display_text}
+        if self.idt_text is not None:
+            ret[mod_idt_text_key] = self.idt_text
+            ret[mod_display_connector_key] = False  # type: ignore
+        return ret
+
+    @staticmethod
+    def from_json(
+            json_map: Dict[str, Any]) -> 'Modification':  # remove quotes when Py3.6 support dropped
+        location = json_map[mod_location_key]
+        if location == "5'":
+            return Modification5Prime.from_json(json_map)
+        elif location == "3'":
+            return Modification3Prime.from_json(json_map)
+        elif location == "internal":
+            return ModificationInternal.from_json(json_map)
+        else:
+            raise IllegalDesignError(f'unknown Modification location "{location}"')
+
+
+@dataclass(frozen=True, eq=True)
+class Modification5Prime(Modification):
+    """5' modification of DNA sequence, e.g., biotin or Cy3."""
+
+    def to_json_serializable(self, suppress_indent: bool = True, **kwargs: Any) -> Dict[str, Any]:
+        ret = super().to_json_serializable(suppress_indent)
+        ret[mod_location_key] = "5'"
+        return ret
+
+    # remove quotes when Py3.6 support dropped
+    @staticmethod
+    def from_json(json_map: Dict[str, Any]) -> 'Modification5Prime':
+        display_text = json_map[mod_display_text_key]
+        location = json_map[mod_location_key]
+        assert location == "5'"
+        idt_text = json_map.get(mod_idt_text_key)
+        return Modification5Prime(display_text=display_text, idt_text=idt_text)
+
+
+@dataclass(frozen=True, eq=True)
+class Modification3Prime(Modification):
+    """3' modification of DNA sequence, e.g., biotin or Cy3."""
+
+    def to_json_serializable(self, suppress_indent: bool = True, **kwargs: Any) -> Dict[str, Any]:
+        ret = super().to_json_serializable(suppress_indent)
+        ret[mod_location_key] = "3'"
+        return ret
+
+    # remove quotes when Py3.6 support dropped
+    @staticmethod
+    def from_json(json_map: Dict[str, Any]) -> 'Modification3Prime':
+        display_text = json_map[mod_display_text_key]
+        location = json_map[mod_location_key]
+        assert location == "3'"
+        idt_text = json_map.get(mod_idt_text_key)
+        return Modification3Prime(display_text=display_text, idt_text=idt_text)
+
+
+@dataclass(frozen=True, eq=True)
+class ModificationInternal(Modification):
+    """Internal modification of DNA sequence, e.g., biotin or Cy3."""
+
+    allowed_bases: Optional[FrozenSet[str]] = None
+    """If None, then this is an internal modification that goes between bases. 
+    If instead it is a list of bases, then this is an internal modification that attaches to a base,
+    and this lists the allowed bases for this internal modification to be placed at. 
+    For example, internal biotins for IDT must be at a T. If any base is allowed, it should be
+    ``['A','C','G','T']``."""
+
+    def to_json_serializable(self, suppress_indent: bool = True, **kwargs: Any) -> Dict[str, Any]:
+        ret = super().to_json_serializable(suppress_indent)
+        ret[mod_location_key] = "internal"
+        if self.allowed_bases is not None:
+            ret[mod_allowed_bases_key] = NoIndent(
+                list(self.allowed_bases)) if suppress_indent else list(self.allowed_bases)
+        return ret
+
+    # remove quotes when Py3.6 support dropped
+    @staticmethod
+    def from_json(json_map: Dict[str, Any]) -> 'ModificationInternal':
+        display_text = json_map[mod_display_text_key]
+        location = json_map[mod_location_key]
+        assert location == "internal"
+        idt_text = json_map.get(mod_idt_text_key)
+        allowed_bases_list = json_map.get(mod_allowed_bases_key)
+        allowed_bases = frozenset(allowed_bases_list) if allowed_bases_list is not None else None
+        return ModificationInternal(display_text=display_text, idt_text=idt_text, allowed_bases=allowed_bases)
+
+
+# end modification abstract base classes
+##########################################################################
+
+
+@dataclass
+class Position3D(_JSONSerializable):
+    """
+    Position (x,y,z) in 3D space.
+    """
+
+    x: float = 0
+    """x-coordinate of position. 
+    Increasing `x` moves right in the side view and out of the screen in the main view."""
+
+    y: float = 0
+    """y-coordinate of position.
+    Increasing `y` moves down in the side and main views, i.e., "screen coordinates".
+    (though this can be rotated to Cartesian coordinates, where y goes up, 
+    by selecting "invert y/z axes" in the View menu of scadnano.)"""
+
+    z: float = 0
+    """z-coordinate of position.
+    Increasing `z` moves right in the main view and into the screen in the side view."""
+
+    def to_json_serializable(self, suppress_indent: bool = True, **kwargs: Any) -> Dict[str, Any]:
+        dct: Dict[str, Any] = dict(self.__dict__)
+        # return NoIndent(dct) if suppress_indent else dct
+        return dct
+
+    @staticmethod
+    def from_json(json_map: Dict[str, Any]) -> 'Position3D':  # remove quotes when Py3.6 support dropped
+        if position_origin_key in json_map:
+            origin_ = json_map[position_origin_key]
+            x = origin_[position_x_key]
+            y = origin_[position_y_key]
+            z = origin_[position_z_key]
+        else:
+            x = json_map[position_x_key]
+            y = json_map[position_y_key]
+            z = json_map[position_z_key]
+        return Position3D(x=x, y=y, z=z)
+
+
+origin: Position3D = Position3D(x=0, y=0, z=0)
+
+
+@dataclass
+class HelixGroup(_JSONSerializable):
+    """
+    Represents a set of properties to apply to a specific group of :any:`Helix`'s in the :any:`Design`.
+
+    A :any:`HelixGroup` is useful for grouping together helices that should all be in parallel,
+    as part of a design where different groups are not parallel. In particular, each :any:`HelixGroup`
+    can be given its own 3D position and pitch/yaw/roll orientation angles. Each :any:`HelixGroup` does
+    not actually *contain* its helices; they are associated through the field `Helix.group`, which is
+    a string representing a key in the dict ``groups`` specified in the constructor for :any:`Design`.
+
+    If there are :any:`HelixGroup`'s explicitly specified, then the field :py:data:`Design.grid` is ignored.
+    Each :any:`HelixGroup` has its own grid, and the fields :py:data:`Helix.position` or
+    :py:data:`Helix.grid_position` are considered relative to the origin of that :any:`HelixGroup`
+    (i.e., the value :py:data:`HelixGroup.position`). Although it is possible to assign a :any:`Helix`
+    in a :any:`HelixGroup` a non-zero :py:data:`Helix.pitch` or :py:data:`Helix.yaw`,
+    the most common use case is that all helices in a group are parallel, so they all have these angles
+    equal to 0 (since they are unrotated relative to each other along the pitch and yaw planes).
+    """
+
+    position: Position3D = origin
+    """The "origin" of this :any:`HelixGroup`."""
+
+    pitch: float = 0
+    """Angle in the main view plane; 0 means pointing to the right (min_offset on left, max_offset on right).
+    Rotation is clockwise in the main view.
+    See https://en.wikipedia.org/wiki/Aircraft_principal_axes
+    Units are degrees."""
+
+    yaw: float = 0
+    """Third angle for orientation besides :py:data:`HelixGroup.pitch` and :py:data:`HelixGroup.roll`.
+    Not visually displayed in scadnano, but here to support more general 3D applications.
+    See https://en.wikipedia.org/wiki/Aircraft_principal_axes
+    Units are degrees."""
+
+    roll: float = 0
+    """Same meaning as :py:data:`Helix.roll`, applied to every :any:`Helix` in the group."""
+
+    helices_view_order: Optional[List[int]] = None
+    """Order in which to display the :any:`Helix`'s in the group in the 2D view; if None, then the order
+    is given by the order of the fields :data:`Helix.idx` for each :any:`Helix` in this :any:`HelixGroup`."""
+
+    grid: Grid = Grid.none
+    """:any:`Grid` of this :any:`HelixGroup` used to interpret the field :data:`Helix.grid_position`."""
+
+    def to_json_serializable(self, suppress_indent: bool = True, **kwargs: Any) -> Dict[str, Any]:
+        dct: Dict[str, Any] = dict()
+
+        helix_idxs: List[int] = kwargs['helix_idxs']
+
+        pos = self.position.to_json_serializable(suppress_indent)
+        dct[position_key] = NoIndent(pos) if suppress_indent else pos
+
+        if not _is_close(self.pitch, default_pitch):
+            dct[pitch_key] = self.pitch
+        if not _is_close(self.roll, default_roll):
+            dct[roll_key] = self.roll
+        if not _is_close(self.yaw, default_yaw):
+            dct[yaw_key] = self.yaw
+
+        dct[grid_key] = str(self.grid)[5:]  # remove prefix 'Grid.'
+
+        default_helices_view_order = sorted(helix_idxs)
+        if self.helices_view_order != default_helices_view_order:
+            dct[helices_view_order_key] = NoIndent(self.helices_view_order)
+
+        return dct
+
+    def _assign_default_helices_view_order(self, helices_in_group: Dict[int, 'Helix']) -> None:
+        if self.helices_view_order is not None:
+            raise AssertionError('should not call _assign_default_helices_view_order if '
+                                 'HelixGroup.helices_view_order is not None, but it is '
+                                 f'{self.helices_view_order}')
+        helix_idxs = list(helices_in_group.keys())
+        self.helices_view_order = _check_helices_view_order_and_return(self.helices_view_order, helix_idxs)
+
+    @staticmethod
+    def from_json(json_map: dict, **kwargs: Any) -> 'HelixGroup':  # remove quotes when Py3.6 support dropped
+        grid: Grid = optional_field(Grid.none, json_map, grid_key, transformer=Grid)
+        # grid: Grid = Grid.none
+        # if grid_key in json_map:
+        #     grid = Grid(json_map[grid_key])
+
+        num_helices: int = kwargs['num_helices']
+        helices_view_order = json_map.get(helices_view_order_key)
+        if helices_view_order is not None:
+            if len(helices_view_order) != num_helices:
+                raise IllegalDesignError(f'length of helices ({num_helices}) does not match '
+                                         f'length of helices_view_order ({len(helices_view_order)})')
+
+        position_map = mandatory_field(HelixGroup, json_map, position_key, legacy_keys=legacy_position_keys)
+        position = Position3D.from_json(position_map)
+
+        pitch = json_map.get(pitch_key, default_pitch)
+        roll = json_map.get(roll_key, default_roll)
+        yaw = json_map.get(yaw_key, default_yaw)
+
+        return HelixGroup(position=position,
+                          pitch=pitch,
+                          yaw=yaw,
+                          roll=roll,
+                          helices_view_order=helices_view_order,
+                          grid=grid)
+
+    def helices_view_order_inverse(self, idx: int) -> int:
+        """
+        Given a :py:data:`Helix.idx` in this :any:`HelixGroup`, return its view order.
+
+        :param idx: index of :any:`Helix` in this :any:`HelixGroup`
+        :return: view order of the :any:`Helix`
+        :raises ValueError: if `idx` is not the index of a :any:`Helix` in this :any:`HelixGroup`
+        """
+        if self.helices_view_order is None:
+            raise ValueError('cannot access helices_view_order_inverse until helices_view_order is set')
+        return self.helices_view_order.index(idx)
+
+
+# def in_browser() -> bool:
+#     """Test if this code is running in the browser.
+#
+#     Checks for existence of package "pyodide" used in pyodide. If present it is assumed the code is
+#     running in the browser."""
+#     try:
+#         import pyodide  # type: ignore
+#         return True
+#     except ImportError:
+#         return False
+
+
+@dataclass
+class Helix(_JSONSerializable):
+    """
+    Represents a "helix" where :any:`Domain`'s could go. Technically a :any:`Helix` can contain no
+    :any:`Domain`'s. More commonly, some partial regions of it may have only 1 or 0 :any:`Domain`'s.
+    So it is best thought of as a "potential" double-helix.
+
+    It has a 1-dimensional integer coordinate system given by "offsets", integers between
+    :py:data:`Helix.min_offset` (inclusive) and :py:data:`Helix.max_offset` (exclusive).
+    At any valid offset for this :any:`Helix`, at most two :any:`Domain`'s may share that offset
+    on this :any:`Helix`, and if there are exactly two, then one must have
+    :py:data:`Domain.forward` = ``true`` and the other must have
+    :py:data:`Domain.forward` = ``false``.
+
+    Once part of a :any:`Design`, a :any:`Helix` has an index (accessible  via :py:meth:`Helix.idx`
+    once the :any:`Design` is created)
+    representing its order in the list of all :any:`Helix`'s. This index is how a :any:`Domain` is
+    associated to the :any:`Helix` via the integer index :any:`Domain.helix`.
+    """
+
+    max_offset: Optional[int] = None  # type: ignore
+    """Maximum offset (exclusive) of :any:`Domain` that can be drawn on this :any:`Helix`. 
+    
+    Optional field.
+    If unspecified, it is calculated when the :any:`Design` is instantiated as 
+    the largest :any:`Domain.end` offset of any :any:`Domain` in the design.
+    """
+
+    min_offset: int = 0
+    """Minimum offset (inclusive) of :any:`Domain` that can be drawn on this :any:`Helix`. 
+    
+    Optional field. Default value 0.
+    """
+
+    major_tick_start: Optional[int] = None  # type: ignore
+    """Offset of first major tick when not specifying :py:data:`Helix.major_ticks`. 
+    Used in combination with either 
+    :py:data:`Helix.major_tick_distance` or 
+    :py:data:`Helix.major_tick_periodic_distances`.
+    
+    Optional field. 
+    If not specified, is initialized to value :py:data:`Helix.min_offset`."""
+
+    major_tick_distance: Optional[int] = None
+    """Distance between major ticks (bold) delimiting boundaries between bases. Major ticks will appear
+    in the visual interface at positions 
+
+    Optional field.
+    If 0 then no major ticks are drawn.
+    If not specified then the default value is assumed.
+    If the grid is :any:`Grid.square` then the default value is 8.
+    If the grid is :any:`Grid.hex` or :any:`Grid.honeycomb` then the default value is 7."""
+
+    major_tick_periodic_distances: Optional[List[int]] = None
+    """Periodic distances between major ticks. For example, setting 
+    :py:data:`Helix.major_tick_periodic_distances` = [2, 3] and 
+    :py:data:`Helix.major_tick_start` = 10 means that major ticks will appear at
+    12, 
+    15,
+    17,
+    20,
+    22, 
+    25,
+    27,
+    30, ...
+    
+    Optional field.
+    :py:data:`Helix.major_tick_distance` is equivalent to 
+    the setting :py:data:`Helix.major_tick_periodic_distances` = [:py:data:`Helix.major_tick_distance`].
+    """
+
+    major_ticks: Optional[List[int]] = None  # type: ignore
+    """If not ``None``, overrides :any:`Helix.major_tick_distance`
+    to specify a list of offsets at which to put major ticks."""
+
+    grid_position: Optional[Tuple[int, int]] = None  # type: ignore
+    """`(h,v)` position of this helix in the side view grid,
+    if :const:`Grid.square`, :const:`Grid.hex` , or :const:`Grid.honeycomb` is used
+    in the :any:`Design` containing this helix.
+    `h` and `v` are in units of "helices": incrementing `h` moves right one helix in the grid
+    and incrementing `v` moves down one helix in the grid. 
+    In the case of the hexagonal lattice, 
+    The convention is that incrementing `v` moves down and to the right if h is even, 
+    and moves down and to the left if `h` is odd.
+    This is the "odd-q" coordinate system here: 
+    https://www.redblobgames.com/grids/hexagons/)
+    However, the default y position in the main view for helices does not otherwise depend on grid_position.
+    The default is to list the y-coordinates in order by helix idx.
+    
+    Default is `h` = 0, `v` = index of :any:`Helix` in :py:data:`Design.helices`.
+    
+    In the case of the honeycomb lattice, we use the same convention as cadnano for encoding hex coordinates,
+    see `misc/cadnano-format-specs/v2.txt`.
+    That convention is different from simply excluding coordinates from the hex lattice.
+    """
+
+    position: Optional[Position3D] = None  # type: ignore
+    """Position (x,y,z) of this :any:`Helix` in 3D space.
+    
+    Must be None if :py:data:`Helix.grid_position` is specified."""
+
+    roll: float = 0
+    """Angle around the center of the helix; 0 means pointing straight up in the side view.
+    Rotation is clockwise in the side view.
+    See https://en.wikipedia.org/wiki/Aircraft_principal_axes
+    Units are degrees."""
+
+    idx: Optional[int] = None
+    """Index of this :any:`Helix`.
+    
+    Optional if no other :any:`Helix` specifies a value for *idx*.
+    Default is the order of the :any:`Helix` is listed in constructor for :any:`Design`."""
+
+    group: str = default_group_name  # type: ignore
+    """Name of the :any:`HelixGroup` to which this :any:`Helix` belongs."""
+
+    # for optimization; list of domains on that Helix
+    _domains: List['Domain'] = field(default_factory=list)
+
+    def __post_init__(self) -> None:
+        if self.major_tick_start is None:  # type: ignore
+            self.major_tick_start = self.min_offset  # type: ignore
+        if self.grid_position is not None and self.position is not None:
+            raise IllegalDesignError('exactly one of grid_position or position must be specified, '
+                                     'but both are specified')
+        if self.major_ticks is not None and self.max_offset is not None and self.min_offset is not None:
+            for major_tick in self.major_ticks:
+                if major_tick > self.max_offset - self.min_offset:
+                    raise IllegalDesignError(f'major tick {major_tick} in list {self.major_ticks} is '
+                                             f'outside the range of available offsets since max_offset = '
+                                             f'{self.max_offset}')
+
+    def to_json_serializable(self, suppress_indent: bool = True, **kwargs: Any) -> Dict[str, Any]:
+        dct: Any = dict()
+
+        grid: Grid = kwargs['grid']
+
+        # if we have major ticks or position, it's harder to read Helix on one line,
+        # so don't wrap it in NoIndent, but still wrap longer sub-objects in them
+        # use_no_indent_helix: bool = not (self.major_ticks is not None or self.position is not None)
+        use_no_indent_helix: bool = not (self.major_ticks is not None)
+
+        if self.group != default_group_name:
+            dct[group_key] = self.group
+
+        if not self.min_offset_is_default():
+            dct[min_offset_key] = self.min_offset
+
+        if not self.major_tick_start_is_default():
+            dct[major_tick_start_key] = self.major_tick_start
+
+        dct[max_offset_key] = self.max_offset
+
+        if self.position is None:
+            if grid == Grid.none:
+                raise IllegalDesignError('cannot have Helix.position == None when grid is None')
+            dct[grid_position_key] = NoIndent(
+                self.grid_position) if suppress_indent and not use_no_indent_helix else self.grid_position
+        else:
+            if grid != Grid.none:
+                raise IllegalDesignError('cannot have Helix.position != None when grid is not None')
+            pos = self.position.to_json_serializable(suppress_indent)
+            dct[position_key] = NoIndent(pos) if suppress_indent and not use_no_indent_helix else pos
+
+        if not _is_close(self.roll, default_roll):
+            dct[roll_key] = self.roll
+
+        if not self.major_tick_distance_is_default(grid):
+            dct[major_tick_distance_key] = self.major_tick_distance
+
+        if not self.major_tick_periodic_distances_is_default():
+            dct[major_tick_periodic_distances_key] = NoIndent(
+                self.major_tick_periodic_distances) if suppress_indent and not use_no_indent_helix else \
+                self.major_tick_periodic_distances
+
+        if not self.major_ticks_is_default():
+            dct[major_ticks_key] = NoIndent(
+                self.major_ticks) if suppress_indent and not use_no_indent_helix else self.major_ticks
+
+        dct[idx_on_helix_key] = self.idx
+
+        return NoIndent(dct) if suppress_indent and use_no_indent_helix else dct
+
+    @staticmethod
+    def from_json(json_map: dict) -> 'Helix':  # remove quotes when Py3.6 support dropped
+        grid_position: Optional[Tuple[int, int]] = None
+        if grid_position_key in json_map:
+            gp_list = json_map[grid_position_key]
+            if len(gp_list) == 3:
+                gp_list = gp_list[:2]
+            if len(gp_list) != 2:
+                raise IllegalDesignError("list of grid_position coordinates must be length 2, "
+                                         f"but this is the list: {gp_list}")
+            grid_position = (gp_list[0], gp_list[1])
+
+        major_tick_distance = json_map.get(major_tick_distance_key)
+        major_ticks = json_map.get(major_ticks_key)
+        major_tick_start = json_map.get(major_tick_start_key)
+        major_tick_periodic_distances = json_map.get(major_tick_periodic_distances_key)
+        min_offset = optional_field(0, json_map, min_offset_key)
+        max_offset = json_map.get(max_offset_key)
+        idx = json_map.get(idx_on_helix_key)
+
+        position_map = optional_field(None, json_map, position_key, legacy_keys=legacy_position_keys)
+        position = Position3D.from_json(position_map) if position_map is not None else None
+
+        roll = json_map.get(roll_key, default_roll)
+
+        group = json_map.get(group_key, default_group_name)
+
+        return Helix(
+            major_tick_distance=major_tick_distance,
+            major_ticks=major_ticks,
+            major_tick_start=major_tick_start,
+            major_tick_periodic_distances=major_tick_periodic_distances,
+            grid_position=grid_position,
+            min_offset=min_offset,
+            max_offset=max_offset,
+            position=position,
+            roll=roll,
+            idx=idx,
+            group=group,
+        )
+
+    def default_grid_position(self) -> Tuple[int, int]:
+        if self.idx is None:
+            raise AssertionError('cannot call default_grid_position when idx is None')
+        return 0, self.idx
+
+    def calculate_major_ticks(self, grid: Grid) -> List[int]:
+        """
+        Calculates full list of major tick marks, whether using `default_major_tick_distance` (from
+        :any:`Design`), :py:data:`Helix.major_tick_distance`, or :py:data:`Helix.major_ticks`.
+        They are used in reverse order to determine precedence. (e.g., :py:data:`Helix.major_ticks`
+        overrides :py:data:`Helix.major_tick_distance`, which overrides
+        `default_major_tick_distance` from :any:`Design`.
+        """
+        if self.max_offset is None:
+            raise ValueError('cannot calculate major ticks if max_offset is not specified')
+        if self.major_tick_start is None:
+            raise AssertionError('major_tick_start should never be None')
+        if self.major_ticks is not None:
+            return self.major_ticks
+        elif self.major_tick_distance is not None:
+            return list(range(self.major_tick_start, self.max_offset + 1, self.major_tick_distance))
+        elif self.major_tick_periodic_distances is not None:
+            ticks = []
+            tick = self.major_tick_start
+            idx_period = 0
+            while tick <= self.max_offset:
+                ticks.append(tick)
+                tick += self.major_tick_periodic_distances[idx_period]
+                idx_period = (idx_period + 1) % len(self.major_tick_periodic_distances)
+            return ticks
+        else:
+            distance = default_major_tick_distance(grid)
+            return list(range(self.major_tick_start, self.max_offset + 1, distance))
+
+    @property
+    def domains(self) -> List['Domain']:
+        """
+        Return :any:`Domain`'s on this :any:`Helix`.
+        Assigned when a :any:`Design` is created using this :any:`Helix`.
+
+        :return: :any:`Domain`'s on this helix
+        """
+        return self._domains
+
+    def min_offset_is_default(self) -> bool:
+        return self.min_offset == 0
+
+    def major_tick_start_is_default(self) -> bool:
+        return self.major_tick_start == self.min_offset
+
+    def major_tick_distance_is_default(self, grid: Grid) -> bool:
+        return (self.major_tick_distance is None
+                or default_major_tick_distance(grid) == self.major_tick_distance)
+
+    def major_tick_periodic_distances_is_default(self) -> bool:
+        return self.major_tick_periodic_distances is None
+
+    def major_ticks_is_default(self) -> bool:
+        return self.major_ticks is None
+
+
+def _is_close(x1: float, x2: float) -> bool:
+    return abs(x1 - x2) < _floating_point_tolerance
+
+
+DomainLabel = TypeVar('DomainLabel')
+StrandLabel = TypeVar('StrandLabel')
+
+
+@dataclass
+class Domain(_JSONSerializable, Generic[DomainLabel]):
+    """
+    A maximal portion of a :any:`Strand` that is continguous on a single :any:`Helix`.
+    A :any:`Strand` contains a list of :any:`Domain`'s (and also potentially :any:`Loopout`'s).
+    """
+
+    helix: int
+    """index of the :any:`Helix` on which this :any:`Domain` resides."""
+
+    forward: bool
+    """Whether the strand "points" forward (i.e., its 3' end has a larger offset than its 5' end).
+    If :any:`Domain.forward` is ``True``, then 
+    :any:`Domain.start` is the 5' end of the :any:`Domain` and 
+    :any:`Domain.end` is the 3' end of the :any:`Domain`.
+    If :any:`Domain.forward` is ``False``, these roles are reversed."""
+
+    start: int
+    """
+    The smallest offset position of any base on this Domain
+    (3' end if :any:`Domain.forward` = ``False``,
+    5' end if :any:`Domain.forward` = ``True``).
+    """
+
+    end: int
+    """
+    1 plus the largest offset position of any base on this Domain
+    (5' end if :any:`Domain.forward` = ``False``,
+    3' end if :any:`Domain.forward` = ``True``).
+    Note that the set of base offsets occupied by this Domain is {start, start+1, ..., end-1},
+    i.e., inclusive for :py:data:`Strand.start` but exclusive for :py:data:`Strand.end`,
+    the same convention used in Python for slices of lists and strings.
+    (e.g., :samp:`"abcdef"[1:3] == "bc"`)
+    
+    Some methods (such as :py:meth:`Domain.dna_sequence_in`) use the convention of being inclusive on 
+    both ends and are marked with the word "INCLUSIVE".
+    (Such a convention is easier to reason about when there are insertions and deletions.)
+    """
+
+    deletions: List[int] = field(default_factory=list)
+    """List of positions of deletions on this Domain."""
+
+    insertions: List[Tuple[int, int]] = field(default_factory=list)
+    """List of (position,num_insertions) pairs on this Domain.
+    
+    This is the number of *extra* bases in addition to the base already at this position. 
+    The total number of bases at this offset is num_insertions+1."""
+
+    name: Optional[str] = None
+    """Optional name to give this :any:`Domain`. 
+    
+    This is used to interoperate with the dsd DNA sequence design package."""
+
+    label: Optional[DomainLabel] = None
+    """
+    Generic "label" object to associate to this :any:`Domain`.
+
+    Useful for associating extra information with the :any:`Domain` that will be serialized, for example,
+    for DNA sequence design. It must be an object (e.g., a dict or primitive type such as str or int) 
+    that is naturally JSON serializable. (Calling 
+    `json.dumps <https://docs.python.org/3/library/json.html#json.dumps>`_
+    on the object should succeed without having to specify a custom encoder.)
+    """
+
+    # not serialized; for efficiency
+    # remove quotes when Py3.6 support dropped
+    _parent_strand: Optional['Strand'] = field(init=False, repr=False, compare=False, default=None)
+
+    def __post_init__(self) -> None:
+        self._check_start_end()
+
+    def to_json_serializable(self, suppress_indent: bool = True,
+                             **kwargs: Any) -> Union[NoIndent, Dict[str, Any]]:
+        dct: Dict[str, Any] = OrderedDict()
+        if self.name is not None:
+            dct[domain_name_key] = self.name
+        dct[helix_idx_key] = self.helix
+        dct[forward_key] = self.forward
+        dct[start_key] = self.start
+        dct[end_key] = self.end
+        if len(self.deletions) > 0:
+            dct[deletions_key] = self.deletions
+        if len(self.insertions) > 0:
+            dct[insertions_key] = self.insertions
+        if self.label is not None:
+            dct[domain_label_key] = self.label
+        return NoIndent(dct) if suppress_indent else dct
+
+    @staticmethod
+    def from_json(json_map: Dict[str, Any]) -> 'Domain':  # remove quotes when Py3.6 support dropped
+        helix = mandatory_field(Domain, json_map, helix_idx_key)
+        forward = mandatory_field(Domain, json_map, forward_key, legacy_keys=legacy_forward_keys)
+        start = mandatory_field(Domain, json_map, start_key)
+        end = mandatory_field(Domain, json_map, end_key)
+        deletions = json_map.get(deletions_key, [])
+        insertions = cast(List[Tuple[int, int]],  # noqa
+                          list(map(tuple, json_map.get(insertions_key, []))))  # type: ignore
+        name = json_map.get(domain_name_key)
+        label = json_map.get(domain_label_key)
+        return Domain(
+            helix=helix,
+            forward=forward,
+            start=start,
+            end=end,
+            deletions=deletions,
+            insertions=insertions,
+            name=name,
+            label=label,
+        )
+
+    def __repr__(self) -> str:
+        rep = (f'Domain(' +
+               (f'name={self.name}' if self.name is not None else '') +
+               f', helix={self.helix}'
+               f', forward={self.forward}'
+               f', start={self.start}'
+               f', end={self.end}') + \
+              (f', deletions={self.deletions}' if len(self.deletions) > 0 else '') + \
+              (f', insertions={self.insertions}' if len(self.insertions) > 0 else '') + \
+              ')'
+        return rep
+
+    def __str__(self) -> str:
+        return repr(self) if self.name is None else self.name
+
+    def strand(self) -> 'Strand':  # remove quotes when Py3.6 support dropped
+        """
+        :return: The :any:`Strand` that contains this :any:`Loopout`.
+        """
+        if self._parent_strand is None:
+            raise ValueError('_parent_strand has not yet been set')
+        return self._parent_strand
+
+    def set_name(self, name: str) -> None:
+        """Sets name of this :any:`Domain`."""
+        self.name = name
+
+    def set_label(self, label: DomainLabel) -> None:
+        """Sets label of this :any:`Domain`."""
+        self.label = label
+
+    def _check_start_end(self) -> None:
+        if self.start >= self.end:
+            if self._parent_strand is None:
+                raise ValueError(f'start = {self.start} must be less than end = {self.end}\n'
+                                 f'_parent_strand has not yet been set')
+            raise StrandError(self._parent_strand,
+                              f'start = {self.start} must be less than end = {self.end}')
+
+    # @staticmethod
+    # def is_loopout() -> bool:
+    #     """Indicates if this is a :any:`Loopout` (always false)
+    #     Useful when object could be either :any:`Loopout` or :any:`Domain`."""
+    #     return False
+    #
+    # @staticmethod
+    # def is_domain() -> bool:
+    #     """Indicates if this is a :any:`Domain` (always true)
+    #     Useful when object could be either :any:`Loopout` or :any:`Domain`."""
+    #     return True
+
+    def set_start(self, new_start: int) -> None:
+        self.start = new_start
+        self._check_start_end()
+
+    def set_end(self, new_end: int) -> None:
+        self.end = new_end
+        self._check_start_end()
+
+    def offset_5p(self) -> int:
+        """5' offset of this :any:`Domain`, INCLUSIVE."""
+        if self.forward:
+            return self.start
+        else:
+            return self.end - 1
+        # return self.start if self.forward else self.end - 1
+
+    def offset_3p(self) -> int:
+        """3' offset of this :any:`Domain`, INCLUSIVE."""
+        return self.end - 1 if self.forward else self.start
+
+    def _num_insertions(self) -> int:
+        # total number of insertions in this Domain
+        return sum(insertion[1] for insertion in self.insertions)
+
+    def contains_offset(self, offset: int) -> bool:
+        """Indicates if `offset` is the offset of a base on this :any:`Domain`.
+
+        Note that offsets refer to visual portions of the displayed grid for the Helix.
+        If for example, this Domain starts at position 0 and ends at 10, and it has 5 deletions,
+        then it contains the offset 7 even though there is no base 7 positions from the start."""
+        return self.start <= offset < self.end
+
+    def __len__(self) -> int:
+        """Same as :meth:`Domain.dna_length`.
+
+        See also :meth:`Domain.visual_length`."""
+        return self.dna_length()
+
+    def dna_length(self) -> int:
+        """Number of bases in this Domain."""
+        return self.end - self.start - len(self.deletions) + self._num_insertions()
+
+    def dna_length_in(self, left: int, right: int) -> int:
+        """Number of bases in this Domain between offsets `left` and `right` (INCLUSIVE)."""
+        if not left <= right + 1:
+            raise ValueError(f'left = {left} and right = {right} but we should have left <= right + 1')
+        if not self.start <= left:
+            raise ValueError(f'left = {left} should be at least self.start = {self.start}')
+        if not right < self.end:
+            raise ValueError(f'right = {right} should be at most self.end - 1 = {self.end - 1}')
+        num_deletions = sum(1 for offset in self.deletions if left <= offset <= right)
+        num_insertions = sum(length for (offset, length) in self.insertions if left <= offset <= right)
+        return (right - left + 1) - num_deletions + num_insertions
+
+    def visual_length(self) -> int:
+        """Distance between :any:`Domain.start` offset and :any:`Domain.end` offset.
+
+        This can be more or less than the :meth:`Domain.dna_length` due to insertions and deletions."""
+        return self.end - self.start
+
+    def dna_sequence(self) -> Optional[str]:
+        """Return DNA sequence of this Domain, or ``None`` if no DNA sequence has been assigned
+        to this :any:`Domain`'s :any:`Strand`."""
+        return self.dna_sequence_in(self.start, self.end - 1)
+
+    def dna_sequence_in(self, offset_left: int, offset_right: int) -> Optional[str]:
+        """Return DNA sequence of this Domain in the interval of offsets given by
+        [`offset_left`, `offset_right`], INCLUSIVE, or ``None`` if no DNA sequence has been assigned
+        to this :any:`Domain`'s :any:`Strand`.
+
+        WARNING: This is inclusive on both ends,
+        unlike other parts of this API where the right endpoint is exclusive.
+        This is to make the notion well-defined when one of the endpoints is on an offset with a
+        deletion or insertion."""
+        strand_seq = self._parent_strand.dna_sequence if self._parent_strand is not None else None
+        if strand_seq is None:
+            return None
+
+        # if on a deletion, move inward until we are off of it
+        while offset_left in self.deletions:
+            offset_left += 1
+        while offset_right in self.deletions:
+            offset_right -= 1
+
+        if offset_left > offset_right:
+            return ''
+        if offset_left >= self.end:
+            return ''
+        if offset_right < 0:
+            return ''
+
+        str_idx_left = self.domain_offset_to_strand_dna_idx(offset_left, self.forward)
+        str_idx_right = self.domain_offset_to_strand_dna_idx(offset_right, not self.forward)
+        if not self.forward:  # these will be out of order if strand is left
+            str_idx_left, str_idx_right = str_idx_right, str_idx_left
+        subseq = strand_seq[str_idx_left:str_idx_right + 1]
+        return subseq
+
+    def get_seq_start_idx(self) -> int:
+        """Starting DNA subsequence index for first base of this :any:`Domain` on its
+        Parent :any:`Strand`'s DNA sequence."""
+        if self._parent_strand is None:
+            raise ValueError('should not call this method if a Strand has not be associated to this Domain')
+        domains = self._parent_strand.domains
+        # index of self in parent strand's list of domains
+        self_domain_idx = domains.index(self)
+        # index of self's position within the DNA sequence of parent strand
+        self_seq_idx_start = sum(prev_domain.dna_length()
+                                 for prev_domain in domains[:self_domain_idx])
+        return self_seq_idx_start
+
+    def domain_offset_to_strand_dna_idx(self, offset: int, offset_closer_to_5p: bool) -> int:
+        """ Convert from offset on this :any:`Domain`'s :any:`Helix`
+        to string index on the parent :any:`Strand`'s DNA sequence.
+
+        If `offset_closer_to_5p` is ``True``, (this only matters if `offset` contains an insertion)
+        then the only leftmost string index corresponding to this offset is included,
+        otherwise up to the rightmost string index (including all insertions) is included."""
+        if offset in self.deletions:
+            raise ValueError(f'offset {offset} illegally contains a deletion from {self.deletions}')
+
+        # length adjustment for insertions depends on whether this is a left or right offset
+        len_adjust = self._net_ins_del_length_increase_from_5p_to(offset, offset_closer_to_5p)
+
+        # get string index assuming this Domain is first on Strand
+        if self.forward:
+            offset += len_adjust  # account for insertions and deletions
+            domain_str_idx = offset - self.start
+        else:
+            # account for insertions and deletions
+            offset -= len_adjust  # account for insertions and deletions
+            domain_str_idx = self.end - 1 - offset
+
+        # correct for existence of previous Domains on this Strand
+        return domain_str_idx + self.get_seq_start_idx()
+
+    def _net_ins_del_length_increase_from_5p_to(self, offset_edge: int, offset_closer_to_5p: bool) -> int:
+        """Net number of insertions from 5'/3' end to offset_edge,
+        INCLUSIVE on 5'/3' end, EXCLUSIVE on offset_edge.
+
+        Set `five_p` ``= False`` to test from 3' end to `offset_edge`."""
+        length_increase = 0
+        for deletion in self.deletions:
+            if self._between_5p_and_offset(deletion, offset_edge):
+                length_increase -= 1
+        for (insertion_offset, insertion_length) in self.insertions:
+            if self._between_5p_and_offset(insertion_offset, offset_edge):
+                length_increase += insertion_length
+        # special case for when offset_edge is an endpoint closer to the 3' end,
+        # we add its extra insertions also in this case
+        if not offset_closer_to_5p:
+            insertion_map: Dict[int, int] = dict(self.insertions)
+            if offset_edge in insertion_map:
+                insertion_length = insertion_map[offset_edge]
+                length_increase += insertion_length
+        return length_increase
+
+    def _between_5p_and_offset(self, offset_to_test: int, offset_edge: int) -> bool:
+        return ((self.forward and self.start <= offset_to_test < offset_edge) or
+                (not self.forward and offset_edge < offset_to_test < self.end))
+
+    # def _between_3p_and_offset(self, offset_to_test: int, offset_edge: int) -> bool:
+    #     return ((self.direction == Direction.left and self.start <= offset_to_test < offset_edge) or
+    #             (self.direction == Direction.forward and offset_edge < offset_to_test < self.end))
+
+    # The type hint 'Domain' must be in quotes since Domain is not yet defined.
+    # This is a "forward reference": https://www.python.org/dev/peps/pep-0484/#forward-references
+    # remove quotes when Py3.6 support dropped
+    # def overlaps(self, other: Domain) -> bool:
+    def overlaps(self, other: 'Domain') -> bool:
+        r"""Indicates if this :any:`Domain`'s set of offsets (the set
+        :math:`\{x \in \mathbb{N} \mid`
+        ``self.start``
+        :math:`\leq x \leq`
+        ``self.end``
+        :math:`\}`)
+        has nonempty intersection with those of `other`,
+        and they appear on the same helix,
+        and they point in opposite directions."""  # noqa (suppress PEP warning)
+        return (self.helix == other.helix and
+                self.forward == (not other.forward) and
+                self.compute_overlap(other)[0] >= 0)
+
+    # remove quotes when Py3.6 support dropped
+    # def overlaps_illegally(self, other: Domain):
+    def overlaps_illegally(self, other: 'Domain') -> bool:
+        r"""Indicates if this :any:`Domain`'s set of offsets (the set
+        :math:`\{x \in \mathbb{N} \mid`
+        ``self.start``
+        :math:`\leq x \leq`
+        ``self.end``
+        :math:`\}`)
+        has nonempty intersection with those of `other`,
+        and they appear on the same helix,
+        and they point in the same direction."""  # noqa (suppress PEP warning)
+        return (self.helix == other.helix and
+                self.forward == other.forward and
+                self.compute_overlap(other)[0] >= 0)
+
+    # remove quotes when Py3.6 support dropped
+    # def compute_overlap(self, other: Domain) -> Tuple[int, int]:
+    def compute_overlap(self, other: 'Domain') -> Tuple[int, int]:
+        """Return [left,right) offset indicating overlap between this Domain and `other`.
+
+        Return ``(-1,-1)`` if they do not overlap (different helices, or non-overlapping regions
+        of the same helix)."""
+        overlap_start = max(self.start, other.start)
+        overlap_end = min(self.end, other.end)
+        if overlap_start >= overlap_end:  # overlap is empty
+            return -1, -1
+        return overlap_start, overlap_end
+
+    def insertion_offsets(self) -> List[int]:
+        """Return offsets of insertions (but not their lengths)."""
+        return [ins_off for (ins_off, _) in self.insertions]
+
+
+@dataclass
+class Loopout(_JSONSerializable, Generic[DomainLabel]):
+    """Represents a single-stranded loopout on a :any:`Strand`.
+
+    One could think of a :any:`Loopout` as a type of :any:`Domain`, but none of the fields of
+    :any:`Domain` make sense for :any:`Loopout`, so they are not related to each other in the type
+    hierarchy. It is interpreted that a :any:`Loopout` is a single-stranded region bridging two
+    :any:`Domain`'s that are connected to :any:`Helix`'s.
+    It is illegal for two consecutive :any:`Domain`'s to both
+    be :any:`Loopout`'s,
+    or for a :any:`Loopout` to occur on either end of the :any:`Strand`
+    (i.e., each :any:`Strand` must begin and end with a :any:`Domain`).
+
+    For example, one use of a loopout is to describe a hairpin (a.k.a.,
+    `stem-loop <https://en.wikipedia.org/wiki/Stem-loop>`_).
+    The following creates a :any:`Strand` that represents a hairpin with a stem length of 10 and a loop
+    length of 5.
+
+    .. code-block:: Python
+
+        import scadnano as sc
+
+        domain_f = sc.Domain(helix=0, forward=True, start=0, end=10)
+        loop = sc.Loopout(length=5)
+        domain_r = sc.Domain(helix=0, forward=False, start=0, end=10)
+        hairpin = sc.Strand([domain_f, loop, domain_r])
+
+    It can also be created with chained method calls
+
+    .. code-block:: Python
+
+        import scadnano as sc
+
+        design = sc.Design(helices=[sc.Helix(max_offset=10)])
+        design.strand(0,0).move(10).loopout(0,5).move(-10)
+    """
+
+    length: int
+    """Length (in DNA bases) of this :any:`Loopout`."""
+
+    name: Optional[str] = None
+    """
+    Optional name to give this :any:`Loopout`.
+
+    This is used to interoperate with the dsd DNA sequence design package.
+    """
+
+    label: Optional[DomainLabel] = None
+    """
+    Generic "label" object to associate to this :any:`Loopout`.
+
+    Useful for associating extra information with the :any:`Loopout` that will be serialized, for example,
+    for DNA sequence design. It must be an object (e.g., a dict or primitive type such as str or int) 
+    that is naturally JSON serializable. (Calling 
+    `json.dumps <https://docs.python.org/3/library/json.html#json.dumps>`_
+    on the object should succeed without having to specify a custom encoder.)
+    """
+
+    # not serialized; for efficiency
+    # remove quotes when Py3.6 support dropped
+    _parent_strand: Optional['Strand'] = field(init=False, repr=False, compare=False, default=None)
+
+    def to_json_serializable(self, suppress_indent: bool = True,
+                             **kwargs: Any) -> Union[Dict[str, Any], NoIndent]:
+        dct: Dict[str, Any] = {loopout_key: self.length}
+        if self.name is not None:
+            dct[domain_name_key] = self.name
+        if self.label is not None:
+            dct[domain_label_key] = self.label
+        return NoIndent(dct) if suppress_indent else dct
+
+    @staticmethod
+    def from_json(json_map: Dict[str, Any]) -> 'Loopout':  # remove quotes when Py3.6 support dropped
+        # XXX: this should never fail since we detect whether to call this from_json by the presence
+        # of a length key in json_map
+        length_str = mandatory_field(Loopout, json_map, loopout_key)
+        length = int(length_str)
+        name = json_map.get(domain_name_key)
+        label = json_map.get(domain_label_key)
+        return Loopout(length=length, name=name, label=label)
+
+    def strand(self) -> 'Strand':  # remove quotes when Py3.6 support dropped
+        """
+        :return: The :any:`Strand` that contains this :any:`Loopout`.
+        """
+        if self._parent_strand is None:
+            raise ValueError('_parent_strand has not yet been set')
+        return self._parent_strand
+
+    def __repr__(self) -> str:
+        return f'Loopout(' + \
+               (f'{self.name}, ' if self.name is not None else '') + \
+               f'{self.length}, ' + \
+               (f'{self.label}, ' if self.label is not None else '') + \
+               f')'
+
+    def __str__(self) -> str:
+        return repr(self) if self.name is None else self.name
+
+    def set_name(self, name: str) -> None:
+        """Sets name of this :any:`Loopout`."""
+        self.name = name
+
+    def set_label(self, label: Optional[DomainLabel]) -> None:
+        """Sets label of this :any:`Loopout`."""
+        self.label = label
+
+    def __len__(self) -> int:
+        """Same as :any:`Loopout.dna_length`"""
+        return self.dna_length()
+
+    def dna_length(self) -> int:
+        """Length of this :any:`Loopout`; same as field :py:data:`Loopout.length`."""
+        return self.length
+
+    def dna_sequence(self) -> Optional[str]:
+        """Return DNA sequence of this :any:`Loopout`, or ``None`` if no DNA sequence has been assigned
+        to the :any:`Strand` of this :any:`Loopout`."""
+        if self._parent_strand is None:
+            raise ValueError('_parent_strand has not been set')
+        strand_seq = self._parent_strand.dna_sequence
+        if strand_seq is None:
+            return None
+
+        str_idx_left = self.get_seq_start_idx()
+        str_idx_right = str_idx_left + self.length  # EXCLUSIVE (unlike similar code for Domain)
+        subseq = strand_seq[str_idx_left:str_idx_right]
+        return subseq
+
+    def get_seq_start_idx(self) -> int:
+        """Starting DNA subsequence index for first base of this :any:`Loopout` on its
+        :any:`Strand`'s DNA sequence."""
+        if self._parent_strand is None:
+            raise ValueError('_parent_strand has not been set')
+        domains = self._parent_strand.domains
+        # index of self in parent strand's list of domains
+        self_domain_idx = domains.index(self)
+        # index of self's position within the DNA sequence of parent strand
+        self_seq_idx_start = sum(prev_domain.dna_length()
+                                 for prev_domain in domains[:self_domain_idx])
+        return self_seq_idx_start
+
+
+_wctable = str.maketrans('ACGTacgt', 'TGCAtgca')
+
+
+def wc(seq: str) -> str:
+    """Return reverse Watson-Crick complement of `seq`."""
+    return seq.translate(_wctable)[::-1]
+
+
+@dataclass
+class IDTFields(_JSONSerializable):
+    """Data required when ordering DNA strands from the synthesis company
+    `IDT (Integrated DNA Technologies) <https://www.idtdna.com/>`_.
+    This data is used when automatically generating files used to order DNA from IDT.
+
+    When exporting to IDT files via :py:meth:`Design.write_idt_plate_excel_file`
+    or :py:meth:`Design.write_idt_bulk_input_file`, the field :py:data:`Strand.name` is used for the
+    name if it exists, otherwise a reasonable default is chosen."""
+
+    scale: str = default_idt_scale
+    """Synthesis scale at which to synthesize the strand (third field in IDT bulk input:
+    https://www.idtdna.com/site/order/oligoentry).
+    Choices supplied by IDT at the time this was written: 
+    ``"25nm"``, ``"100nm"``, ``"250nm"``, ``"1um"``, ``"5um"``, 
+    ``"10um"``, ``"4nmU"``, ``"20nmU"``, ``"PU"``, ``"25nmS"``.
+    
+    Optional field.
+    """
+
+    purification: str = default_idt_purification
+    """Purification options (fourth field in IDT bulk input:
+    https://www.idtdna.com/site/order/oligoentry). 
+    Choices supplied by IDT at the time this was written: 
+    ``"STD"``, ``"PAGE"``, ``"HPLC"``, ``"IEHPLC"``, ``"RNASE"``, ``"DUALHPLC"``, ``"PAGEHPLC"``.
+    
+    Optional field.
+    """
+
+    plate: Optional[str] = None
+    """Name of plate in case this strand will be ordered on a 96-well or 384-well plate.
+    
+    Optional field, but non-optional if :py:data:`IDTField.well` is not ``None``.
+    """
+
+    well: Optional[str] = None
+    """Well position on plate in case this strand will be ordered on a 96-well or 384-well plate.
+    
+    Optional field, but non-optional if :py:data:`IDTField.plate` is not ``None``.
+    """
+
+    def __post_init__(self) -> None:
+        _check_idt_string_not_none_or_empty(self.scale, 'scale')
+        _check_idt_string_not_none_or_empty(self.purification, 'purification')
+        if self.plate is None and self.well is not None:
+            raise IllegalDesignError(f'IDTFields.plate cannot be None if IDTFields.well is not None\n'
+                                     f'IDTFields.well = {self.well}')
+        if self.plate is not None and self.well is None:
+            raise IllegalDesignError(f'IDTFields.well cannot be None if IDTFields.plate is not None\n'
+                                     f'IDTFields.plate = {self.plate}')
+
+    def to_json_serializable(self, suppress_indent: bool = True,
+                             **kwargs: Any) -> Union[NoIndent, Dict[str, Any]]:
+        dct: Dict[str, Any] = dict(self.__dict__)
+        if self.plate is None:
+            del dct['plate']
+        if self.well is None:
+            del dct['well']
+        return NoIndent(dct)
+
+    @staticmethod
+    def from_json(json_map: Dict[str, Any]) -> 'IDTFields':
+        scale = mandatory_field(IDTFields, json_map, idt_scale_key)
+        purification = mandatory_field(IDTFields, json_map, idt_purification_key)
+        plate = json_map.get(idt_plate_key)
+        well = json_map.get(idt_well_key)
+        return IDTFields(scale=scale, purification=purification, plate=plate, well=well)
+
+
+def _check_idt_string_not_none_or_empty(value: str, field_name: str) -> None:
+    if value is None:
+        raise IllegalDesignError(f'field {field_name} in IDTFields cannot be None')
+    if len(value) == 0:
+        raise IllegalDesignError(f'field {field_name} in IDTFields cannot be empty')
+
+
+class StrandBuilder(Generic[StrandLabel, DomainLabel]):
+    """
+    Represents a :any:`Strand` that is being built in an existing :any:`Design`.
+
+    This is an intermediate object created when using chained method building by calling
+    :py:meth:`Design.strand`, for example
+
+    .. code-block:: Python
+
+        design.strand(0, 0).to(10).cross(1).to(5).with_modification_5p(mod.biotin_5p).as_scaffold()
+
+    :any:`StrandBuilder` should generally not be created directly.
+    Although it is convenient to use chained method calls, it is also sometimes useful to assign the
+    :any:`StrandBuilder` object into a variable and then call the methods on that variable. For example,
+    this code is equivalent to the above line:
+
+    .. code-block:: Python
+
+        strand_builder = design.strand(0, 0)
+        strand_builder.to(10)
+        strand_builder.cross(1)
+        strand_builder.to(5)
+        strand_builder.with_modification_5p(mod.biotin_5p)
+        strand_builder.as_scaffold()
+    """
+
+    # remove quotes when Py3.6 support dropped
+    def __init__(self, design: 'Design[StrandLabel, DomainLabel]', helix: int, offset: int):
+        self.design: Design[StrandLabel, DomainLabel] = design
+        self.current_helix: int = helix
+        self.current_offset: int = offset
+        # self.loopout_length: Optional[int] = None
+        self._strand: Optional[Strand[StrandLabel, DomainLabel]] = None
+        self.just_moved_to_helix: bool = True
+        self.last_domain: Optional[Domain[DomainLabel]] = None
+
+    @property
+    def strand(self) -> 'Strand[StrandLabel, DomainLabel]':
+        if self._strand is None:
+            raise ValueError('no Strand created yet; make at least one domain first')
+        return self._strand
+
+    # remove quotes when Py3.6 support dropped
+    def cross(self, helix: int, offset: Optional[int] = None, move: Optional[int] = None) \
+            -> 'StrandBuilder[StrandLabel, DomainLabel]':
+        """
+        Add crossover. To have any effect, must be followed by call to :py:meth:`StrandBuilder.to`
+        or :py:meth:`StrandBuilder.move`.
+
+        :param helix: :any:`Helix` to crossover to
+        :param offset: new offset on `helix`. If not specified, defaults to current offset.
+            (i.e., a "vertical" crossover)
+            Mutually excusive with `move`.
+        :param move:
+            Relative distance to new offset on `helix` from current offset.
+            If not specified, defaults to using parameter `offset`.
+            Mutually excusive with `offset`.
+        :return: self
+        """
+        if self._strand is None:
+            raise ValueError('no Strand created yet; make at least one domain first')
+        if move is not None and offset is not None:
+            raise IllegalDesignError('move and offset cannot both be specified:\n'
+                                     f'move:   {move}\n'
+                                     f'offset: {offset}')
+        self.last_domain = None
+        self.current_helix = helix
+        if offset is not None:
+            self.current_offset = offset
+        elif move is not None:
+            self.current_offset += move
+        return self
+
+    # remove quotes when Py3.6 support dropped
+    def loopout(self, helix: int, length: int, offset: Optional[int] = None, move: Optional[int] = None) \
+            -> 'StrandBuilder[StrandLabel, DomainLabel]':
+        """
+        Like :py:meth:`StrandBuilder.cross`, but creates a :any:`Loopout` instead of a crossover.
+
+        :param helix: :any:`Helix` to crossover to
+        :param length: length of :any:`Loopout` to add
+        :param offset: new offset on `helix`. If not specified, defaults to current offset.
+            (i.e., a "vertical" loopout)
+            Mutually excusive with `move`.
+        :param move:
+            Relative distance to new offset on `helix` from current offset.
+            If not specified, defaults to using parameter `offset`.
+            Mutually excusive with `offset`.
+        :return: self
+        """
+        if self._strand is None:
+            raise ValueError('no Strand created yet; make at least one domain first')
+        self.cross(helix, offset=offset, move=move)
+        self.design.append_domain(self._strand, Loopout(length))
+        return self
+
+    # remove quotes when Py3.6 support dropped
+    def move(self, delta: int) -> 'StrandBuilder[StrandLabel, DomainLabel]':
+        """
+        Extends this :any:`StrandBuilder` on the current helix to offset given by the current offset
+        plus `delta`, which adds a new :any:`Domain` to the :any:`Strand` being built. This is a
+        "relative move", whereas :py:meth:`StrandBuilder.to` and :py:meth:`StrandBuilder.update_to`
+        are "absolute moves".
+
+        This updates the underlying :any:`Design` with a new :any:`Domain`,
+        and if :py:meth:`StrandBuilder.loopout` was last called on this :any:`StrandBuilder`,
+        also a new :any:`Loopout`.
+
+        If two instances of :py:meth:`StrandBuilder.move` are chained together, this creates two domains
+        on the same helix. The two offsets must move in the same direction. In other words, if we call
+        ``.move(o1).move(o2)``, then ``o1`` and ``o2`` must be either both negative or both positive.
+
+        :param delta:
+            Distance to new offset to extend to, compared to current offset.
+            If less than current offset, the new :any:`Domain` is reverse, otherwise it is forward.
+        :return: self
+        """
+        return self.to(self.current_offset + delta)
+
+    # remove quotes when Py3.6 support dropped
+    def to(self, offset: int) -> 'StrandBuilder[StrandLabel, DomainLabel]':
+        """
+        Extends this :any:`StrandBuilder` on the current helix to offset `offset`,
+        which adds a new :any:`Domain` to the :any:`Strand` being built. This is an
+        "absolute move", whereas :py:meth:`StrandBuilder.move` is a "relative move".
+
+        This updates the underlying :any:`Design` with a new :any:`Domain`,
+        and if :py:meth:`StrandBuilder.loopout` was last called on this :any:`StrandBuilder`,
+        also a new :any:`Loopout`.
+
+        If two instances of :py:meth:`StrandBuilder.to` are chained together, this creates two domains
+        on the same helix. The two offsets must move in the same direction. In other words, if the starting
+        offset is ``s``, and we call ``.to(o1).to(o2)``, then either ``s < o1 < o2`` or ``o2 < o1 < s``
+        must be true.
+
+        To simply change the current offset after calling :py:meth:`StrandBuilder.to`, without creating
+        a new Domain, call :py:meth:`StrandBuilder.update_to` instead.
+
+        :param offset: new offset to extend to. If less than current offset,
+            the new :any:`Domain` is reverse, otherwise it is forward.
+        :return: self
+        """
+        if self.last_domain and ((self.last_domain.forward and offset < self.current_offset) or (
+                not self.last_domain.forward and offset > self.current_offset)):
+            raise IllegalDesignError('offsets must be monotonic '
+                                     '(strictly increasing or strictly decreasing) '
+                                     'when calling to() twice in a row')
+
+        if offset > self.current_offset:
+            forward = True
+            start = self.current_offset
+            end = offset
+        elif offset < self.current_offset:
+            forward = False
+            start = offset
+            end = self.current_offset
+        else:
+            raise IllegalDesignError(f'offset {offset} cannot be equal to current offset')
+
+        domain: Domain[DomainLabel] = Domain(helix=self.current_helix, forward=forward, start=start, end=end)
+        self.last_domain = domain
+        if self._strand is not None:
+            self.design.append_domain(self._strand, domain)
+        else:
+            self._strand = Strand(domains=[domain])
+            self.design.add_strand(self._strand)
+
+        self.current_offset = offset
+
+        return self
+
+    # remove quotes when Py3.6 support dropped
+    def update_to(self, offset: int) -> 'StrandBuilder[StrandLabel, DomainLabel]':
+        """
+        Like :py:meth:`StrandBuilder.to`, but changes the current offset without creating
+        a new :any:`Domain`. So unlike :py:meth:`StrandBuilder.to`, several consecutive calls to
+        :py:meth:`StrandBuilder.update_to` are equivalent to only making the final call. This is an
+        "absolute move", whereas :py:meth:`StrandBuilder.move` is a "relative move".
+
+        If :py:meth:`StrandBuilder.cross` or :py:meth:`StrandBuilder.loopout` was just called,
+        then :py:meth:`StrandBuilder.to` and :py:meth:`StrandBuilder.update_to` have the same effect.
+
+        :param offset: new offset to extend to. If less than offset of the last call to
+            :py:meth:`StrandBuilder.cross` or :py:meth:`StrandBuilder.loopout`,
+            the new :any:`Domain` is reverse, otherwise it is forward.
+        :return: self
+        """
+        if not self.last_domain:
+            return self.to(offset)
+
+        domain = self.last_domain
+        if (self.last_domain.forward and offset < self.current_offset) or (
+                not self.last_domain.forward and offset > self.current_offset):
+            raise IllegalDesignError(f'when calling ')
+
+        if domain.forward:
+            domain.set_end(offset)
+        else:
+            domain.set_start(offset)
+
+        self.current_offset = offset
+
+        return self
+
+    def as_circular(self) -> 'StrandBuilder[StrandLabel, DomainLabel]':
+        """
+        Makes :any:`Strand` being built circular.
+
+        :return: self
+        """
+        if self._strand is None:
+            raise ValueError('no Strand created yet; make at least one domain first')
+        self._strand.set_circular()
+        return self
+
+    # remove quotes when Py3.6 support dropped
+    def as_scaffold(self) -> 'StrandBuilder[StrandLabel, DomainLabel]':
+        """
+        Makes :any:`Strand` being built a scaffold.
+
+        :return: self
+        """
+        if self._strand is None:
+            raise ValueError('no Strand created yet; make at least one domain first')
+        self._strand.set_scaffold(True)
+        return self
+
+    def with_idt(self, scale: str = default_idt_scale,
+                 purification: str = default_idt_purification,
+                 plate: Optional[str] = None, well: Optional[str] = None) \
+            -> 'StrandBuilder[StrandLabel, DomainLabel]':
+        """
+        Gives :any:`IDTFields` value to :any:`Strand` being built.
+        Only a name is required; other fields are given reasonable default values.
+
+        :param scale:
+            see :py:data:`IDTFields.scale`
+        :param purification:
+            see :py:data:`IDTFields.purification`
+        :param plate:
+            see :py:data:`IDTFields.plate`
+        :param well:
+            see :py:data:`IDTFields.well`
+        :return: self
+        """
+        if self._strand is None:
+            raise ValueError('no Strand created yet; make at least one domain first')
+        self._strand.idt = IDTFields(scale=scale, purification=purification,
+                                     plate=plate, well=well)
+        return self
+
+    # remove quotes when Py3.6 support dropped
+    def with_modification_5p(self, mod: Modification5Prime) -> 'StrandBuilder[StrandLabel, DomainLabel]':
+        """
+        Sets Strand being built to have given 5' modification.
+
+        :param mod: 5' modification
+        :return: self
+        """
+        if self._strand is None:
+            raise ValueError('no Strand created yet; make at least one domain first')
+        self._strand.set_modification_5p(mod)
+        return self
+
+    # remove quotes when Py3.6 support dropped
+    def with_modification_3p(self, mod: Modification3Prime) -> 'StrandBuilder[StrandLabel, DomainLabel]':
+        """
+        Sets Strand being built to have given 3' modification.
+
+        :param mod: 3' modification
+        :return: self
+        """
+        if self._strand is None:
+            raise ValueError('no Strand created yet; make at least one domain first')
+        self._strand.set_modification_3p(mod)
+        return self
+
+    # remove quotes when Py3.6 support dropped
+    def with_modification_internal(self, idx: int, mod: ModificationInternal, warn_on_no_dna: bool) \
+            -> 'StrandBuilder[StrandLabel, DomainLabel]':
+        """
+        Sets Strand being built to have given internal modification.
+
+        :param idx: idx along DNA sequence of internal modification
+        :param mod: internal modification
+        :param warn_on_no_dna: whether to print warning to screen if DNA has not been assigned
+        :return: self
+        """
+        if self._strand is None:
+            raise ValueError('no Strand created yet; make at least one domain first')
+        self._strand.set_modification_internal(idx, mod, warn_on_no_dna)
+        return self
+
+    # remove quotes when Py3.6 support dropped
+    def with_color(self, color: Color) -> 'StrandBuilder[StrandLabel, DomainLabel]':
+        """
+        Sets Strand being built to have given color.
+
+        :param color: color to set for Strand
+        :return: self
+        """
+        if self._strand is None:
+            raise ValueError('no Strand created yet; make at least one domain first')
+        self._strand.set_color(color)
+        return self
+
+    # remove quotes when Py3.6 support dropped
+    def with_sequence(self, sequence: str, assign_complement: bool = True) \
+            -> 'StrandBuilder[StrandLabel, DomainLabel]':
+        """
+        Assigns `sequence` as DNA sequence of the :any:`Strand` being built.
+        This should be done after the :any:`Strand`'s structure is done being built, e.g.,
+
+        .. code-block:: Python
+
+            design.strand(0, 0).to(10).cross(1).to(5).with_sequence('AAAAAAAAAACGCGC')
+
+        :param sequence: the DNA sequence to assign to the :any:`Strand`
+        :param assign_complement: whether to automatically assign the complement to existing :any:`Strand`'s
+            bound to this :any:`Strand`. This has the same meaning as the parameter `assign_complement` in
+            :py:meth:`Design.assign_dna`.
+        :return: self
+        """
+        if self._strand is None:
+            raise ValueError('no Strand created yet; make at least one domain first')
+        self.design.assign_dna(strand=self._strand, sequence=sequence, assign_complement=assign_complement)
+        return self
+
+    # remove quotes when Py3.6 support dropped
+    def with_domain_sequence(self, sequence: str, assign_complement: bool = True) \
+            -> 'StrandBuilder[StrandLabel, DomainLabel]':
+        """
+        Assigns `sequence` as DNA sequence of the most recently created :any:`Domain` in
+        the :any:`Strand` being built. This should be called immediately after a :any:`Domain` is created
+        via a call to
+        :py:meth:`StrandBuilder.to`,
+        :py:meth:`StrandBuilder.update_to`,
+        or
+        :py:meth:`StrandBuilder.loopout`, e.g.,
+
+        .. code-block:: Python
+
+            design.strand(0, 5).to(8).with_domain_sequence('AAA')\\
+                .cross(1).to(5).with_domain_sequence('TTT')\\
+                .loopout(2, 4).with_domain_sequence('CCCC')\\
+                .to(10).with_domain_sequence('GGGGG')
+
+        :param sequence: the DNA sequence to assign to the :any:`Domain`
+        :param assign_complement: whether to automatically assign the complement to existing :any:`Strand`'s
+            bound to this :any:`Strand`. This has the same meaning as the parameter `assign_complement` in
+            :py:meth:`Design.assign_dna`.
+        :return: self
+        """
+        if self._strand is None:
+            raise ValueError('no Strand created yet; make at least one domain first')
+        last_domain = self._strand.domains[-1]
+        self.design.assign_dna(strand=self._strand, sequence=sequence, domain=last_domain,
+                               assign_complement=assign_complement)
+        return self
+
+    # remove quotes when Py3.6 support dropped
+    def with_name(self, name: str) -> 'StrandBuilder[StrandLabel, DomainLabel]':
+        """
+        Assigns `name` as name of the :any:`Strand` being built.
+
+        .. code-block:: Python
+
+            design.strand(0, 0).to(10).cross(1).to(5).with_name('scaffold')
+
+        :param name: name to assign to the :any:`Strand`
+        :return: self
+        """
+        if self._strand is None:
+            raise ValueError('no Strand created yet; make at least one domain first')
+        self._strand.set_name(name)
+        return self
+
+    # remove quotes when Py3.6 support dropped
+    def with_label(self, label: StrandLabel) -> 'StrandBuilder[StrandLabel, DomainLabel]':
+        """
+        Assigns `label` as label of the :any:`Strand` being built.
+
+        .. code-block:: Python
+
+            design.strand(0, 0).to(10).cross(1).to(5).with_label('scaffold')
+
+        :param label: label to assign to the :any:`Strand`
+        :return: self
+        """
+        if self._strand is None:
+            raise AssertionError('_strand cannot be None')
+        self._strand.set_label(label)
+        return self
+
+    # remove quotes when Py3.6 support dropped
+    def with_domain_name(self, name: str) -> 'StrandBuilder[StrandLabel, DomainLabel]':
+        """
+        Assigns `name` as of the most recently created :any:`Domain` or :any:`Loopout` in
+        the :any:`Strand` being built. This should be called immediately after a :any:`Domain` is created
+        via a call to
+        :py:meth:`StrandBuilder.to`,
+        :py:meth:`StrandBuilder.update_to`,
+        or
+        :py:meth:`StrandBuilder.loopout`, e.g.,
+
+        .. code-block:: Python
+
+            design.strand(0, 0).to(10).with_domain_name('dom1*').cross(1).to(5).with_domain_name('dom1')
+
+        :param name: name to assign to the most recently created :any:`Domain` or :any:`Loopout`
+        :return: self
+        """
+        if self._strand is None:
+            raise ValueError('no Strand created yet; make at least one domain first')
+        last_domain = self._strand.domains[-1]
+        last_domain.set_name(name)
+        return self
+
+    # remove quotes when Py3.6 support dropped
+    def with_domain_label(self, label: DomainLabel) -> 'StrandBuilder[StrandLabel, DomainLabel]':
+        """
+        Assigns `label` as label of the most recently created :any:`Domain` or :any:`Loopout` in
+        the :any:`Strand` being built. This should be called immediately after a :any:`Domain` is created
+        via a call to
+        :py:meth:`StrandBuilder.to`,
+        :py:meth:`StrandBuilder.update_to`,
+        or
+        :py:meth:`StrandBuilder.loopout`, e.g.,
+
+        .. code-block:: Python
+
+            design.strand(0, 5).to(8).with_domain_label('domain 1')\\
+                .cross(1).to(5).with_domain_label('domain 2')\\
+                .loopout(2, 4).with_domain_label('domain 3')\\
+                .to(10).with_domain_label('domain 4')
+
+        :param label: label to assign to the :any:`Domain` or :any:`Loopout`
+        :return: self
+        """
+        if self._strand is None:
+            raise ValueError('no Strand created yet; make at least one domain first')
+        last_domain = self._strand.domains[-1]
+        last_domain.set_label(label)
+        return self
+
+
+@dataclass
+class Strand(_JSONSerializable, Generic[StrandLabel, DomainLabel]):
+    """
+    Represents a single strand of DNA.
+
+    Each maximal portion that is continguous on a single :any:`Helix` is a :any:`Domain`.
+    Crossovers from one :any:`Helix` to another are implicitly from the 3' end of one of this
+    Strand's :any:`Domain`'s to the 5' end of the next :any:`Domain`.
+
+    A portion of the :any:`Strand` not associated to any :any:`Helix` is represented by a :any:`Loopout`.
+    Two :any:`Loopout`'s cannot occur consecutively on a :any:`Strand`, nor can a :any:`Strand`
+    contain only a :any:`Loopout` but no :any:`Domain`.
+
+
+    One can set the strand to be a scaffold in the constructor:
+
+    .. code-block:: Python
+
+        import scadnano as sc
+
+        scaffold_domains = [ ... ]
+        scaffold_strand = sc.Strand(domains=scaffold_domains, is_scaffold=True)
+
+    or by calling :py:meth:`Strand.set_scaffold` on the :any:`Strand` object:
+
+    .. code-block:: Python
+
+        import scadnano as sc
+
+        scaffold_domains = [ ... ]
+        scaffold_strand = sc.Strand(domains=scaffold_domains)
+        scaffold_strand.set_scaffold()
+
+    Both will give the strand the same color that
+    `cadnano <https://cadnano.org>`_
+    uses for the scaffold.
+    """
+
+    domains: List[Union[Domain[DomainLabel], Loopout[DomainLabel]]]
+    """:any:`Domain`'s (or :any:`Loopout`'s) composing this Strand. 
+    Each :any:`Domain` is contiguous on a single :any:`Helix` 
+    and could be either single-stranded or double-stranded, 
+    whereas each :any:`Loopout` is single-stranded and has no associated :any:`Helix`."""
+
+    circular: bool = False
+    """If True, this :any:`Strand` is circular and has no 5' or 3' end. Although there is still a 
+    first and last :any:`Domain`, we interpret there to be a crossover from the 3' end of the last domain
+    to the 5' end of the first domain, and any circular permutation of :py:data:`Strand.domains` 
+    should result in a functionally equivalent :any:`Strand`. It is illegal to have a 
+    :any:`Modification5Prime` or :any:`Modification3Prime` on a circular :any:`Strand`."""
+
+    dna_sequence: Optional[str] = None
+    """Do not assign directly to this field. Always use :any:`Design.assign_dna` 
+    (for complementarity checking) or :any:`Strand.set_dna_sequence` 
+    (without complementarity checking, to allow mismatches)."""
+
+    color: Optional[Color] = None
+    """Color to show this strand in the main view. If not specified in the constructor,
+    a color is assigned by cycling through a list of defaults given by 
+    :meth:`ColorCycler.colors`"""
+
+    idt: Optional[IDTFields] = None
+    """Fields used when ordering strands from the synthesis company IDT 
+    (Integrated DNA Technologies, Coralville, IA). If present (i.e., not equal to :const:`None`)
+    then the method :py:meth:`Design.write_idt_bulk_input_file` can be called to automatically
+    generate an text file for ordering strands in test tubes: 
+    https://www.idtdna.com/site/order/oligoentry,
+    as can the method :py:meth:`Design.write_idt_plate_excel_file` for writing a Microsoft Excel 
+    file that can be uploaded to IDT's website for describing DNA sequences to be ordered in 96-well
+    or 384-well plates."""
+
+    is_scaffold: bool = False
+    """Indicates whether this :any:`Strand` is a scaffold for a DNA origami. If any :any:`Strand` in a
+    :any:`Design` is a scaffold, then the design is considered a DNA origami design."""
+
+    modification_5p: Optional[Modification5Prime] = None
+    """
+    5' modification; None if there is no 5' modification. 
+    Illegal to have if :py:data:`Strand.circular` is True.
+    """
+
+    modification_3p: Optional[Modification3Prime] = None
+    """
+    3' modification; None if there is no 3' modification. 
+    Illegal to have if :py:data:`Strand.circular` is True.
+    """
+
+    modifications_int: Dict[int, ModificationInternal] = field(default_factory=dict)
+    """:any:`Modification`'s to the DNA sequence (e.g., biotin, Cy3/Cy5 fluorphores). Maps offset to 
+    modification. If the internal modification is attached to a base 
+    (e.g., internal biotin, /iBiodT/ from IDT), 
+    then the offset is that of the base.
+    If it goes between two bases 
+    (e.g., internal Cy3, /iCy3/ from IDT),
+    then the offset is that of the previous base, 
+    e.g., to put a Cy3 between bases at offsets 3 and 4, the offset should be 3. 
+    So for an internal modified base on a sequence of length n, the allowed offsets are 0,...,n-1,
+    and for an internal modification that goes between bases, the allowed offsets are 0,...,n-2."""
+
+    name: Optional[str] = None
+    """
+    Optional name to give the strand. If specified it is shown on mouseover in the scadnano web interface.
+    
+    This is used to interoperate with the dsd DNA sequence design package.
+    """
+
+    label: Optional[StrandLabel] = None
+    """Generic "label" object to associate to this :any:`Strand`.
+    
+    Useful for associating extra information with the Strand that will be serialized, for example,
+    for DNA sequence design. It must be an object (e.g., a dict or primitive type such as str or int) 
+    that is naturally JSON serializable. (Calling ``json.dumps`` on the object should succeed without
+    having to specify a custom encoder.)
+    """
+
+    # not serialized; efficient way to see a list of all domains on a given helix
+    _helix_idx_domain_map: Dict[int, List[Domain[DomainLabel]]] = field(
+        init=False, repr=False, compare=False, default_factory=dict)
+
+    def __post_init__(self) -> None:
+        self._helix_idx_domain_map = defaultdict(list)
+
+        self.set_domains(self.domains)
+
+        self._ensure_modifications_legal()
+        self._ensure_domains_nonoverlapping()
+
+    def to_json_serializable(self, suppress_indent: bool = True, **kwargs: Any) -> Dict[str, Any]:
+        dct: Dict[str, Any] = OrderedDict()
+        if self.name is not None:
+            dct[strand_name_key] = self.name
+        if self.circular:
+            dct[circular_key] = self.circular
+        if self.color is not None:
+            dct[color_key] = self.color.to_json_serializable(suppress_indent)
+        if self.dna_sequence is not None:
+            dct[dna_sequence_key] = self.dna_sequence
+        if self.idt is not None:
+            dct[idt_key] = self.idt.to_json_serializable(suppress_indent)
+        dct[domains_key] = [domain.to_json_serializable(suppress_indent) for domain in self.domains]
+        if hasattr(self, is_scaffold_key) and self.is_scaffold:
+            dct[is_scaffold_key] = self.is_scaffold
+
+        if self.modification_5p is not None:
+            dct[modification_5p_key] = self.modification_5p.id
+        if self.modification_3p is not None:
+            dct[modification_3p_key] = self.modification_3p.id
+        if len(self.modifications_int) > 0:
+            mods_dict = {}
+            for offset, mod in self.modifications_int.items():
+                mods_dict[f"{offset}"] = mod.id
+            dct[modifications_int_key] = NoIndent(mods_dict) if suppress_indent else mods_dict
+
+        if self.label is not None:
+            dct[strand_label_key] = NoIndent(self.label) if suppress_indent else self.label
+
+        return dct
+
+    @staticmethod
+    def from_json(json_map: dict) -> 'Strand':  # remove quotes when Py3.6 support dropped
+        domain_jsons = mandatory_field(Strand, json_map, domains_key, legacy_keys=legacy_domains_keys)
+        if len(domain_jsons) == 0:
+            raise IllegalDesignError(f'{domains_key} list cannot be empty')
+
+        domains: List[Union[Domain, Loopout]] = []
+        for domain_json in domain_jsons:
+            if loopout_key in domain_json:
+                domains.append(Loopout.from_json(domain_json))
+            else:
+                domains.append(Domain.from_json(domain_json))
+        if isinstance(domains[0], Loopout):
+            raise IllegalDesignError('Loopout at beginning of Strand not supported')
+        if isinstance(domains[-1], Loopout):
+            raise IllegalDesignError('Loopout at end of Strand not supported')
+
+        is_scaffold = json_map.get(is_scaffold_key, False)
+        circular = json_map.get(circular_key, False)
+
+        dna_sequence = optional_field(None, json_map, dna_sequence_key, legacy_keys=legacy_dna_sequence_keys)
+
+        color_str = json_map.get(color_key,
+                                 default_scaffold_color if is_scaffold else default_strand_color)
+        if isinstance(color_str, int):
+            def decimal_int_to_hex(d: int) -> str:
+                return "#" + "{0:#08x}".format(d, 8)[2:]  # type: ignore
+
+            color_str = decimal_int_to_hex(color_str)
+        color = Color(hex_string=color_str)
+
+        label = json_map.get(strand_label_key)
+
+        name = json_map.get(strand_name_key)
+
+        idt_dict: Optional[dict] = json_map.get(idt_key)
+        idt = None if idt_dict is None else IDTFields.from_json(idt_dict)
+        # legacy:
+        # if no name is specified, but there's a name field in idt, then use that as the Strand's name
+        if name is None and idt_dict is not None and idt_name_key in idt_dict:
+            name = idt_dict[idt_name_key]
+
+        return Strand(
+            domains=domains,
+            dna_sequence=dna_sequence,
+            circular=circular,
+            color=color,
+            idt=idt,
+            is_scaffold=is_scaffold,
+            name=name,
+            label=label,
+        )
+
+    def __eq__(self, other: Any) -> bool:  # remove quotes when Py3.6 support dropped
+        if not isinstance(other, Strand):
+            return False
+        return self.domains == other.domains
+
+    def __hash__(self) -> int:
+        return hash(self.domains)
+
+    def __str__(self) -> str:
+        return repr(self) if self.name is None else self.name
+
+    def rotate_domains(self, rotation: int, forward: bool = True) -> None:
+        """
+        "Rotates" the strand by replacing domains with a circular rotation, e.g., if the domains are
+
+        A, B, C, D, E, F
+
+        then ``strand.rotate_domains(2)`` makes the :any:`Strand` have the same domains, but in this order:
+        
+        E, F, A, B, C, D
+        
+        and  ``strand.rotate_domains(2, forward=False)`` makes
+        
+        C, D, E, F, A, B
+
+        :param rotation:
+            Amount to rotate domains.
+        :param forward:
+            Whether to move domains forward (wrapping off 3' end back to 5' end) or backward (wrapping off
+            5' end back to 3' end).
+        """
+        idx = rotation if not forward else len(self.domains) - rotation
+        self.domains = self.domains[idx:] + self.domains[:idx]
+
+    def set_scaffold(self, is_scaf: bool = True) -> None:
+        """Sets this :any:`Strand` as a scaffold. Alters color to default scaffold color.
+
+        If `is_scaf` == ``False``, sets this strand as not a scaffold, and leaves the color alone."""
+        self.is_scaffold = is_scaf
+        if is_scaf:
+            self.color = default_scaffold_color
+
+    def set_name(self, name: str) -> None:
+        """Sets name of this :any:`Strand`."""
+        self.name = name
+
+    def set_label(self, label: Any) -> None:
+        """Sets label of this :any:`Strand`."""
+        self.label = label
+
+    def set_color(self, color: Color) -> None:
+        """Sets color of this :any:`Strand`."""
+        self.color = color
+
+    def set_circular(self, circular: bool = True) -> None:
+        """
+        Sets this to be a circular :any:`Strand` (or non-circular if optional parameter is False).
+
+        :param circular:
+            whether to make this :any:`Strand` circular (True) or linear (False)
+        :raises StrandError:
+            if this :any:`Strand` has a 5' or 3' modification
+        """
+        if circular and self.modification_5p is not None:
+            raise StrandError(self, "cannot have a 5' modification on a circular strand")
+        if circular and self.modification_3p is not None:
+            raise StrandError(self, "cannot have a 3' modification on a circular strand")
+        self.circular = circular
+
+    def set_linear(self) -> None:
+        """
+        Makes this a linear (non-circular) :any:`Strand`. Equivalent to calling
+        `self.set_circular(False)`.
+        """
+        self.set_circular(False)
+
+    def set_domains(self, domains: Iterable[Union[Domain[DomainLabel], Loopout[DomainLabel]]]) -> None:
+        """
+        Sets the :any:`Domain`'s/:any:`Loopout`'s of this :any:`Strand` to be `domains`,
+        which can contain a mix of :any:`Domain`'s and :any:`Loopout`'s,
+        just like the field :py:data:`Strand.domains`.
+
+        :param domains:
+            The new sequence of :any:`Domain`'s/:any:`Loopout`'s to use for this :any:`Strand`.
+        :raises StrandError:
+            if domains has two consecutive :any:`Loopout`'s, consists of just a single :any:`Loopout`'s,
+            or starts or ends with a :any:`Loopout`
+        """
+        self.domains = domains if isinstance(domains, list) else list(domains)
+
+        for domain in self.domains:
+            if isinstance(domain, Domain):
+                self._helix_idx_domain_map[domain.helix].append(domain)
+
+        for domain in self.domains:
+            domain._parent_strand = self
+
+        if len(self.domains) == 1:
+            if isinstance(self.domains[0], Loopout):
+                raise StrandError(self, 'strand cannot have a single Loopout as its only domain')
+
+        if len(self.domains) == 0:
+            raise StrandError(self, 'domains cannot be empty')
+
+        for domain1, domain2 in _pairwise(self.domains):
+            if isinstance(domain1, Loopout) and isinstance(domain2, Loopout):
+                raise StrandError(self, 'cannot have two consecutive Loopouts in a strand')
+
+        if isinstance(self.domains[0], Loopout):
+            raise StrandError(self, 'strand cannot begin with a loopout')
+
+        if isinstance(self.domains[-1], Loopout):
+            raise StrandError(self, 'strand cannot end with a loopout')
+
+    def idt_export_name(self, unique_names: bool = False) -> str:
+        """
+        :param unique_names:
+            If True and default name is used,
+            enforces that strand names must be unique by encoding the forward/reverse Boolean
+            into the name.
+            If False (the default), uses cadnano's exact naming convention, which allows two strands
+            to have the same default name, if they begin and end at the same (helix,offset) pair (but
+            point in opposite directions at each).
+            Has no effect if :py:data:`Strand.idt` or :py:data:`Strand.name` are defined;
+            if those are used, they must be explicitly set to be unique.
+        :return:
+            If :py:data:`Strand.name` is not None, return :py:data:`Strand.name`,
+            otherwise return the result of :py:meth:`Strand.default_export_name`
+            with parameter `unique_names`.
+        """
+        return self.name if self.name is not None else self.default_export_name(unique_names)
+
+    def default_export_name(self, unique_names: bool = False) -> str:
+        """
+        Returns a default name to use when exporting the DNA sequence.
+        Uses cadnano's naming
+        convention of, for example `'ST2[5]4[10]'` to indicate a strand that starts at helix 2, offset 5,
+        and ends at helix 4, offset 10. Note that this naming convention is not unique: two strands in
+        the system could share this name. To ensure it is unique, set the parameter `unique_names` to True,
+        which will modify the name with forward/reverse information from the first domain that uniquely
+        identifies the strand, e.g., `'ST2[5]F4[10]'` or `'ST2[5]R4[10]'`.
+
+        If the strand is a scaffold (i.e., if :py:data:`Strand.is_scaffold` is True),
+        then the name will begin with `'SCAF'` instead of `'ST'`.
+
+        :param unique_names:
+            If True, enforces that strand names must be unique by encoding the forward/reverse Boolean
+            into the name.
+            If False (the default), uses cadnano's exact naming convention, which allows two strands
+            to have the same default name, if they begin and end at the same (helix,offset) pair (but
+            point in opposite directions at each).
+        :return:
+            default name to export
+            (used, for example, by idt DNA export methods :py:meth:`Design.write_idt_plate_excel_file`
+            and :py:meth:`Design.write_idt_bulk_input_file`
+            if :py:data:`Strand.name` and :py:data:`Strand.idt.name` are both not set)
+        """
+        start_helix = self.first_bound_domain().helix
+        end_helix = self.last_bound_domain().helix
+        start_offset = self.first_bound_domain().offset_5p()
+        end_offset = self.last_bound_domain().offset_3p()
+        forward_str = 'F' if self.first_bound_domain().forward else 'R'
+        if not unique_names:
+            forward_str = ''
+        name = f'{start_helix}[{start_offset}]{forward_str}{end_helix}[{end_offset}]'
+        return f'SCAF{name}' if self.is_scaffold else f'ST{name}'
+
+    def set_modification_5p(self, mod: Modification5Prime = None) -> None:
+        """Sets 5' modification to be `mod`. `mod` cannot be non-None if :any:`Strand.circular` is True."""
+        if self.circular and mod is not None:
+            raise StrandError(self, "cannot have a 5' modification on a circular strand")
+        self.modification_5p = mod
+
+    def set_modification_3p(self, mod: Modification3Prime = None) -> None:
+        """Sets 3' modification to be `mod`. `mod` cannot be non-None if :any:`Strand.circular` is True."""
+        if self.circular and mod is not None:
+            raise StrandError(self, "cannot have a 3' modification on a circular strand")
+        self.modification_3p = mod
+
+    def remove_modification_5p(self) -> None:
+        """Removes 5' modification."""
+        self.modification_5p = None
+
+    def remove_modification_3p(self) -> None:
+        """Removes 3' modification."""
+        self.modification_3p = None
+
+    def set_modification_internal(self, idx: int, mod: ModificationInternal,
+                                  warn_on_no_dna: bool = True) -> None:
+        """Adds internal modification `mod` at given DNA index `idx`."""
+        if idx < 0:
+            raise IllegalDesignError('idx of modification must be nonnegative')
+        if idx >= self.dna_length():
+            raise IllegalDesignError(f'idx of modification must be at most length of DNA: '
+                                     f'{self.dna_length()}')
+        if self.dna_sequence is not None:
+            if mod.allowed_bases is not None and self.dna_sequence[idx] not in mod.allowed_bases:
+                raise IllegalDesignError(f'only bases {",".join(mod.allowed_bases)} are allowed at '
+                                         f'index {idx}, but sequence has base {self.dna_sequence[idx]} '
+                                         f'\nDNA sequence: {self.dna_sequence}'
+                                         f'\nmodification: {mod}')
+        elif warn_on_no_dna:
+            print('WARNING: no DNA sequence has been assigned, so certain error checks on the internal '
+                  'modification were not done. To be safe, first assign DNA, then add the modifications.')
+        self.modifications_int[idx] = mod
+
+    def remove_modification_internal(self, idx: int) -> None:
+        """Removes internal modification at given DNA index `idx`."""
+        if idx in self.modifications_int:
+            del self.modifications_int[idx]
+
+    def first_domain(self) -> Domain:
+        """First domain on this :any:`Strand`."""
+        domain = self.domains[0]
+        if isinstance(domain, Loopout):
+            raise StrandError(self, 'cannot have loopout as first domain on strand')
+        return domain
+
+    def last_domain(self) -> Domain:
+        """Last domain on this :any:`Strand`."""
+        domain = self.domains[-1]
+        if isinstance(domain, Loopout):
+            raise StrandError(self, 'cannot have loopout as last domain on strand')
+        return domain
+
+    def set_dna_sequence(self, sequence: str) -> None:
+        """Set this :any:`Strand`'s DNA sequence to `seq`
+        WITHOUT checking for complementarity with overlapping
+        :any:`Strand`'s or automatically assigning their sequences.
+        To assign a sequence to a :any:`Strand` and have the overlapping
+        :any:`Strand`'s automatically have the appropriate Watson-Crick complements assigned,
+        use :any:`Design.assign_dna`.
+
+        All whitespace in `sequence` is removed,
+        and lowercase bases 'a', 'c', 'g', 't' are converted to uppercase.
+
+        `sequence`, after all whitespace is removed, must be exactly the same length as
+        :py:meth:`Strand.dna_length`.
+        Wildcard symbols (:py:const:`DNA_case_wildcard`) are allowed to leave part of the DNA unassigned.
+        """
+        trimmed_seq = _remove_whitespace_and_uppercase(sequence)
+        if len(trimmed_seq) != self.dna_length():
+            domain = self.first_domain()
+            raise StrandError(self, f"strand starting at helix {domain.helix} offset {domain.offset_5p()} "
+                                    f"has length {self.dna_length()}, but you attempted to assign a "
+                                    f"DNA sequence of length {len(trimmed_seq)}: {sequence}")
+        self.dna_sequence = trimmed_seq
+
+    def dna_length(self) -> int:
+        """Return sum of DNA length of :any:`Domain`'s and :any:`Loopout`'s of this :any:`Strand`."""
+        acc = 0
+        for domain in self.domains:
+            acc += domain.dna_length()
+        return acc
+
+    def bound_domains(self) -> List[Domain]:
+        """:any:`Domain`'s of this :any:`Strand` that are not :any:`Loopout`'s."""
+        return [domain for domain in self.domains if isinstance(domain, Domain)]
+
+    def offset_5p(self) -> int:
+        """5' offset of this entire :any:`Strand`, INCLUSIVE."""
+        return self.first_domain().offset_5p()
+
+    def offset_3p(self) -> int:
+        """3' offset of this entire :any:`Strand`, INCLUSIVE."""
+        return self.last_domain().offset_3p()
+
+    def overlaps(self, other: 'Strand') -> bool:  # remove quotes when Py3.6 support dropped
+        """Indicates whether `self` overlaps `other_strand`, meaning that the set of offsets occupied
+        by `self` has nonempty intersection with those occupied by `other_strand`."""
+        for domain_self in self.bound_domains():
+            for domain_other in other.bound_domains():
+                if domain_self.overlaps(domain_other):
+                    return True
+        return False
+
+    def assign_dna_complement_from(self, other: 'Strand') -> None:  # remove quotes when Py3.6 support dropped
+        """Assuming a DNA sequence has been assigned to `other`, assign its Watson-Crick
+        complement to the portions of this Strand that are bound to `other`.
+
+        Generally this is not called directly; use :py:meth:`Design.assign_dna` to assign
+        a DNA sequence to a :any:`Strand`. The method :py:meth:`Design.assign_dna` will calculate
+        which other :any:`Strand`'s need
+        to be assigned via :py:meth:`Strand.assign_dna_complement_from`.
+
+        However, it is permitted to assign the field :py:data:`Strand.dna_sequence` directly
+        via the method :py:meth:`Strand.set_dna_sequence`.
+        This is used, for instance, to assign a DNA sequence to a :any:`Strand` bound to another
+        :any:`Strand`
+        with an assigned DNA sequence where they overlap. In this case no error checking
+        about sequence complementarity is done. This can be used to intentionally assign *mismatching*
+        DNA sequences to :any:`Strand`'s that are bound on a :any:`Helix`."""
+
+        already_assigned = self.dna_sequence is not None
+
+        # put DNA sequences to assign to domains in List, one position per domain
+        strand_complement_builder: List[str] = []
+        if already_assigned:
+            for domain in self.domains:
+                domain_seq = domain.dna_sequence()
+                if domain_seq is None:
+                    raise ValueError(f'no DNA sequence has been assigned to {self}')
+                strand_complement_builder.append(domain_seq)
+        else:
+            for domain in self.domains:
+                wildcards = DNA_base_wildcard * domain.dna_length()
+                strand_complement_builder.append(wildcards)
+
+        for (domain_idx, domain_self) in enumerate(self.domains):
+            if isinstance(domain_self, Loopout):
+                domain_self_dna_sequence = DNA_base_wildcard * domain_self.dna_length()
+            else:
+                helix = domain_self.helix
+
+                # for helix, domains_on_helix_self in self._helix_idx_domain_map.items():
+                domains_on_helix_other = other._helix_idx_domain_map[helix]
+                # for domain_self in domains_on_helix_self:
+                overlaps = []
+                for domain_other in domains_on_helix_other:
+                    if domain_self != domain_other and domain_self.overlaps(domain_other):
+                        overlap = domain_self.compute_overlap(domain_other)
+                        overlaps.append((overlap, domain_other))
+
+                overlaps.sort()
+
+                domain_complement_builder = []
+                start_idx = domain_self.start
+                # repeatedly insert wildcards into gaps, then reverse WC complement
+                for ((overlap_left, overlap_right), domain_other) in overlaps:
+                    # wildcards = DNA_base_wildcard * (overlap_left - start_idx)
+                    num_wildcard_bases = domain_self.dna_length_in(start_idx, overlap_left - 1)
+                    wildcards = DNA_base_wildcard * num_wildcard_bases
+
+                    other_seq = domain_other.dna_sequence_in(overlap_left, overlap_right - 1)
+                    if other_seq is None:
+                        raise ValueError(f'no DNA sequence has been assigned to strand {other}')
+                    overlap_complement = wc(other_seq)
+                    domain_complement_builder.append(wildcards)
+                    domain_complement_builder.append(overlap_complement)
+                    start_idx = overlap_right
+
+                # last wildcard for gap between last overlap and end
+                # last_wildcards = DNA_base_wildcard * (domain_self.end - start_idx)
+                num_wildcard_bases = domain_self.dna_length_in(start_idx, domain_self.end - 1)
+                last_wildcards = DNA_base_wildcard * num_wildcard_bases
+
+                domain_complement_builder.append(last_wildcards)
+
+                # If pointing left, each individual overlap sequence was reverse orientation in wc(),
+                # but not the list of all of them put together until now.
+                if not domain_self.forward:
+                    domain_complement_builder.reverse()
+
+                domain_self_dna_sequence = ''.join(domain_complement_builder)
+
+            # merge with existing pre-assigned sequence
+            existing_domain_self_dna_sequence = strand_complement_builder[domain_idx]
+            merged_domain_self_dna_sequence = _string_merge_wildcard(domain_self_dna_sequence,
+                                                                     existing_domain_self_dna_sequence,
+                                                                     DNA_base_wildcard)
+            strand_complement_builder[domain_idx] = merged_domain_self_dna_sequence
+
+        strand_complement = ''.join(strand_complement_builder)
+        new_dna_sequence = strand_complement
+        if self.dna_sequence is not None:
+            try:
+                new_dna_sequence = _string_merge_wildcard(self.dna_sequence, new_dna_sequence,
+                                                          DNA_base_wildcard)
+            except ValueError:
+                domain_self = self.first_domain()
+                domain_other = other.first_domain()
+                msg = f'strand starting at helix {domain_self.helix}, offset {domain_self.offset_5p()} ' \
+                      f'has length ' \
+                      f'{self.dna_length()} and already has a partial DNA sequence assignment of length ' \
+                      f'{len(self.dna_sequence)}, which is \n' \
+                      f'{self.dna_sequence}, ' \
+                      f'but you tried to assign sequence of length {len(new_dna_sequence)} to it, which ' \
+                      f'is\n{new_dna_sequence} (this assignment was indirect, since you assigned directly ' \
+                      f'to a strand bound to this one). This occurred while directly assigning a DNA ' \
+                      f'sequence to the strand whose 5\' end is at helix {domain_other.helix}, and is of ' \
+                      f'length {other.dna_length()}.'
+                raise IllegalDesignError(msg)
+
+        self.set_dna_sequence(new_dna_sequence)
+        # self.dna_sequence = _pad_dna(new_dna_sequence, self.dna_length())
+
+    def insert_domain(self, order: int, domain: Union[Domain, Loopout]) -> None:
+        # Only intended to be called by Design.insert_domain
+        self.domains.insert(order, domain)
+        domain._parent_strand = self
+        if isinstance(domain, Domain):
+            self._helix_idx_domain_map[domain.helix].append(domain)
+
+        # add wildcard symbols to DNA sequence to maintain its length
+        if self.dna_sequence is not None:
+            start_idx = self.dna_index_start_domain(domain)
+            end_idx = start_idx + domain.dna_length()
+            prefix = self.dna_sequence[:start_idx]
+            suffix = self.dna_sequence[start_idx:]
+            new_wildcards = DNA_base_wildcard * (end_idx - start_idx)
+            self.dna_sequence = prefix + new_wildcards + suffix
+
+    def remove_domain(self, domain: Union[Domain, Loopout]) -> None:
+        # Only intended to be called by Design.remove_domain
+
+        # remove relevant portion of DNA sequence to maintain its length
+        if self.dna_sequence is not None:
+            start_idx = self.dna_index_start_domain(domain)
+            end_idx = start_idx + domain.dna_length()
+            prefix = self.dna_sequence[:start_idx]
+            suffix = self.dna_sequence[end_idx:]
+            self.dna_sequence = prefix + suffix
+
+        self.domains.remove(domain)
+        domain._parent_strand = None
+        if isinstance(domain, Domain):
+            self._helix_idx_domain_map[domain.helix].remove(domain)
+
+    def dna_index_start_domain(self, domain: Union[Domain, Loopout]) -> int:
+        """
+        Returns index in DNA sequence of domain, e.g., if there are five domains
+
+        012 3 45 678 9
+        AAA-C-GG-TTT-ACGT
+
+        Then their indices, respectively in order, are 0, 3, 4, 6, 9.
+
+        :param domain: :any: to find the start DNA index of
+        :return: index (within DNA sequence string) of substring of DNA starting with given :any:`Domain`
+        """
+        domain_order = self.domains.index(domain)
+        idx = sum(self.domains[i].dna_length() for i in range(domain_order))
+        return idx
+
+    def contains_loopouts(self) -> bool:
+        for domain in self.domains:
+            if isinstance(domain, Loopout):
+                return True
+        return False
+
+    def first_bound_domain(self) -> Domain:
+        """First :any:`Domain` (i.e., not a :any:`Loopout`) on this :any:`Strand`.
+
+        Currently the first and last strand must not be :any:`Loopout`'s, so this should return the same
+        domain as :py:meth:`Strand.first_domain`, but in case an initial or final :any:`Loopout` is
+        supported in the future, this method is provided."""
+        for domain in self.domains:
+            if isinstance(domain, Domain):
+                return domain
+        raise StrandError(self, 'should not be able to have a Strand with no (bound) Domains')
+
+    def last_bound_domain(self) -> Domain:
+        """Last :any:`Domain` (i.e., not a :any:`Loopout`) on this :any:`Strand`.
+
+        Currently the first and last strand must not be :any:`Loopout`'s, so this should return the same
+        domain as :py:meth:`Strand.first_domain`, but in case an initial or final :any:`Loopout` is
+        supported in the future, this method is provided."""
+        domain_rev = list(self.domains)
+        domain_rev.reverse()
+        for domain in domain_rev:
+            if isinstance(domain, Domain):
+                return domain
+        raise AssertionError('should not be able to have a Strand with no (bound) Domains')
+
+    def reverse(self) -> None:
+        """
+        Reverses "polarity" of this :any:`Strand`.
+
+        Does NOT check whether this keeps the :any:`Design` legal, so be cautious in calling this method
+        directly. To reverse every :any:`Strand`, called :py:meth:`Design.reverse_all`.
+        If the design was legal before, it will be legal after calling that method.
+        """
+        self.domains.reverse()
+        for domain in self.bound_domains():
+            domain.forward = not domain.forward
+
+    def _ensure_modifications_legal(self, check_offsets_legal: bool = False) -> None:
+        if check_offsets_legal:
+            if self.dna_sequence is None:
+                raise IllegalDesignError(f"must assign DNA sequence first")
+            mod_i_offsets_list = list(self.modifications_int.keys())
+            min_offset = min(mod_i_offsets_list) if len(mod_i_offsets_list) > 0 else None
+            max_offset = max(mod_i_offsets_list) if len(mod_i_offsets_list) > 0 else None
+            if min_offset is not None and min_offset < 0:
+                raise IllegalDesignError(f"smallest offset is {min_offset} but must be nonnegative: "
+                                         f"{self.modifications_int}")
+            if max_offset is not None and max_offset > len(self.dna_sequence):
+                raise IllegalDesignError(f"largeest offset is {max_offset} but must be at most "
+                                         f"{len(self.dna_sequence)}: "
+                                         f"{self.modifications_int}")
+
+    def _ensure_domains_nonoverlapping(self) -> None:
+        for d1, d2 in itertools.combinations(self.domains, 2):
+            if isinstance(d1, Domain) and isinstance(d2, Domain) and d1.overlaps_illegally(d2):
+                raise StrandError(self, f'two domains on strand overlap:'
+                                        f'\n{d1}'
+                                        f'\n{d2}')
+
+    def idt_dna_sequence(self) -> str:
+        """
+        :return: DNA sequence as it needs to be typed to order from IDT, with
+            :py:data:`Modification5Prime`'s,
+            :py:data:`Modification3Prime`'s,
+            and
+            :py:data:`ModificationInternal`'s represented with text codes, e.g., "/5Biosg/ACGT" for sequence
+            ACGT with a 5' biotin modification.
+        """
+        self._ensure_modifications_legal(check_offsets_legal=True)
+
+        if self.dna_sequence is None:
+            raise ValueError('DNA sequence has not been assigned yet')
+
+        ret_list: List[str] = []
+        if self.modification_5p is not None and self.modification_5p.idt_text is not None:
+            ret_list.append(self.modification_5p.idt_text)
+
+        for offset, base in enumerate(self.dna_sequence):
+            ret_list.append(base)
+            if offset in self.modifications_int:  # if internal mod attached to base, replace base
+                mod = self.modifications_int[offset]
+                if mod.idt_text is not None:
+                    if mod.allowed_bases is not None:
+                        if base not in mod.allowed_bases:
+                            msg = f'internal modification {mod} can only replace one of these bases: ' \
+                                  f'{",".join(mod.allowed_bases)}, but the base at offset {offset} is {base}'
+                            raise IllegalDesignError(msg)
+                        ret_list[-1] = mod.idt_text  # replace base with modified base
+                    else:
+                        ret_list.append(mod.idt_text)  # append modification between two bases
+
+        if self.modification_3p is not None and self.modification_3p.idt_text is not None:
+            ret_list.append(self.modification_3p.idt_text)
+
+        return ''.join(ret_list)
+
+    def no_modifications_version(self) -> 'Strand':
+        """
+        :return: version of this :any:`Strand` with no DNA modifications.
+        """
+        strand_nomods = replace(self, modification_3p=None, modification_5p=None, modifications_int={})
+        return strand_nomods
+
+
+# for defining comparables for strongly typed sorting
+class Comparable(metaclass=ABCMeta):
+    @abstractmethod
+    def __lt__(self, other: Any) -> bool: ...
+
+
+T = TypeVar('T')
+# CT = TypeVar('CT', bound=Comparable)
+
+# KeyFunction = Callable[[T], CT]
+KeyFunction = Callable[[T], Any]
+
+
+class StrandOrder(enum.Enum):
+    """
+    Which part of a :any:`Strand` to use for sorting in the
+    `key function <https://docs.python.org/3/howto/sorting.html#key-functions>`_
+    returned by :py:meth:`strand_order_key_function`.
+    """
+    five_prime = 0
+    """5' end of the strand"""
+
+    three_prime = 1
+    """3' end of the strand"""
+
+    five_or_three_prime = 2
+    """Either 5' end or 3' end is used, whichever is first according to the sort order."""
+
+    top_left_domain = 3
+    """The start offset of the "top-left" :any:`Domain` of the :any:`Strand`: the :any:`Domain` whose
+    :py:data:`Domain.helix` is minimal, and, among all such :any:`Domain`'s, the one with 
+    minimal :py:data:`Domain.start`."""
+
+
+def strand_order_key_function(*, column_major: bool = True, strand_order: StrandOrder) -> KeyFunction[Strand]:
+    """
+    Returns a `key function <https://docs.python.org/3/howto/sorting.html#key-functions>`_
+    indicating a sorted order for :any:`Strand`'s. Useful as a parameter for
+    :py:meth:`Design.`.
+
+    :param column_major:
+        If true, column major order is used: ordered by base offset first, then by helix.
+        Otherwise row-major order is used: ordered by helix first, then by base offset.
+    :param strand_order:
+        Which part of the strand to use as a key for the sorted order.
+        See :any:`StrandOrder` for definitions.
+    :return:
+        A `key function <https://docs.python.org/3/howto/sorting.html#key-functions>`_ that can be
+        passed to :py:meth:`Design.` to specify a sorted order for the :any:`Strand`'s.
+    """
+
+    def key(strand: Strand) -> Tuple[int, int]:
+        # we'll return a tuple (helix_idx, offset) for row-major or (offset, helix_idx) for col-major.
+        helix_idx: int
+        offset: int
+
+        if strand_order == StrandOrder.five_prime:
+            helix_idx = strand.first_bound_domain().helix
+            offset = strand.first_bound_domain().offset_5p()
+        elif strand_order == StrandOrder.three_prime:
+            helix_idx = strand.last_bound_domain().helix
+            offset = strand.last_bound_domain().offset_3p()
+        elif strand_order == StrandOrder.five_or_three_prime:
+            helix_idx_5p = strand.first_bound_domain().helix
+            offset_5p = strand.first_bound_domain().offset_5p()
+            helix_idx_3p = strand.last_bound_domain().helix
+            offset_3p = strand.last_bound_domain().offset_3p()
+            if column_major:
+                offset, helix_idx = min((offset_5p, helix_idx_5p), (offset_3p, helix_idx_3p))
+            else:
+                helix_idx, offset = min((helix_idx_5p, offset_5p), (helix_idx_3p, offset_3p))
+        elif strand_order == StrandOrder.top_left_domain:
+            helix_idx = strand.first_bound_domain().helix
+            offset = strand.first_bound_domain().start
+            for domain in strand.bound_domains():
+                if (helix_idx, offset) > (domain.helix, domain.start):
+                    helix_idx, offset = domain.helix, domain.start
+        else:
+            raise ValueError(f'{strand_order} is not a valid StrandOrder')
+
+        if column_major:
+            return offset, helix_idx
+        else:
+            return helix_idx, offset
+
+    return key
+
+
+def _pad_and_remove_whitespace_and_uppercase(sequence: str, strand: Strand, start: int = 0) -> str:
+    sequence = _remove_whitespace_and_uppercase(sequence)
+    padded_sequence = _pad_dna(sequence, strand.dna_length(), start)
+    return padded_sequence
+
+
+def _remove_whitespace_and_uppercase(sequence: str) -> str:
+    sequence = re.sub(r'\s*', '', sequence)
+    sequence = sequence.upper()
+    return sequence
+
+
+def _pad_dna(sequence: str, length: int, start: int = 0) -> str:
+    """Return `sequence` modified to have length `length`.
+
+    If len(sequence) < length, pad with  :py:data:`DNA_base_wildcard`.
+
+    If len(sequence) > length, remove extra symbols, from 0 up to `start`, and at the end.
+
+    :param sequence: sequence to pad
+    :param length: final length of padded sequence
+    :param start: index at which to start padding. If not specified, defaults to 0
+    :return: padded sequence
+    """
+    if start < 0:
+        raise ValueError(f'cannot pad DNA with negative start, but start = {start}')
+    elif start >= length:
+        raise ValueError(f'cannot pad DNA with start >= length, but start = {start} and '
+                         f'length = {length}')
+    if len(sequence) > length:
+        sequence = sequence[start:start + length]
+    elif len(sequence) < length:
+        prefix = DNA_base_wildcard * start
+        suffix = DNA_base_wildcard * (length - len(sequence) - start)
+        sequence = prefix + sequence + suffix
+    return sequence
+
+
+def _string_merge_wildcard(s1: str, s2: str, wildcard: str) -> str:
+    """Takes a "union" of two equal-length strings `s1` and `s2`.
+    Whenever one has a symbol `wildcard` and the other does not, the result has the non-wildcard symbol.
+
+    Raises :py:class:`ValueError` if `s1` and `s2` are not the same length or do not agree on non-wildcard
+    symbols at any position."""
+    if len(s1) != len(s2):
+        raise ValueError(f'\ns1={s1} and\ns2={s2}\nare not the same length.')
+    union_builder = []
+    for i in range(len(s1)):
+        c1, c2 = s1[i], s2[i]
+        if c1 == wildcard:
+            union_builder.append(c2)
+        elif c2 == wildcard:
+            union_builder.append(c1)
+        elif c1 != c2:
+            raise ValueError(f's1={s1} and s2={s2} have unequal symbols {c1} and {c2} at position {i}.')
+        elif c1 == c2:
+            union_builder.append(c1)
+        else:
+            raise AssertionError('should be unreachable')
+    return ''.join(union_builder)
+
+
+class IllegalDesignError(ValueError):
+    """Indicates that some aspect of the :any:`Design` object is illegal."""
+
+    def __init__(self, the_cause: str) -> None:
+        self.cause = the_cause
+
+    # __str__ is to print() the value
+    def __str__(self) -> str:
+        return repr(self.cause)
+
+
+class StrandError(IllegalDesignError):
+    """Indicates that the :any:`Design` is illegal due to some specific :any:`Strand`.
+    Information about the :any:`Strand` is embedded in the error message when this exception is
+    raised that helps to identify which :any:`Strand` caused the problem."""
+
+    def __init__(self, strand: Strand, the_cause: str) -> None:
+        # need to avoid calling first_bound_domain here to avoid infinite mutual recursion
+        first_domain: Optional[Domain]
+        last_domain: Optional[Domain]
+        if len(strand.domains) > 0 and isinstance(strand.domains[0], Domain):
+            first_domain = strand.domains[0]
+        else:
+            first_domain = None
+        if len(strand.domains) > 0 and isinstance(strand.domains[-1], Domain):
+            last_domain = strand.domains[-1]
+        else:
+            last_domain = None
+
+        msg = (f'''{the_cause}
+    strand length        =  {strand.dna_length()}
+    DNA length           =  {len(strand.dna_sequence) if strand.dna_sequence else "N/A"}
+    DNA sequence         =  {strand.dna_sequence}
+    strand 5' helix      =  {first_domain.helix if first_domain is not None else 'N/A'}
+    strand 5' end offset =  {first_domain.offset_5p() if first_domain is not None else 'N/A'}
+    strand 3' helix      =  {last_domain.helix if last_domain is not None else 'N/A'}
+    strand 3' end offset =  {last_domain.offset_3p() if last_domain is not None else 'N/A'}\n''')
+
+        super().__init__(msg)
+        # super(IllegalDesignError, self).__init__(msg)
+
+
+# def _plates(idt_strands) -> List[str]:
+#     plates: Set[str] = set()
+#     for strand in idt_strands:
+#         if strand.idt is not None and strand.idt.plate is not None:
+#             plates.add(strand.idt.plate)
+#     return list(plates)
+
+
+_96WELL_PLATE_ROWS: List[str] = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']
+_96WELL_PLATE_COLS: List[int] = list(range(1, 13))
+
+_384WELL_PLATE_ROWS: List[str] = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N',
+                                  'O',
+                                  'P']
+_384WELL_PLATE_COLS: List[int] = list(range(1, 25))
+
+
+@enum.unique
+class PlateType(int, enum.Enum):
+    """Represents two different types of plates in which DNA sequences can be ordered."""
+
+    wells96 = 96
+    """96-well plate."""
+
+    wells384 = 384
+    """384-well plate."""
+
+    def rows(self) -> List[str]:
+        return _96WELL_PLATE_ROWS if self is PlateType.wells96 else _384WELL_PLATE_ROWS
+
+    def cols(self) -> List[int]:
+        return _96WELL_PLATE_COLS if self is PlateType.wells96 else _384WELL_PLATE_COLS
+
+
+class _PlateCoordinate:
+
+    def __init__(self, plate_type: PlateType) -> None:
+        self._plate_type = plate_type
+        self._plate: int = 1
+        self._row_idx: int = 0
+        self._col_idx: int = 0
+
+    def increment(self) -> None:
+        self._row_idx += 1
+        if self._row_idx == len(self._plate_type.rows()):
+            self._row_idx = 0
+            self._col_idx += 1
+            if self._col_idx == len(self._plate_type.cols()):
+                self._col_idx = 0
+                self._plate += 1
+
+    def plate(self) -> int:
+        return self._plate
+
+    def row(self) -> str:
+        return self._plate_type.rows()[self._row_idx]
+
+    def col(self) -> int:
+        return self._plate_type.cols()[self._col_idx]
+
+    def well(self) -> str:
+        return f'{self.row()}{self.col()}'
+
+
+def remove_helix_idxs_if_default(helices: List[Dict]) -> None:
+    # removes indices from each helix if they are the default (order of appearance in list)
+    default = True
+    for expected_idx, helix in enumerate(helices):
+        idx = helix[idx_on_helix_key]
+        if idx != expected_idx:
+            default = False
+            break
+
+    if default:
+        for helix in helices:
+            del helix[idx_on_helix_key]
+
+
+def add_quotes(string: str) -> str:
+    # adds quotes around a string
+    return f'"{string}"'
+
+
+def mandatory_field(ret_type: Type, json_map: Dict[str, Any], main_key: str, *,
+                    legacy_keys: Sequence[str] = ()) -> Any:
+    # should be called from function whose return type is the type being constructed from JSON, e.g.,
+    # Design or Strand, given by ret_type. This helps give a useful error message
+    keys = (main_key,) + tuple(legacy_keys)
+    for key in keys:
+        if key in json_map:
+            return json_map[key]
+    ret_type_name = ret_type.__name__
+    msg_about_keys = f'the key "{main_key}"'
+    if len(legacy_keys) > 0:
+        msg_about_keys += f" (or any of the following legacy keys: {', '.join(map(add_quotes, legacy_keys))})"
+    msg = f'I was looking for {msg_about_keys} in the JSON encoding of a {ret_type_name}, ' \
+          f'but I did not find it.' \
+          f'\n\nThis occurred when reading this JSON object:\n{json_map}'
+    raise IllegalDesignError(msg)
+
+
+def optional_field(default_value: Any, json_map: Dict[str, Any], main_key: str, *,
+                   legacy_keys: Sequence[str] = (), transformer=None) -> Any:
+    # like dict.get, except that it checks for multiple keys, and it can transform the value if it is the
+    # wrong type by specifying transformer
+    keys = (main_key,) + tuple(legacy_keys)
+    for key in keys:
+        if key in json_map:
+            json_value = json_map[key]
+            if transformer is None:
+                value = json_value
+            else:
+                value = transformer(json_value)
+            return value
+    return default_value
+
+
+@dataclass
+class Geometry(_JSONSerializable):
+    """Parameters controlling some geometric visualization/physical aspects of Design."""
+
+    rise_per_base_pair: float = 0.332
+    """Distance in nanometers between two adjacent base pairs along the length of a DNA double helix."""
+
+    helix_radius: float = 1.0
+    """Radius of a DNA helix in nanometers."""
+
+    bases_per_turn: float = 10.5
+    """Number of DNA base pairs in a full turn of DNA."""
+
+    minor_groove_angle: float = 150.0
+    """Minor groove angle in degrees."""
+
+    inter_helix_gap: float = 1.0
+    """Gap between helices in nanometers (due to electrostatic repulsion; needed to display to scale)."""
+
+    def distance_between_helices(self) -> float:
+        return 2 * self.helix_radius + self.inter_helix_gap
+
+    def is_default(self) -> bool:
+        return self == _default_geometry
+
+    @staticmethod
+    def from_json(json_map: dict) -> 'Geometry':  # remove quotes when Py3.6 support dropped
+        geometry = Geometry()
+        geometry.rise_per_base_pair = optional_field(_default_geometry.rise_per_base_pair, json_map,
+                                                     rise_per_base_pair_key,
+                                                     legacy_keys=legacy_rise_per_base_pair_keys)
+        geometry.helix_radius = optional_field(_default_geometry.helix_radius, json_map, helix_radius_key)
+        geometry.bases_per_turn = optional_field(_default_geometry.bases_per_turn, json_map,
+                                                 bases_per_turn_key)
+        geometry.minor_groove_angle = optional_field(_default_geometry.minor_groove_angle, json_map,
+                                                     minor_groove_angle_key)
+        geometry.inter_helix_gap = optional_field(_default_geometry.inter_helix_gap, json_map,
+                                                  inter_helix_gap_key)
+        return geometry
+
+    @staticmethod
+    def keys() -> List[str]:
+        return [rise_per_base_pair_key, helix_radius_key, bases_per_turn_key, minor_groove_angle_key,
+                inter_helix_gap_key]
+
+    def values(self) -> List[float]:
+        return [self.rise_per_base_pair, self.helix_radius, self.bases_per_turn, self.minor_groove_angle,
+                self.inter_helix_gap]
+
+    @staticmethod
+    def default_values() -> List[float]:
+        return _default_geometry.values()
+
+    def to_json_serializable(self, suppress_indent: bool = True, **kwargs: Any) -> Dict[str, Any]:
+        dct: Dict[str, Any] = OrderedDict()
+        for name, val, val_default in zip(Geometry.keys(), self.values(), Geometry.default_values()):
+            if val != val_default:
+                dct[name] = val
+        return dct
+
+
+_default_geometry = Geometry()
+
+
+def _check_helices_view_order_and_return(
+        helices_view_order: Optional[List[int]], helix_idxs: Iterable[int]) -> List[int]:
+    if helices_view_order is None:
+        identity = sorted(helix_idxs)
+        helices_view_order = identity
+    else:
+        _check_helices_view_order_is_bijection(helices_view_order, helix_idxs)
+    return helices_view_order
+
+
+def _check_helices_grid_legal(grid: Grid, helices: Iterable[Helix]) -> None:
+    for helix in helices:
+        if grid == Grid.none and helix.grid_position is not None:
+            raise IllegalDesignError(
+                f'grid is none, but Helix {helix.idx} has grid_position = {helix.grid_position}')
+        elif grid != Grid.none and helix.position is not None:
+            raise IllegalDesignError(
+                f'grid is not none, but Helix {helix.idx} has position = ${helix.position}')
+
+
+def _check_helices_view_order_is_bijection(helices_view_order: List[int], helix_idxs: Iterable[int]) -> None:
+    if not (sorted(helices_view_order) == sorted(helix_idxs)):
+        raise IllegalDesignError(
+            f"The specified helices view order: {helices_view_order}\n "
+            f"is not a bijection on helices indices: {helix_idxs}.")
+
+
+@dataclass
+class Design(_JSONSerializable, Generic[StrandLabel, DomainLabel]):
+    """Object representing the entire design of the DNA structure."""
+
+    strands: List[Strand[StrandLabel, DomainLabel]]
+    """All of the :any:`Strand`'s in this :any:`Design`.
+    
+    Required field."""
+
+    helices: Dict[int, Helix] = None  # type: ignore
+    """All of the :any:`Helix`'s in this :any:`Design`. 
+    This is a dictionary mapping index to the :any:`Helix` with that index; if helices have indices 
+    0, 1, ..., num_helices-1, then this can be used as a list of Helices. 
+    
+    Optional field. If not specified, then the number of helices will be just large enough to store the
+    largest index :py:data:`Domain.helix` 
+    stored in any :any:`Domain` 
+    in :py:data:`Design.strands`."""
+
+    groups: Dict[str, HelixGroup] = None  # type: ignore
+    """:any:`HelixGroup`'s in this :any:`Design`."""
+
+    geometry: Geometry = field(default_factory=lambda: Geometry())
+    """Controls some geometric/physical aspects of this :any:`Design`."""
+
+    automatically_assign_color: bool = field(repr=False, default=True)
+    """If `automatically_assign_color` = ``False``, then for any :any:`Strand` such that
+    `Strand.color` = ``None``, do not automatically assign a :any:`Color` to it. 
+    In this case color will be set to its default of ``None`` and will not be
+    written to the JSON with :py:meth:`Design.write_scadnano_file` or :py:meth:`Design.to_json`."""
+
+    color_cycler: ColorCycler = field(default_factory=lambda: ColorCycler(), init=False)
+
+    def __init__(self, *,
+                 helices: Optional[Union[List[Helix], Dict[int, Helix]]] = None,
+                 groups: Optional[Dict[str, HelixGroup]] = None,
+                 strands: List[Strand] = None,
+                 grid: Grid = Grid.none,
+                 helices_view_order: List[int] = None,
+                 geometry: Geometry = None) -> None:
+        """
+        :param helices:
+            List of :any:`Helix`'s; if missing, set based on `strands`.
+        :param groups:
+            Dict mapping group name to :any:`HelixGroup`.
+            Mutually exclusive with `helices_view_order`, and any non-none :any:`Grid`. If set, then each
+            :any:`Helix` must have its :py:data:`Helix.group` field set to a group name that is one of the
+            keys of this dict.
+        :param strands:
+            List of :any:`Strand`'s. If missing, will be empty.
+        :param grid:
+            :any:`Grid` to use; if not the default value of :data:`Grid.none`, then it is
+            mutually exclusive with `groups`.
+        :param helices_view_order:
+            order in which to view helices from top to bottom in web interface main view.
+            Mutually exclusive with `groups`.
+            This list must contain each :py:data:`Helix.idx` exactly once.
+            If no :py:data:`Helix.idx` is explicitly specified, then this should be a permutation of the
+            list [0,1,...,len(helices)-1].
+            default is to display in increasing order of :py:data:`Helix.idx`.
+        :param geometry: geometric physical parameters for visualization.
+            If not specified, a default set of parameters from the literature are used.
+        """
+        using_groups = groups is not None
+
+        if helices_view_order is not None and using_groups:
+            raise IllegalDesignError('Design.helices_view_order and Design.groups are mutually exclusive. '
+                                     'Set at most one of them.')
+
+        if grid != Grid.none and using_groups:
+            raise IllegalDesignError('Design.grid and Design.groups are mutually exclusive. '
+                                     'Set at most one of them.')
+
+        self.strands = [] if strands is None else strands
+        self.color_cycler = ColorCycler()
+        self.geometry = Geometry() if geometry is None else geometry
+
+        if groups is None:
+            self.groups = {default_group_name: HelixGroup()}
+        else:
+            self.groups = groups
+            if grid != Grid.none:
+                raise IllegalDesignError('cannot use a non-none grid for whole Design when helix groups are '
+                                         'used; only the HelixGroups can have non-none grids in this case')
+            if helices_view_order is not None:
+                raise IllegalDesignError('cannot use helices_view_order for whole Design when helix groups '
+                                         'are used; only the HelixGroups can have helices_view_order '
+                                         'in this case')
+
+        if helices is None:
+            if len(self.strands) > 0:
+                max_helix_idx = max(
+                    domain.helix for strand in self.strands for domain in strand.bound_domains())
+                helices = {idx: Helix(idx=idx) for idx in range(max_helix_idx + 1)}
+            else:
+                helices = {}
+        elif not (isinstance(helices, dict) or isinstance(helices, list)):
+            raise IllegalDesignError('type of parameter helices must be list of helices, '
+                                     f'dict mapping int to helices, or None, but it is {type(helices)}')
+
+        self.helices = Design._normalize_helices_as_dict(helices)
+
+        # set up helices_view_order in groups
+        uses_default_group = self._has_default_groups()
+        for name, group in self.groups.items():
+            helix_idxs_in_group = self.helices_idxs_in_group(name)
+            if uses_default_group:
+                helices_view_order_for_group = helices_view_order
+                grid_for_group = grid
+            else:
+                helices_view_order_for_group = group.helices_view_order
+                grid_for_group = group.grid
+            group.helices_view_order = _check_helices_view_order_and_return(helices_view_order_for_group,
+                                                                            helix_idxs_in_group)
+            if grid_for_group is None:
+                raise AssertionError()
+            group.grid = grid_for_group
+            helices_in_group = [self.helices[idx] for idx in helix_idxs_in_group]
+            _check_helices_grid_legal(group.grid, helices_in_group)
+
+        self.__post_init__()
+
+    def __post_init__(self) -> None:
+        # XXX: exact order of these calls is important
+        self._ensure_helices_distinct_objects()
+        self._ensure_strands_distinct_objects()
+        self._set_helices_grid_positions_or_positions()
+        self._build_domains_on_helix_lists()
+        self._set_helices_min_max_offsets(update=False)
+        self._ensure_helix_groups_exist()
+        self._assign_default_helices_view_orders_to_groups()
+        self._check_legal_design()
+
+        if self.automatically_assign_color:
+            self._assign_colors_to_strands()
+
+    @property
+    def helices_view_order(self) -> List[int]:
+        """
+        Return helices_view_order of this :any:`Design` if no :any:`HelixGroup`'s are being used, otherwise
+        raise a ValueError.
+
+        :return: helices_view_order of this :any:`Design`
+        """
+        group = self._get_default_group()
+        if group.helices_view_order is None:
+            raise ValueError(f'group {group} does not have helices_view_order defined')
+        return group.helices_view_order
+
+    @property
+    def grid(self) -> Grid:
+        """
+        Return grid of this :any:`Design` if no :any:`HelixGroup`'s are being used, otherwise
+        raise a ValueError.
+
+        :return: grid of this :any:`Design`
+        """
+        group = self._get_default_group()
+        return group.grid
+
+    def set_grid(self, grid: Grid) -> None:
+        """
+        Sets the grid of the default :any:`HelixGroup`, if the default is being used,
+        otherwise raises an exception.
+
+        :param grid:
+            new grid to set for the (only) :any:`HelixGroup` in this :any:`Design`
+        :raises IllegalDesignError:
+            if there is more than one :any:`HelixGroup` in this :any:`Design`
+        """
+        group = self._get_default_group()
+        group.grid = grid
+
+    def _get_default_group(self) -> HelixGroup:
+        # Gets default group and raise exception if default group is not being used
+        if not self._has_default_groups():
+            raise ValueError('The default group is not being used for this design.')
+        if self.groups is None:
+            raise AssertionError('Design.groups should not be None by this point')
+        groups: List[HelixGroup] = list(self.groups.values())
+        group: HelixGroup = groups[0]
+        return group
+
+    def helices_idxs_in_group(self, group_name: str) -> List[int]:
+        """
+        Indexes of :any:`Helix`'s in this group. Must be associated with a :any:`Design` for this to work.
+
+        :param group_name: name of group
+        :return: list of indices of :any:`Helix`'s in this :any:`HelixGroup`
+        """
+        return [idx for idx, helix in self.helices.items() if helix.group == group_name]
+
+    def _assign_colors_to_strands(self) -> None:
+        # if color not specified, pick one by cycling through list of staple colors,
+        # unless caller specified not to
+        for strand in self.strands:
+            self._assign_color_to_strand(strand)
+
+    def _assign_color_to_strand(self, strand: Strand) -> None:
+        if strand.color is None and self.automatically_assign_color:
+            if strand.is_scaffold:
+                strand.color = default_scaffold_color
+            else:
+                strand.color = next(self.color_cycler)
+
+    def pitch_of_helix(self, helix: Helix) -> float:
+        """Same as the pitch of helix's :any:`HelixGroup`"""
+        return self.groups[helix.group].pitch
+
+    def yaw_of_helix(self, helix: Helix) -> float:
+        """Same as the yaw of helix's :any:`HelixGroup`"""
+        return self.groups[helix.group].yaw
+
+    def roll_of_helix(self, helix: Helix) -> float:
+        """Roll of helix's :any:`HelixGroup` plus :py:data:`Helix.roll`"""
+        return self.groups[helix.group].roll + helix.roll
+
+    @staticmethod
+    def from_scadnano_file(filename: str) -> 'Design':  # remove quotes when Py3.6 support dropped
+        """
+        Loads a :any:`Design` from the file with the given name.
+
+        :param filename: name of the file with the design. Should be a JSON file ending in .dna
+        :return: Design described in the file
+        """
+        with open(filename) as f:
+            json_str = f.read()
+        return Design.from_scadnano_json_str(json_str)
+
+    @staticmethod
+    def from_scadnano_json_str(json_str: str) -> 'Design':  # remove quotes when Py3.6 support dropped
+        """
+        Loads a :any:`Design` from the given JSON string.
+
+        :param json_str: JSON description of the :any:`Design`
+        :return: Design described in the JSON string
+        """
+        json_map = json.loads(json_str)
+        try:
+            design = Design.from_scadnano_json_map(json_map)
+            return design
+        except KeyError as e:
+            raise IllegalDesignError(f'I was expecting a JSON key but did not find it: {e}')
+
+    @staticmethod
+    def _check_mutually_exclusive_fields(json_map: dict) -> None:
+        exclusive_pairs = [
+            (grid_key, groups_key),
+            (helices_view_order_key, groups_key),
+        ]
+        for key1, key2 in exclusive_pairs:
+            if key1 in json_map and key2 in json_map:
+                raise IllegalDesignError(f'cannot specify both "{key1}" and "{key2}" in Design JSON')
+
+    @staticmethod
+    def _num_helix_groups(json_map: Dict) -> int:
+        num_groups_used = 0
+        if groups_key in json_map:
+            num_groups_used = len(json_map[groups_key])
+        return num_groups_used
+
+    @staticmethod
+    def _helices_from_json(json_map: Dict) -> Tuple[List[Helix],
+                                                    Dict[str, Tuple[float, float, int]],
+                                                    Dict[Tuple[float, float], List[Helix]]]:
+        """Returns list of helices as well as two maps, group_to_pitch_yaw, and pitch_yaw_to_helices
+
+        group_to_pitch_yaw is filled if multiple helix groups are used
+        - maps group name to pitch, yaw, and helix idx of a helix in that group (the first one found)
+
+        pitch_yaw_to_helices is filled if a single helix group is used
+        - maps pitch and yaw pairs to list of helix with those pitch and yaw
+
+        These maps are used to convert designs that have helices with individual pitches and yaws
+        into designs where helices do not have individual pitches and yaws.
+
+        Pass these two maps into _groups_from_json so that groups can be assigned appropriate pitch, yaw values.
+        """
+        # Initialize return values
+        helices = []
+        group_to_pitch_yaw: Dict[str, Tuple[float, float, int]] = {}
+        pitch_yaw_to_helices: Dict[Tuple[float, float], List[Helix]] = defaultdict(list)
+
+        # Useful booleans
+        multiple_groups_used = Design._num_helix_groups(json_map) > 1
+        single_group_used = not multiple_groups_used
+        using_groups = groups_key in json_map
+        grid: Grid = optional_field(Grid.none, json_map, grid_key, transformer=Grid)
+        grid_is_none = grid == Grid.none
+
+        idx_default = 0
+        deserialized_helices_list = json_map[helices_key]
+        for helix_json in deserialized_helices_list:
+            helix = Helix.from_json(helix_json)
+            helix_idx = helix.idx if helix.idx is not None else idx_default
+
+            #########################################################################
+            # BEGIN Backward Compatibility Code for Helix With Individual Pitch/Yaw #
+            #########################################################################
+
+            # Handle pitch and yaw for individual helices
+            pitch = 0 if pitch_key not in helix_json else helix_json[pitch_key]
+            yaw = 0 if yaw_key not in helix_json else helix_json[yaw_key]
+            group = helix.group
+            if multiple_groups_used:
+                if group not in group_to_pitch_yaw:
+                    # Log pitch and yaw for a helix in group so that other helices in the group can be compared
+                    group_to_pitch_yaw[group] = (pitch, yaw, helix_idx)
+                else:
+                    # Another helix in this group also had a non-zero pitch/yaw, so check if they match
+                    expected_pitch, expected_yaw, idx_of_helix_with_expected_pitch_yaw = group_to_pitch_yaw[
+                        group]
+                    if not (_is_close(pitch, expected_pitch) and _is_close(yaw, expected_yaw)):
+                        raise IllegalDesignError(
+                            f'In HelixGroup {group}, Helix {helix_idx} has pitch {pitch} and yaw {yaw} but Helix {helix_idx} has pitch {expected_pitch} and yaw {expected_yaw}. Please seperate Helix {helix_idx} and Helix {idx_of_helix_with_expected_pitch_yaw} into seperate HelixGroups.')
+            if single_group_used:
+                is_new_pitch_yaw = True
+                # Search for a pitch yaw pair that is close to pitch yaw of helix
+                for (p, y) in pitch_yaw_to_helices:
+                    if _is_close(p, pitch) and _is_close(y, yaw):
+                        pitch_yaw_to_helices[(p, y)].append(helix)
+                        is_new_pitch_yaw = False
+                        break
+                # If no pitch yaw pair found, then create a new one and add to dictionary
+                if is_new_pitch_yaw:
+                    pitch_yaw_to_helices[(pitch, yaw)].append(helix)
+
+            #######################################################################
+            # END Backward Compatibility Code for Helix With Individual Pitch/Yaw #
+            #######################################################################
+
+            if not using_groups and grid_is_none and grid_position_key in helix_json:
+                raise IllegalDesignError(
+                    f'grid is none, but Helix {helix_idx} has grid_position = {helix_json[grid_position_key]}')
+            elif not using_groups and not grid_is_none and position_key in helix_json:
+                raise IllegalDesignError(
+                    f'grid is not none, but Helix {helix_idx} has position = ${helix_json[position_key]}')
+            helices.append(helix)
+            idx_default += 1
+        return helices, group_to_pitch_yaw, pitch_yaw_to_helices
+
+    @staticmethod
+    def _groups_and_grid_from_json(json_map: dict, helices: List[Helix],
+                                   group_to_pitch_yaw: Dict[str, Tuple[float, float, int]],
+                                   pitch_yaw_to_helices: Dict[Tuple[float, float], List[Helix]]) \
+            -> Tuple[Dict[str, HelixGroup], Grid]:
+        """Returns map of helix group names to group as well as the grid.
+
+        If multiple helix groups are used, then groups pitch and yaw will be the
+        pitch and yaw given in the json map plus the pitch and yaw values
+        provided in group_to_pitch_yaw.
+
+        If a single helix group is used, then new helix groups will be created using
+        the provided group_to_pitch_yaw map. Because new helix groups are created,
+        helices will be modfied so that each helix's group field is set to the new
+        group it has been assigned to.
+
+        In most cases, grid will simply be what is given in the json_map.
+        There is a special case where if the default helix group is used,
+        then the grid may be initially set to some value. If individual helix
+        pitch and rolls were set, then new helix groups are created, meaning
+        the grid field is no longer valid.
+        """
+        groups = None
+        grid: Grid = optional_field(Grid.none, json_map, grid_key, transformer=Grid)
+
+        # Get helix groups provided from json_map
+        if groups_key in json_map:
+            groups_json = json_map[groups_key]
+            groups = {}
+            for name, group_json in groups_json.items():
+                num_helices_in_group = sum(1 for helix in helices if helix.group == name)
+                groups[name] = HelixGroup.from_json(group_json, num_helices=num_helices_in_group)
+
+        #########################################################################
+        # BEGIN Backward Compatibility Code for Helix With Individual Pitch/Yaw #
+        #########################################################################
+
+        multiple_groups_used = Design._num_helix_groups(json_map) > 1
+        single_group_used = not multiple_groups_used
+
+        # Add individual helix pitch and yaw
+        if multiple_groups_used:
+            for (group_name, (pitch, yaw, _)) in group_to_pitch_yaw.items():
+                groups[group_name].pitch += pitch
+                groups[group_name].yaw += yaw
+
+        # Add new helix groups if individual helix pitch and yaw set
+        if single_group_used:
+            # New groups to add
+            new_groups = {}
+            # The only group (take into account case when default helix group is used)
+            group = list(groups.values())[0] if groups is not None else HelixGroup()
+
+            for ((helix_pitch, helix_yaw), helix_list) in pitch_yaw_to_helices.items():
+                new_pitch = group.pitch + helix_pitch
+                new_yaw = group.yaw + helix_yaw
+                # Only make new groups if helix's pitch/yaw is non-zero
+                if helix_pitch or helix_yaw:
+                    # If there is more than one helix, create new helix group for each pitch yaw value
+                    new_group_name = f'pitch_{new_pitch}_yaw_{new_yaw}'
+                    new_groups[new_group_name] = HelixGroup(pitch=new_pitch, yaw=new_yaw, grid=group.grid)
+                    # Move helices into new group
+                    for helix in helix_list:
+                        helix.group = new_group_name
+
+            if len(new_groups) != 0:
+                if groups is not None:
+                    groups.update(new_groups)
+                else:
+                    groups = new_groups
+                    # Change grid to none because multiple helix groups are now used
+                    # so grid can no longer be specified
+                    grid = Grid.none
+
+        #######################################################################
+        # END Backward Compatibility Code for Helix With Individual Pitch/Yaw #
+        #######################################################################
+
+        return groups, grid
+
+    @staticmethod
+    def _helices_and_groups_and_grid_from_json(json_map: Dict) -> Tuple[List[Helix],
+                                                                        Dict[str, HelixGroup],
+                                                                        Grid]:
+        helices, group_to_pitch_yaw, pitch_yaw_to_helices = Design._helices_from_json(json_map)
+        groups, grid = Design._groups_and_grid_from_json(json_map, helices, group_to_pitch_yaw,
+                                                         pitch_yaw_to_helices)
+        return helices, groups, grid
+
+    @staticmethod
+    def from_scadnano_json_map(
+            json_map: dict) -> 'Design':  # remove quotes when Py3.6 support dropped
+        """
+        Loads a :any:`Design` from the given JSON object (i.e., Python object obtained by calling
+        json.loads(json_str) from a string representing contents of a JSON file.
+
+        :param json_map: map describing the :any:`Design`;
+            should be JSON serializable via ``encode(json_map)``
+        :return: :any:`Design` described in the object
+        """
+        # version = json_map.get(version_key, initial_version)  # not sure what to do with version
+
+        Design._check_mutually_exclusive_fields(json_map)
+
+        # helices, groups, and grid
+        helices, groups, grid = Design._helices_and_groups_and_grid_from_json(json_map)
+        num_helices = len(helices)
+
+        # view order of helices
+        helices_view_order = json_map.get(helices_view_order_key)
+        if helices_view_order is not None:
+            helix_idxs = [helix.idx for helix in helices]
+            if len(helices_view_order) != num_helices:
+                raise IllegalDesignError(f'length of helices ({num_helices}) does not match '
+                                         f'length of helices_view_order ({len(helices_view_order)})')
+            if sorted(helices_view_order) != sorted(helix_idxs):
+                raise IllegalDesignError(f'helices_view_order = {helices_view_order} is not a '
+                                         f'permutation of the set of helices {helix_idxs}')
+
+        # strands
+        strands = []
+        strand_jsons = mandatory_field(Design, json_map, strands_key)
+        for strand_json in strand_jsons:
+            strand = Strand.from_json(strand_json)
+            strands.append(strand)
+
+        # modifications in whole design
+        if design_modifications_key in json_map:
+            all_mods_json = json_map[design_modifications_key]
+            all_mods = {}
+            for mod_key, mod_json in all_mods_json.items():
+                mod = Modification.from_json(mod_json)
+                mod = dataclasses.replace(mod, id=mod_key)
+                all_mods[mod_key] = mod
+            Design.assign_modifications_to_strands(strands, strand_jsons, all_mods)
+
+        geometry = None
+        if geometry_key in json_map:
+            geometry = Geometry.from_json(json_map[geometry_key])
+
+        return Design(
+            helices=helices,
+            groups=groups,
+            strands=strands,
+            grid=grid,
+            helices_view_order=helices_view_order,
+            geometry=geometry,
+        )
+
+    def to_json_serializable(self, suppress_indent: bool = True, **kwargs: Any) -> Dict[str, Any]:
+        dct: Any = OrderedDict()
+        dct[version_key] = __version__
+
+        if self._has_default_groups():
+            dct[grid_key] = str(self.grid)[5:]  # remove prefix 'Grid.'
+
+        if not self.geometry.is_default():
+            dct[geometry_key] = self.geometry.to_json_serializable(suppress_indent)
+
+        if not self._has_default_groups():
+            group_map = {}
+            for name, group in self.groups.items():
+                helix_idxs_in_group = [helix.idx for helix in self.helices.values() if
+                                       helix.group == name]
+                group_map[name] = group.to_json_serializable(suppress_indent,
+                                                             helix_idxs=helix_idxs_in_group)
+            dct[groups_key] = group_map
+
+        helices_json = []
+        for helix in self.helices.values():
+            group = self.groups[helix.group]
+            helix_json = helix.to_json_serializable(suppress_indent, grid=group.grid)
+            helices_json.append(helix_json)
+        dct[helices_key] = helices_json
+
+        # remove idx key from list of helices if they have the default index
+        unwrapped_helices = list(dct[helices_key])
+        if len(unwrapped_helices) > 0:
+            for i, wrapped in enumerate(unwrapped_helices):
+                if isinstance(wrapped, NoIndent):
+                    unwrapped_helices[i] = wrapped.value
+        remove_helix_idxs_if_default(unwrapped_helices)
+
+        if self._has_default_groups():
+            default_helices_view_order = sorted(self.helices.keys())
+            if self.helices_view_order != default_helices_view_order:
+                dct[helices_view_order_key] = NoIndent(
+                    self.helices_view_order) if suppress_indent else self.helices_view_order
+
+        # modifications
+        mods = self._all_modifications()
+        if len(mods) > 0:
+            mods_dict = {}
+            for mod in mods:
+                if mod.id not in mods_dict:
+                    mods_dict[mod.id] = mod.to_json_serializable(suppress_indent)
+            dct[design_modifications_key] = mods_dict
+
+        dct[strands_key] = [strand.to_json_serializable(suppress_indent) for strand in self.strands]
+
+        for helix_list_order, helix in enumerate(self.helices.values()):
+            helix_json_maybe: Union[NoIndent, Dict[str, Any]] = dct[helices_key][helix_list_order]
+            if isinstance(helix_json_maybe, NoIndent):
+                helix_json = helix_json_maybe.value  # get past NoIndent surrounding helix, if it is there
+            else:
+                helix_json = helix_json_maybe
+            # XXX: no need to check here because key was already deleted by Helix.to_json_serializable
+            # max_offset still needs to be checked here since it requires global knowledge of Strands
+            # if 0 == helix_json[min_offset_key]:
+            #     del helix_json[min_offset_key]
+            max_offset = max((domain.end for strand in self.strands for domain in strand.bound_domains()),
+                             default=-1)
+            if max_offset == helix_json[max_offset_key] or helix_json[max_offset_key] is None:
+                del helix_json[max_offset_key]
+
+        return dct
+
+    @property
+    def scaffold(self) -> Optional[Strand]:
+        """Returns the first scaffold in this :any:`Design`, if there is one, or ``None`` otherwise."""
+        for strand in self.strands:
+            if strand.is_scaffold:
+                return strand
+        return None
+
+    @staticmethod
+    def _normalize_helices_as_dict(helices: Union[List[Helix], Dict[int, Helix]]) -> Dict[int, Helix]:
+        def idx_of(helix: Helix, order: int) -> int:
+            return order if helix.idx is None else helix.idx
+
+        if isinstance(helices, list):
+            indices = [idx_of(helix, idx) for idx, helix in enumerate(helices)]
+            if len(set(indices)) < len(indices):
+                duplicates = [index for index, count in Counter(indices).items() if count > 1]
+                raise IllegalDesignError(
+                    'No two helices can share an index, but these indices appear on '
+                    f'multiple helices: {", ".join(map(str, duplicates))}')
+            helices = {idx_of(helix, idx): helix for idx, helix in enumerate(helices)}
+
+        for idx, helix in helices.items():
+            helix.idx = idx
+
+        return helices
+
+    @staticmethod
+    def assign_modifications_to_strands(strands: List[Strand], strand_jsons: List[dict],
+                                        all_mods: Dict[str, Modification]) -> None:
+        for strand, strand_json in zip(strands, strand_jsons):
+            if modification_5p_key in strand_json:
+                mod_name = strand_json[modification_5p_key]
+                strand.modification_5p = cast(Modification5Prime, all_mods[mod_name])
+            if modification_3p_key in strand_json:
+                mod_name = strand_json[modification_3p_key]
+                strand.modification_3p = cast(Modification3Prime, all_mods[mod_name])
+            if modifications_int_key in strand_json:
+                mod_names_by_offset = strand_json[modifications_int_key]
+                for offset_str, mod_name in mod_names_by_offset.items():
+                    offset = int(offset_str)
+                    strand.modifications_int[offset] = cast(ModificationInternal, all_mods[mod_name])
+
+    @staticmethod
+    def _cadnano_v2_import_find_5_end(vstrands: VStrands, strand_type: str, helix_num: int, base_id: int,
+                                      id_from: int,
+                                      base_from: int) -> Tuple[int, int, bool]:
+        """ Routine which finds the 5' end of a strand in a cadnano v2 import. It returns the
+        helix and the base of the 5' end.
+        """
+        id_from_before = helix_num  # 'id' stands for helix id
+        base_from_before = base_id
+
+        circular_seen = {}
+        is_circular = False
+
+        while not (id_from == -1 and base_from == -1):
+            if (id_from, base_from) in circular_seen:
+                is_circular = True
+                break
+            circular_seen[(id_from, base_from)] = True
+            id_from_before = id_from
+            base_from_before = base_from
+            id_from, base_from, _, _ = vstrands[id_from][strand_type][base_from]
+        return id_from_before, base_from_before, is_circular
+
+    @staticmethod
+    def _cadnano_v2_import_find_strand_color(vstrands: VStrands, strand_type: str, strand_5_end_base: int,
+                                             strand_5_end_helix: int) -> Color:
+        """Routine that finds the color of a cadnano v2 strand."""
+        color: Color = default_cadnano_strand_color
+
+        if strand_type == 'scaf':
+            return default_scaffold_color
+
+        if strand_type == 'stap':
+            base_id: int
+            stap_color: int
+
+            for base_id, stap_color in vstrands[strand_5_end_helix]['stap_colors']:
+                if base_id == strand_5_end_base:
+                    color = Color.from_cadnano_v2_int_hex(stap_color)
+                    break
+        return color
+
+    @staticmethod
+    def _cadnano_v2_import_extract_deletions(skip_table: Dict[int, Any], start: int, end: int) -> List[int]:
+        """ Routines which converts cadnano skips to scadnano deletions """
+        to_return: List[int] = []
+        for base_id in range(start, end):
+            if skip_table[base_id] == -1:
+                to_return.append(base_id)
+        return to_return
+
+    @staticmethod
+    def _cadnano_v2_import_extract_insertions(loop_table: Dict[int, Any],
+                                              start: int, end: int) -> List[Tuple[int, int]]:
+        """ Routines which converts cadnano skips to scadnano insertions """
+        to_return: List[Tuple[int, int]] = []
+        for base_id in range(start, end):
+            if loop_table[base_id] != 0:
+                to_return.append((base_id, loop_table[base_id]))
+        return to_return
+
+    @staticmethod
+    def _cadnano_v2_import_explore_domains(vstrands: VStrands, seen: Dict[Tuple[int, int], bool],
+                                           strand_type: str,
+                                           strand_5_end_base: int,
+                                           strand_5_end_helix: int) -> List[Domain]:
+        """Finds all domains of a cadnano v2 strand. """
+        curr_helix = strand_5_end_helix
+        curr_base = strand_5_end_base
+        domains: List[Domain] = []
+
+        direction_forward = (strand_type == 'scaf' and curr_helix % 2 == 0) or (
+            (strand_type == 'stap' and curr_helix % 2 == 1))
+        start, end = -1, -1
+        if direction_forward:
+            start = curr_base
+        else:
+            end = curr_base
+
+        circular_seen = {}
+        while not (curr_helix == -1 and curr_base == -1):
+            if (curr_helix, curr_base) in circular_seen:
+                break
+            circular_seen[(curr_helix, curr_base)] = True
+
+            old_helix = curr_helix
+            old_base = curr_base
+            seen[(curr_helix, curr_base)] = True
+            curr_helix, curr_base = vstrands[curr_helix][strand_type][curr_base][2:]
+            # Add crossover
+            # We have a crossover when we jump helix or when order is broken on same helix
+            # Or circular strand
+            if curr_helix != old_helix or (not direction_forward and curr_base > old_base) or (
+                    direction_forward and curr_base < old_base) or (
+                    curr_helix == strand_5_end_helix and curr_base == strand_5_end_base):
+
+                if direction_forward:
+                    end = old_base
+                else:
+                    start = old_base
+
+                domains.append(
+                    Domain(old_helix, direction_forward, min(start, end), max(start, end) + 1,
+                           deletions=Design._cadnano_v2_import_extract_deletions(
+                               vstrands[old_helix]['skip'], start, end),
+                           insertions=Design._cadnano_v2_import_extract_insertions(
+                               vstrands[old_helix]['loop'], start, end)))
+
+                direction_forward = (strand_type == 'scaf' and curr_helix % 2 == 0) or (
+                    (strand_type == 'stap' and curr_helix % 2 == 1))
+                start, end = -1, -1
+                if direction_forward:
+                    start = curr_base
+                else:
+                    end = curr_base
+
+        return domains
+
+    @staticmethod
+    def _cadnano_v2_import_circular_strands_merge_first_last_domains(domains: List[Domain]) -> None:
+        """ When we create domains for circular strands in the cadnano import routine, we may end up
+            with a fake crossover if first and last domain are on same helix, we have to merge them 
+            if it is the case.
+        """
+        if domains[0].helix != domains[-1].helix:
+            return
+
+        domains[0].start = min(domains[0].start, domains[-1].start)
+        domains[0].end = max(domains[0].end, domains[-1].end)
+
+        del domains[-1]
+
+    @staticmethod
+    def _cadnano_v2_import_explore_strand(vstrands: VStrands,
+                                          strand_type: str, seen: Dict[Tuple[int, int], bool],
+                                          helix_num: int,
+                                          base_id: int) -> Optional[Strand]:
+        """ Routine that will follow a cadnano v2 strand accross helices and create
+            cadnano domains and strand accordingly.
+        """
+
+        seen[(helix_num, base_id)] = True
+        id_from, base_from, id_to, base_to = vstrands[helix_num][strand_type][base_id]
+
+        if (id_from, base_from, id_to, base_to) == (-1, -1, -1, -1):
+            return None
+
+        strand_5_end_helix, strand_5_end_base, is_circular = Design._cadnano_v2_import_find_5_end(vstrands,
+                                                                                                  strand_type,
+                                                                                                  helix_num,
+                                                                                                  base_id,
+                                                                                                  id_from,
+                                                                                                  base_from)
+
+        strand_color = Design._cadnano_v2_import_find_strand_color(vstrands, strand_type,
+                                                                   strand_5_end_base,
+                                                                   strand_5_end_helix)
+        domains: List[Domain] = Design._cadnano_v2_import_explore_domains(vstrands, seen, strand_type,
+                                                                          strand_5_end_base,
+                                                                          strand_5_end_helix)
+        # merge first and last domain if circular
+        if is_circular:
+            Design._cadnano_v2_import_circular_strands_merge_first_last_domains(domains)
+        domains_loopouts = cast(List[Union[Domain, Loopout]],  # noqa
+                                domains)  # type: ignore
+        strand: Strand = Strand(domains=domains_loopouts,
+                                is_scaffold=(strand_type == 'scaf'), color=strand_color, circular=is_circular)
+
+        return strand
+
+    # remove quotes when Py3.6 support dropped
+    @staticmethod
+    def from_cadnano_v2(directory: Optional[str] = None, filename: Optional[str] = None,
+                        json_dict: Optional[dict] = None) -> 'Design':
+        """
+        Creates a Design from a cadnano v2 file.
+        """
+
+        if json_dict is None and filename is not None and directory is not None:
+            file_path = os.path.join(directory, filename)
+            f = open(file_path, 'r')
+            cadnano_v2_design = json.load(f)
+            f.close()
+        elif json_dict is not None:
+            cadnano_v2_design = json_dict
+        else:
+            raise ValueError('must have json_dict None and filename/directory not None, or vice versa')
+
+        num_bases = len(cadnano_v2_design['vstrands'][0]['scaf'])
+        grid_type = Grid.square
+        if num_bases % 21 == 0:
+            grid_type = Grid.honeycomb
+
+        min_row, min_col = None, None
+        for cadnano_helix in cadnano_v2_design['vstrands']:
+            col, row = cadnano_helix['col'], cadnano_helix['row']
+            min_row = row if min_row is None else min_row
+            min_col = col if min_col is None else min_col
+            min_row = row if row < min_row else min_row
+            min_col = col if col < min_col else min_col
+
+        helices = OrderedDict({})
+        for cadnano_helix in cadnano_v2_design['vstrands']:
+            col, row = cadnano_helix['col'], cadnano_helix['row']
+            num = cadnano_helix['num']
+            helix = Helix(idx=num, max_offset=num_bases, grid_position=(col, row))
+            helices[num] = helix
+
+        # We do a DFS on strands
+        seen: Dict[str, dict] = {'scaf': {}, 'stap': {}}
+        strands: List[Strand] = []
+        cadnano_helices = OrderedDict({})
+        for cadnano_helix in cadnano_v2_design['vstrands']:
+            helix_num = cadnano_helix['num']
+            cadnano_helices[helix_num] = cadnano_helix
+
+        for cadnano_helix in cadnano_v2_design['vstrands']:
+            helix_num = cadnano_helix['num']
+            for strand_type in ['scaf', 'stap']:
+                for base_id in range(num_bases):
+                    if (helix_num, base_id) in seen[strand_type]:
+                        continue
+
+                    strand = Design._cadnano_v2_import_explore_strand(cadnano_helices,
+                                                                      strand_type,
+                                                                      seen[strand_type], helix_num,
+                                                                      base_id)
+                    if strand is not None:
+                        strands.append(strand)
+
+        design: Design = Design(grid=grid_type, helices=helices, strands=strands)
+        # DD: Tristan, I commented this out because I think it's unnecessary given the way the Design
+        # constructor works, and because I'm now implementing this feature:
+        # https://github.com/UC-Davis-molecular-computing/scadnano-python-package/issues/121
+        # which means we may not have a well-defined helices_view_order on the whole design if groups
+        # are used
+        # design.set_helices_view_order([num for num in helices])
+
+        return design
+
+    def _all_modifications(self) -> Set[Modification]:
+        # List of all modifications.
+        mods_5p = {strand.modification_5p for strand in self.strands if
+                   strand.modification_5p is not None}
+        mods_3p = {strand.modification_3p for strand in self.strands if
+                   strand.modification_3p is not None}
+        mods_int = {mod for strand in self.strands for mod in strand.modifications_int.values()}
+        return mods_5p | mods_3p | mods_int
+
+    def strand(self, helix: int, offset: int) -> StrandBuilder:
+        """Used for chained method building by calling
+        :py:meth:`Design.strand` to build the :any:`Strand` domain by domain, in order from 5' to 3'.
+        For example
+
+        .. code-block:: Python
+
+            design.strand(0, 7).to(10).cross(1).to(5).cross(2).to(15)
+
+        This creates a :any:`Strand` in this :any:`Design` equivalent to
+
+        .. code-block:: Python
+
+            design.add_strand(Strand([
+                sc.Domain(0, True, 7, 10),
+                sc.Domain(1, False, 5, 10),
+                sc.Domain(2, True, 5, 15),
+            ]))
+
+        Loopouts can also be included:
+
+        .. code-block:: Python
+
+            design.strand(0, 7).to(10).cross(1).to(5).loopout(2, 3).to(15)
+
+        This creates a :any:`Strand` in this :any:`Design` equivalent to
+
+        .. code-block:: Python
+
+            design.add_strand(Strand([
+                sc.Domain(0, True, 7, 10),
+                sc.Domain(1, False, 5, 10),
+                sc.Loopout(3),
+                sc.Domain(2, True, 5, 15),
+            ]))
+
+        Each call to
+        :py:meth:`Design.strand`,
+        :py:meth:`StrandBuilder.cross`,
+        :py:meth:`StrandBuilder.loopout`,
+        :py:meth:`StrandBuilder.to`
+        :py:meth:`StrandBuilder.update_to`,
+        returns a :any:`StrandBuilder` object.
+
+        Each call to
+        :py:meth:`StrandBuilder.to`,
+        :py:meth:`StrandBuilder.update_to`,
+        or
+        :py:meth:`StrandBuilder.loopout`
+        modifies the :any:`Design` by replacing the Strand with an updated version.
+
+        See the documentation for :any:`StrandBuilder` for the methods available to call in this way.
+
+        :param helix: starting :any:`Helix`
+        :param offset: starting offset on `helix`
+        :return: :any:`StrandBuilder` object representing the partially completed :any:`Strand`
+        """
+        return StrandBuilder(self, helix, offset)
+
+    def assign_m13_to_scaffold(self, rotation: int = 5587, variant: M13Variant = M13Variant.p7249) -> None:
+        """Assigns the scaffold to be the sequence of M13: :py:func:`m13` with the given `rotation`
+        and :any:`M13Variant`.
+
+        Raises :any:`IllegalDesignError` if the number of scaffolds is not exactly 1.
+        """
+        scaffold = None
+        num_scafs = 0
+        for strand in self.strands:
+            if strand.is_scaffold:
+                num_scafs += 1
+                if scaffold is None:
+                    scaffold = strand
+        if num_scafs == 0:
+            raise IllegalDesignError(
+                'Tried to assign DNA to scaffold, but there is no scaffold strand.\n'
+                'You must set strand.is_scaffold to True for exactly one strand.')
+        elif num_scafs > 1:
+            raise IllegalDesignError(
+                'Tried to assign DNA to scaffold, but there are multiple scaffold strands.\n'
+                'You must set strand.is_scaffold to True for exactly one strand.')
+        if scaffold is None:
+            raise AssertionError('we counted; there is exactly one scaffold')
+        self.assign_dna(scaffold, m13(rotation, variant))
+
+    @staticmethod
+    def _get_multiple_of_x_sup_closest_to_y(x: int, y: int) -> int:
+        return y if y % x == 0 else y + (x - y % x)
+
+    @staticmethod
+    def _cadnano_v2_place_strand_segment(helix_dct: Dict[str, Any], domain: Domain,
+                                         strand_type: str = 'scaf') -> None:
+        """Converts a strand region with no crossover to cadnano v2.
+        """
+        # Insertions and deletions
+        for deletion in domain.deletions:
+            helix_dct['skip'][deletion] = -1
+        for loop_where, loop_nb in domain.insertions:
+            helix_dct['loop'][loop_where] = loop_nb
+
+        start, end, forward = domain.start, domain.end, domain.forward
+        strand_helix = helix_dct['num']
+
+        for i_base in range(start, end):
+            if forward:
+                from_helix, from_base = strand_helix, i_base - 1
+                to_helix, to_base = strand_helix, i_base + 1
+            else:
+                from_helix, from_base = strand_helix, i_base + 1
+                to_helix, to_base = strand_helix, i_base - 1
+
+            if i_base == start:
+                if forward:
+                    helix_dct[strand_type][i_base][2:] = [to_helix, to_base]
+                else:
+                    helix_dct[strand_type][i_base][:2] = [from_helix, from_base]
+            elif i_base < end - 1:
+                helix_dct[strand_type][i_base] = [from_helix, from_base, to_helix, to_base]
+            else:
+                if forward:
+                    helix_dct[strand_type][i_base][:2] = [from_helix, from_base]
+                else:
+                    helix_dct[strand_type][i_base][2:] = [to_helix, to_base]
+        return
+
+    @staticmethod
+    def _cadnano_v2_place_crossover(helix_from_dct: Dict[str, Any], helix_to_dct: Dict[str, Any],
+                                    domain_from: Domain, domain_to: Domain,
+                                    strand_type: str = 'scaf') -> None:
+        """Converts a crossover to cadnano v2 format.
+        Returns a conversion table from ids in the structure self.helices to helices ids
+        as given by helix.idx.
+        """
+
+        helix_from = helix_from_dct['num']
+        start_from, end_from, forward_from = domain_from.start, domain_from.end, domain_from.forward
+
+        helix_to = helix_to_dct['num']
+        start_to, end_to = domain_to.start, domain_to.end
+
+        if forward_from:
+            helix_from_dct[strand_type][end_from - 1][2:] = [helix_to, end_to - 1]
+            helix_to_dct[strand_type][end_to - 1][:2] = [helix_from, end_from - 1]
+        else:
+            helix_from_dct[strand_type][start_from][2:] = [helix_to, start_to]
+            helix_to_dct[strand_type][start_to][:2] = [helix_from, start_from]
+
+    @staticmethod
+    def _cadnano_v2_color_of_stap(color: Color, domain: Domain) -> List[int]:
+        base_id = domain.start if domain.forward else domain.end - 1
+        cadnano_color = color.to_cadnano_v2_int_hex()
+        return [base_id, cadnano_color]
+
+    def _cadnano_v2_place_strand(self, strand: Strand, dct: dict,
+                                 helices_ids_reverse: Dict[int, int]) -> None:
+        """Place a scadnano strand in cadnano v2.
+        """
+        strand_type = 'stap'
+        if hasattr(strand, is_scaffold_key) and strand.is_scaffold:
+            strand_type = 'scaf'
+
+        for i, domain in enumerate(strand.domains):
+            if isinstance(domain, Loopout):
+                raise ValueError(f'cannot convert Strand {strand} to cadnanov2 format, since it has Loopouts')
+
+            which_helix_id = helices_ids_reverse[domain.helix]
+            which_helix = dct['vstrands'][which_helix_id]
+
+            if strand_type == 'stap':
+                color = strand.color if strand.color is not None else Color(0, 0, 0)
+                which_helix['stap_colors'].append(self._cadnano_v2_color_of_stap(color, domain))
+
+            self._cadnano_v2_place_strand_segment(which_helix, domain, strand_type)
+
+            if i != len(strand.domains) - 1:
+                next_domain = strand.domains[i + 1]
+                if isinstance(next_domain, Loopout):
+                    raise ValueError(
+                        f'cannot convert Strand {strand} to cadnanov2 format, since it has Loopouts')
+                next_helix_id = helices_ids_reverse[next_domain.helix]
+                next_helix = dct['vstrands'][next_helix_id]
+                self._cadnano_v2_place_crossover(which_helix, next_helix,
+                                                 domain, next_domain, strand_type)
+
+        # if the strand is circular, we need to close the loop
+        if strand.circular:
+            first_domain = strand.first_bound_domain()
+            first_helix = dct['vstrands'][first_domain.helix]
+            first_start, first_end, first_forward = first_domain.start, first_domain.end, first_domain.forward
+
+            last_domain = strand.last_bound_domain()
+            last_helix = dct['vstrands'][last_domain.helix]
+            last_start, last_end, last_forward = last_domain.start, last_domain.end, last_domain.forward
+
+            the_base_from = last_end - 1
+            the_base_to = first_start
+
+            if not last_forward:
+                the_base_from = last_start
+
+            if not first_forward:
+                the_base_to = first_end - 1
+
+            if first_helix[strand_type][the_base_to][:2] == [-1, -1]:
+                first_helix[strand_type][the_base_to][:2] = [last_helix['num'], the_base_from]
+            else:
+                first_helix[strand_type][the_base_to][2:] = [last_helix['num'], the_base_from]
+
+            if last_helix[strand_type][the_base_from][:2] == [-1, -1]:
+                last_helix[strand_type][the_base_from][:2] = [first_helix['num'], the_base_to]
+            else:
+                last_helix[strand_type][the_base_from][2:] = [first_helix['num'], the_base_to]
+
+    def _cadnano_v2_fill_blank(self, dct: dict, num_bases: int, design_grid: Grid) -> Dict[int, int]:
+        """Creates blank cadnanov2 helices in and initialized all their fields.
+        """
+        helices_ids_reverse = {}
+        for i, helix in self.helices.items():
+            helix_dct: Dict[str, Any] = OrderedDict()
+            helix_dct['num'] = helix.idx
+
+            if design_grid == Grid.square or design_grid == Grid.honeycomb:
+                assert helix.grid_position is not None
+                helix_dct['row'] = helix.grid_position[1]
+                helix_dct['col'] = helix.grid_position[0]
+
+            helix_dct['scaf'] = []
+            helix_dct['stap'] = []
+            helix_dct['loop'] = []
+            helix_dct['skip'] = []
+
+            for _ in range(num_bases):
+                helix_dct['scaf'].append([-1, -1, -1, -1])
+                helix_dct['stap'].append([-1, -1, -1, -1])
+                helix_dct['loop'].append(0)
+                helix_dct['skip'].append(0)
+
+            helix_dct['stap_colors'] = []
+            helix_dct['scafLoop'] = []
+            helix_dct['stap_loop'] = []
+
+            helices_ids_reverse[helix_dct['num']] = i
+            dct['vstrands'].append(helix_dct)
+        return helices_ids_reverse
+
+    def to_cadnano_v2(self) -> Dict[str, Any]:
+        """Converts the design to the cadnano v2 format.
+        Please see the spec `misc/cadnano-format-specs/v2.txt` for more info on that format.
+        """
+        dct: Dict[str, Any] = OrderedDict()
+        dct['vstrands'] = []
+
+        '''Check if helix group are used or if only one grid is used'''
+        if self._has_default_groups():
+            design_grid = self.grid
+        else:
+            grid_used = {}
+            assert len(self.groups) > 0
+            grid_type = Grid.none
+            for group_name in self.groups:
+                grid_used[self.groups[group_name].grid] = True
+                grid_type = self.groups[group_name].grid
+            if len(grid_used) > 1:
+                raise ValueError('Designs using helix groups can be exported to cadnano v2 \
+                    only if all groups share the same grid type.')
+            else:
+                design_grid = grid_type
+
+        '''Figuring out the type of grid.
+        In cadnano v2, all helices have the same max offset 
+        called `num_bases` and the type of grid is determined as follows:
+            if num_bases % 32 == 0: then we are on grid square
+            if num_bases % 21 == 0: then we are on grid honey
+        '''
+        num_bases = 0
+        for helix in self.helices.values():
+            if helix.max_offset is None:
+                raise ValueError('must have helix.max_offset set')
+            num_bases = max(num_bases, helix.max_offset)
+
+        if design_grid == Grid.square:
+            num_bases = self._get_multiple_of_x_sup_closest_to_y(32, num_bases)
+        elif design_grid == Grid.honeycomb:
+            num_bases = self._get_multiple_of_x_sup_closest_to_y(21, num_bases)
+        else:
+            raise NotImplementedError('We can export to cadnano v2 `square` and `honeycomb` grids only.')
+
+        '''Figuring out if helices numbers have good parity.
+        In cadnano v2, only even helices have the scaffold go forward, only odd helices
+        have the scaffold go backward.
+
+        '''
+        for strand in self.strands:
+            for domain in strand.domains:
+                if isinstance(domain, Loopout):
+                    raise ValueError(
+                        'We cannot handle designs with Loopouts as it is not a cadnano v2 concept')
+                right_direction: bool
+                if hasattr(strand, is_scaffold_key) and strand.is_scaffold:
+                    right_direction = (domain.helix % 2 == int(not domain.forward))
+                else:
+                    right_direction = not (domain.helix % 2 == int(not domain.forward))
+
+                if not right_direction:
+                    raise ValueError('We can only convert designs where even helices have the scaffold'
+                                     'going forward and odd helices have the scaffold going backward see '
+                                     f'the spec v2.txt Note 4. {domain}')
+
+        '''Filling the helices with blank.
+        '''
+        helices_ids_reverse = self._cadnano_v2_fill_blank(dct, num_bases, design_grid)
+        '''Putting the scaffold in place.
+        '''
+
+        for strand in self.strands:
+            self._cadnano_v2_place_strand(strand, dct, helices_ids_reverse)
+
+        return dct
+
+    def set_helices_view_order(self, helices_view_order: List[int]) -> None:
+        """
+        Sets helices_view_order.
+
+        :param helices_view_order: new view order of helices
+        """
+        if not self._has_default_groups():
+            raise ValueError('cannot call set_helices_view_order on a Design that uses HelixGroups')
+        group = self._default_group()
+        group.helices_view_order = helices_view_order
+        _check_helices_view_order_is_bijection(helices_view_order,
+                                               self.helices_idxs_in_group(default_group_name))
+
+    def _default_group(self) -> HelixGroup:
+        if not self._has_default_groups():
+            raise ValueError('cannot call _default_group on a Design that uses HelixGroups')
+        groups_list = list(self.groups.values())
+        return groups_list[0]
+
+    def _set_helices_grid_positions_or_positions(self) -> None:
+        for name, group in self.groups.items():
+            for idx in self.helices_idxs_in_group(name):
+                helix = self.helices[idx]
+                if group.grid != Grid.none and helix.grid_position is None:
+                    helix.grid_position = (0, group.helices_view_order_inverse(idx))
+                elif group.grid == Grid.none and helix.position is None:
+                    y_delta = self.geometry.distance_between_helices()
+                    y = y_delta * group.helices_view_order_inverse(idx)
+                    helix.position = Position3D(x=0, y=y, z=0)
+
+    def _set_helices_min_max_offsets(self, update: bool) -> None:
+        """update = whether to overwrite existing Helix.max_offset and Helix.min_offset.
+        Don't do this when Design is first created, but do it later when updating."""
+        for helix in self.helices.values():
+
+            if update or helix.max_offset is None:
+                max_offset = None if len(helix.domains) == 0 else helix.domains[0].end
+                for domain in helix.domains:
+                    max_offset = max(max_offset, domain.end)
+                helix.max_offset = max_offset
+
+            if update or helix.min_offset is None:
+                min_offset = None if len(helix.domains) == 0 else helix.domains[0].start
+                for domain in helix.domains:
+                    min_offset = min(min_offset, domain.start)
+                if min_offset is None or min_offset > 0:
+                    min_offset = 0
+                helix.min_offset = min_offset
+
+    def strands_starting_on_helix(self, helix: int) -> List[Strand]:
+        """Return list of :any:`Strand`'s that begin (have their 5' end)
+        on the :any:`Helix` with index `helix`."""
+        return [strand for strand in self.strands
+                if isinstance(strand.domains[0], Domain) and strand.domains[0].helix == helix]
+
+    def strands_ending_on_helix(self, helix: int) -> List[Strand]:
+        """Return list of :any:`Strand`'s that finish (have their 3' end)
+        on the :any:`Helix` with index `helix`."""
+        return [strand for strand in self.strands
+                if isinstance(strand.domains[-1], Domain) and strand.domains[-1].helix == helix]
+
+    def _check_legal_design(self) -> None:
+        self._check_helix_offsets()
+        self._check_strands_reference_helices_legally()
+        self._check_loopouts_not_consecutive_or_singletons_or_zero_length()
+        self._check_loopouts_not_first_or_last_substrand()
+        self._check_strands_overlap_legally()
+        self._warn_if_strand_names_not_unique()
+
+    # TODO: come up with reasonable default behavior when no strands are on helix and max_offset not given
+    def _check_helix_offsets(self) -> None:
+        for helix in self.helices.values():
+            if helix.min_offset is not None \
+                    and helix.max_offset is not None \
+                    and helix.min_offset >= helix.max_offset:
+                err_msg = f'for helix {helix.idx}, ' \
+                          f'helix.min_offset = {helix.min_offset} must be strictly less than ' \
+                          f'helix.max_offset = {helix.max_offset}'
+                raise IllegalDesignError(err_msg)
+
+    def _check_strands_overlap_legally(self, domain_to_check: Optional[Domain] = None) -> None:
+        """If `Domain_to_check` is None, check all.
+        Otherwise only check pairs where one is domain_to_check."""
+
+        def err_msg(d1: Domain, d2: Domain, h_idx: int) -> str:
+            return f"two domains overlap on helix {h_idx}: " \
+                   f"\n{d1}\n  and\n{d2}\n  but have the same direction"
+
+        # ensure that if two strands overlap on the same helix,
+        # they point in opposite directions
+        for helix_idx, domains in enumerate(helix.domains for helix in self.helices.values()):
+            if domain_to_check is not None and domain_to_check.helix != helix_idx:
+                # TODO: if necessary, we can be more efficient by only checking this one domain
+                continue
+
+            if len(domains) == 0:
+                continue
+
+            # check all consecutive domains on the same helix, sorted by start/end indices
+            offsets_data = []
+            for domain in domains:
+                offsets_data.append((domain.start, True, domain))
+                offsets_data.append((domain.end, False, domain))
+            offsets_data.sort(key=lambda offset_data: offset_data[0])
+
+            current_domains: List[Domain] = []
+            for offset, is_start, domain in offsets_data:
+                if is_start:
+                    if len(current_domains) >= 2:
+                        if offset >= current_domains[1].end:
+                            del current_domains[1]
+                    if len(current_domains) >= 1:
+                        if offset >= current_domains[0].end:
+                            del current_domains[0]
+                    current_domains.append(domain)
+                    if len(current_domains) > 2:
+                        domain0, domain1, domain2 = current_domains[0:3]
+                        for d_first, d_second in [(domain0, domain1), (domain1, domain2), (domain0, domain2)]:
+                            if d_first.forward == d_second.forward:
+                                raise IllegalDesignError(err_msg(d_first, d_second, helix_idx))
+                        raise AssertionError(
+                            f"since current_domains = {current_domains} has at least three domains, "
+                            f"I expected to find a pair of illegally overlapping domains")
+                    elif len(current_domains) == 2:
+                        d_first, d_second = current_domains
+                        if d_first.forward == d_second.forward:
+                            raise IllegalDesignError(err_msg(d_first, d_second, helix_idx))
+
+    def _check_loopouts_not_consecutive_or_singletons_or_zero_length(self) -> None:
+        for strand in self.strands:
+            Design._check_loopout_not_singleton(strand)
+            Design._check_two_consecutive_loopouts(strand)
+            Design._check_loopouts_length(strand)
+
+    def _check_loopouts_not_first_or_last_substrand(self) -> None:
+        for strand in self.strands:
+            if isinstance(strand.first_domain(), Loopout):
+                raise StrandError(strand, 'strand cannot have a Loopout as its first domain')
+            if isinstance(strand.last_domain(), Loopout):
+                raise StrandError(strand, 'strand cannot have a Loopout as its last domain')
+
+    @staticmethod
+    def _check_loopout_not_singleton(strand: Strand) -> None:
+        if len(strand.domains) == 1 and isinstance(strand.first_domain(), Loopout):
+            raise StrandError(strand, 'strand cannot have a single Loopout as its only domain')
+
+    @staticmethod
+    def _check_two_consecutive_loopouts(strand: Strand) -> None:
+        for domain1, domain2 in _pairwise(strand.domains):
+            if isinstance(domain1, Loopout) and isinstance(domain2, Loopout):
+                raise StrandError(strand, 'cannot have two consecutive Loopouts in a strand')
+
+    @staticmethod
+    def _check_loopouts_length(strand: Strand) -> None:
+        for loopout in strand.domains:
+            if isinstance(loopout, Loopout) and loopout.length <= 0:
+                raise StrandError(strand, f'loopout length must be positive but is {loopout.length}')
+
+    def _check_strands_reference_helices_legally(self) -> None:
+        # ensure each strand refers to an existing helix
+        for strand in self.strands:
+            self._check_strand_references_legal_helices(strand)
+            self._check_strand_has_legal_offsets_in_helices(strand)
+
+    def _check_strand_references_legal_helices(self, strand: Strand) -> None:
+        for domain in strand.domains:
+            if isinstance(domain, Domain) and domain.helix not in self.helices:
+                err_msg = f"domain {domain} refers to nonexistent Helix index {domain.helix}; " \
+                          f"here is the list of valid helices: {self._helices_to_string()}"
+                raise StrandError(strand, err_msg)
+
+    def _check_strand_has_legal_offsets_in_helices(self, strand: Strand) -> None:
+        for domain in strand.domains:
+            if isinstance(domain, Domain):
+                helix = self.helices[domain.helix]
+                if helix.min_offset is not None and domain.start < helix.min_offset:
+                    err_msg = f"domain {domain} has start offset {domain.start}, " \
+                              f"beyond the end of " \
+                              f"Helix {domain.helix} that has min_offset = {helix.min_offset}"
+                    raise StrandError(strand, err_msg)
+                if helix.max_offset is not None and domain.end > helix.max_offset:
+                    err_msg = f"domain {domain} has end offset {domain.end}, " \
+                              f"beyond the end of " \
+                              f"Helix {domain.helix} that has max_offset = {helix.max_offset}"
+                    raise StrandError(strand, err_msg)
+
+        # ensure helix_idx's are never negative twice in a row
+        for domain1, domain2 in _pairwise(strand.domains):
+            if isinstance(domain1, Loopout) and isinstance(domain2, Loopout):
+                err_msg = f"Loopouts {domain1} and {domain2} are consecutive on strand {strand}. " \
+                          f"At least one of any consecutive pair must be a Domain, not a Loopout."
+                raise StrandError(strand, err_msg)
+
+    def set_helix_idx(self, old_idx: int, new_idx: int) -> None:
+        if new_idx in self.helices:
+            raise IllegalDesignError(f'cannot assign idx {new_idx} to helix {old_idx}; '
+                                     'another helix already has that index')
+        helix: Helix = self.helices[old_idx]
+        del self.helices[old_idx]
+        self.helices[new_idx] = helix
+        helix.idx = new_idx
+        for domain in helix.domains:
+            domain.helix = new_idx
+
+    def domain_at(self, helix: int, offset: int, forward: bool) -> Optional[Domain]:
+        """
+        Return :any:`Domain` that overlaps `offset` on helix with idx `helix` and has
+        :py:data:`Domain.forward` = ``True``, or ``None`` if there is no such :any:`Domain`.
+
+        :param helix: TODO
+        :param offset: TODO
+        :param forward: TODO
+        :return: TODO
+        """
+        for domain in self.domains_at(helix, offset):
+            if domain.forward == forward:
+                return domain
+        return None
+
+    def domains_at(self, helix: int, offset: int) -> List[Domain]:
+        """Return list of :any:`Domain`'s that overlap `offset` on helix with idx `helix`.
+
+        If constructed properly, this list should have 0, 1, or 2 elements."""
+        domains_on_helix = self.helices[helix].domains
+        # TODO: replace this with a faster algorithm using binary search
+        domains_on_helix = [domain for domain in domains_on_helix if
+                            domain.contains_offset(offset)]
+        if len(domains_on_helix) not in [0, 1, 2]:
+            raise AssertionError(f'There should be at most 2 domains on helix {helix}, '
+                                 f'but there are {len(domains_on_helix)}:\n{domains_on_helix}')
+        return domains_on_helix
+
+    # TODO: add_strand and insert_domain should check for existing deletions/insertion parallel strands
+    def add_strand(self, strand: Strand) -> None:
+        """Add `strand` to this design."""
+        self._check_strand_references_legal_helices(strand)
+        self.strands.append(strand)
+        for domain in strand.domains:
+            if isinstance(domain, Domain):
+                self.helices[domain.helix].domains.append(domain)
+                self._check_strands_overlap_legally(domain_to_check=domain)
+        if self.automatically_assign_color:
+            self._assign_color_to_strand(strand)
+
+    def remove_strand(self, strand: Strand) -> None:
+        """Remove `strand` from this design."""
+        self.strands.remove(strand)
+        for domain in strand.domains:
+            if isinstance(domain, Domain):
+                self.helices[domain.helix].domains.remove(domain)
+
+    def append_domain(self, strand: Strand, domain: Union[Domain, Loopout]) -> None:
+        """
+        Same as :any:`Design.insert_domain`, but inserts at end.
+
+        :param strand: strand to append `domain` to
+        :param domain: :any:`Domain` or :any:`Loopout` to append to :any:`Strand`
+        """
+        self.insert_domain(strand, len(strand.domains), domain)
+
+    def insert_domain(self, strand: Strand, order: int, domain: Union[Domain, Loopout]) -> None:
+        """Insert `Domain` into `strand` at index given by `order`. Uses same indexing as Python lists,
+        e.g., ``design.insert_domain(strand, domain, 0)``
+        inserts ``domain`` as the new first :any:`Domain`."""
+        if isinstance(domain, Domain) and domain.helix not in self.helices:
+            err_msg = f"domain {domain} refers to nonexistent Helix index {domain.helix}; " \
+                      f"here is the list of valid helices: {self._helices_to_string()}"
+            raise StrandError(strand, err_msg)
+
+        assert strand in self.strands
+        strand.insert_domain(order, domain)
+        self._check_strand_references_legal_helices(strand)
+        self._check_loopouts_not_consecutive_or_singletons_or_zero_length()
+        if isinstance(domain, Domain):
+            self.helices[domain.helix].domains.append(domain)
+            self._check_strands_overlap_legally(domain_to_check=domain)
+
+    def remove_domain(self, strand: Strand, domain: Union[Domain, Loopout]) -> None:
+        """Remove `Domain` from `strand`."""
+        assert strand in self.strands
+        strand.remove_domain(domain)
+        if isinstance(domain, Domain):
+            self.helices[domain.helix].domains.remove(domain)
+
+    def _build_domains_on_helix_lists(self) -> None:
+        for helix in self.helices.values():
+            helix._domains = []
+        for strand in self.strands:
+            for domain in strand.domains:
+                if isinstance(domain, Domain):
+                    if domain.helix in self.helices:
+                        self.helices[domain.helix].domains.append(domain)
+                    else:
+                        msg = f"domain's helix is {domain.helix} but no helix has that index; here " \
+                              f"is the list of helix indices: {self._helices_to_string()}"
+                        raise StrandError(strand=strand, the_cause=msg)
+
+    def _helices_to_string(self) -> str:
+        return ', '.join(map(str, self.helices.keys()))
+
+    # @_docstring_parameter was used to substitute sc in for the filename extension, but it is
+    # incompatible with .. code-block:: and caused a very strange and hard-to-determine error,
+    # so I removed it.
+    # @_docstring_parameter(default_extension=default_scadnano_file_extension)
+    def to_json(self, suppress_indent: bool = True) -> str:
+        """Return string representing this Design, suitable for reading by scadnano if written to
+        a JSON file ending in extension :attr:`default_scadnano_file_extension`.
+
+
+        :param suppress_indent: whether to suppress indenting JSON for "small" objects such as short lists,
+            e.g., grid coordinates. If True, something like this will be written:
+
+            .. code-block:: JSON
+
+              {
+                "grid_position": [1, 2]
+              }
+
+            instead of this:
+
+            .. code-block:: JSON
+
+              {
+                "grid_position": [
+                  1,
+                  2
+                ]
+              }
+
+        """
+        # if isinstance(self, DNAOrigamiDesign):
+        #     scaf = None
+        #     for strand in self.strands:
+        #         if strand.is_scaffold == True:
+        #             scaf = strand
+        #             break
+        #     if self.scaffold is None:
+        #         msg = 'No scaffold specified for Design. You can delay assigning the scaffold ' \
+        #               'until after creating the Design object, but you must assign a scaffold ' \
+        #               'using the method Strand.set_scaffold() before calling to_json().'
+        #         if scaf is not None:
+        #             msg += f'There is a strand marked as a scaffold. Try calling set_scaffold with it as ' \
+        #                    f'a parameter:\n{scaf}'
+        #         raise IllegalDesignError(msg)
+        return _json_encode(self, suppress_indent)
+
+    # TODO: create version of add_deletion and add_insertion that simply changes the major tick distance
+    #  on the helix at that position, as well as updating the end offset of the domain (and subsequent
+    #  domains on the same helix)
+
+    def add_deletion(self, helix: int, offset: int) -> None:
+        """Adds a deletion to every :class:`scadnano.Strand` at the given helix and base offset."""
+        domains = self.domains_at(helix, offset)
+        if len(domains) == 0:
+            raise IllegalDesignError(f"no domains are at helix {helix} offset {offset}")
+        for domain in domains:
+            if domain.contains_offset(offset):
+                domain.deletions.append(offset)
+
+    def add_insertion(self, helix: int, offset: int, length: int) -> None:
+        """Adds an insertion with the given length to every :class:`scadnano.Strand`
+        at the given helix and base offset, with the given length."""
+        domains = self.domains_at(helix, offset)
+        if len(domains) == 0:
+            raise IllegalDesignError(f"no domains are at helix {helix} offset {offset}")
+        for domain in domains:
+            if domain.contains_offset(offset):
+                domain.insertions.append((offset, length))
+
+    def set_start(self, domain: Domain, start: int) -> None:
+        """Sets ``Domain.start`` to `start`."""
+        assert domain in (domain for strand in self.strands for domain in strand.domains)
+        domain.set_start(start)
+        self._check_strands_overlap_legally(domain)
+
+    def set_end(self, domain: Domain, end: int) -> None:
+        """Sets ``Domain.end`` to `end`."""
+        assert domain in (domain for strand in self.strands for domain in strand.domains)
+        domain.set_end(end)
+        self._check_strands_overlap_legally(domain)
+
+    def move_strand_offsets(self, delta: int) -> None:
+        """Moves all strands backward (if `delta` < 0) or forward (if `delta` > 0) by `delta`."""
+        for strand in self.strands:
+            for domain in strand.bound_domains():
+                domain.start += delta
+                domain.end += delta
+        self._check_strands_overlap_legally()
+
+    def move_strands_on_helices(self, delta: int) -> None:
+        """Moves all strands up (if `delta` < 0) or down (if `delta` > 0) by the number of helices given by
+        `delta`."""
+        for strand in self.strands:
+            for domain in strand.bound_domains():
+                domain.helix += delta
+        self._check_strands_reference_helices_legally()
+
+    def assign_dna(self, strand: Strand, sequence: str, assign_complement: bool = True,
+                   domain: Union[Domain, Loopout] = None, check_length: bool = False) -> None:
+        """
+        Assigns `sequence` as DNA sequence of `strand`.
+
+        If any :class:`scadnano.Strand` is bound to `strand`,
+        it is assigned the reverse Watson-Crick complement of the relevant portion,
+        and any remaining portions of the other strand that have not already been assigned a DNA sequence
+        are assigned to be the symbol :py:data:`DNA_base_wildcard`.
+
+        Before assigning, `sequence` is first forced to be the same length as `strand` as follows:
+        If `sequence` is longer, it is truncated.
+        If `sequence` is shorter, it is padded with :py:data:`DNA_base_wildcard`'s.
+        This can be disabled by setting `check_length` to True, in which case the method raises an
+        :any:`IllegalDesignError` if the lengths do not match.
+
+        All whitespace in `sequence` is removed, and lowercase bases
+        'a', 'c', 'g', 't' are converted to uppercase.
+
+        :param strand:
+            :any:`Strand` to assign DNA sequence to
+        :param sequence:
+            string of DNA bases to assign
+        :param assign_complement:
+            Whether to assign the complement DNA sequence to any :any:`Strand` that
+            is bound to this one (default True)
+        :param domain:
+            :any:`Domain` on `strand` to assign. If ``None``, then the whole :any:`Strand` is
+            given a DNA sequence. Otherwise, only `domain` is assigned, and the rest of the :any:`Domain`'s
+            on `strand` are left alone (either keeping their DNA sequence, or being assigned
+            :py:const:`DNA_base_wildcard` if no DNA sequence was previously assigned.)
+            If `domain` is specified, then ``len(sequence)`` must be least than or equal to the number
+            of bases on `domain`. (i.e., ``domain.dna_length()``)
+        :param check_length:
+            If True, raises :any:`IllegalDesignError` if length of :any:`Strand` or :any:`Domain` being
+            assigned to does not match the length of the DNA sequence.
+        :raises IllegalDesignError:
+            If `check_length` is True and the length of :any:`Strand` or :any:`Domain` being
+            assigned to does not match the length of the DNA sequence.
+        """
+        start = 0
+        if domain is not None:
+            pos = strand.domains.index(domain)
+            start = sum(prev_dom.dna_length() for prev_dom in strand.domains[:pos])
+            if domain.dna_length() < len(sequence):
+                raise IllegalDesignError(f'cannot assign sequence {sequence} to strand domain '
+                                         f'\n{domain}\n'
+                                         f'The number of bases on the domain is {domain.dna_length()} '
+                                         f'but the length of the sequence is {len(sequence)}. The length of '
+                                         f'the sequence must be at most the number of bases on the domain.')
+            elif domain.dna_length() > len(sequence) and check_length:
+                raise IllegalDesignError(f'cannot assign sequence {sequence} to strand domain '
+                                         f'\n{domain}\n'
+                                         f'The number of bases on the domain is {domain.dna_length()} '
+                                         f'but the length of the sequence is {len(sequence)}. Since the '
+                                         f'parameter `check_length` is set to True, these lengths must '
+                                         f'be exactly equal.')
+        elif check_length and len(sequence) != strand.dna_length():
+            raise IllegalDesignError(f'cannot assign sequence {sequence} to strand '
+                                     f'\n{strand}\n'
+                                     f'The number of bases on the strand is {strand.dna_length()} '
+                                     f'but the length of the sequence is {len(sequence)}. Since the '
+                                     f'parameter `check_length` is set to True, these lengths must '
+                                     f'be exactly equal.')
+
+        padded_sequence = _pad_and_remove_whitespace_and_uppercase(sequence, strand, start)
+
+        if strand is None:
+            raise IllegalDesignError('strand cannot be None to assign DNA to it')
+        if strand not in self.strands:
+            raise StrandError(strand, 'strand is not in the given Design')
+
+        if strand.dna_sequence is None:
+            merged_sequence = padded_sequence
+        else:
+            try:
+                merged_sequence = _string_merge_wildcard(strand.dna_sequence, padded_sequence,
+                                                         DNA_base_wildcard)
+            except ValueError:
+                first_domain = strand.first_domain()
+                msg = f'strand starting at helix {first_domain.helix}, offset {first_domain.offset_5p()} has ' \
+                      f'length ' \
+                      f'{strand.dna_length()} and already has a DNA sequence assignment of length ' \
+                      f'{len(strand.dna_sequence)}, which is \n' \
+                      f'{strand.dna_sequence}, ' \
+                      f'but you tried to assign a different sequence of length {len(padded_sequence)} to ' \
+                      f'it, which is\n{padded_sequence}.'
+                raise IllegalDesignError(msg)
+
+        strand.set_dna_sequence(merged_sequence)
+
+        if not assign_complement:
+            return
+
+        for other_strand in self.strands:
+            # note that possibly strand==other_strand; it might bind to itself at some point and we want to
+            # allow a partial assignment to one domain to automatically assign the complement to the
+            # bound domain.
+            # However, if there are no wildcards in the assigned sequence we can safely skip strand.
+            if strand == other_strand \
+                    and strand.dna_sequence is not None \
+                    and DNA_base_wildcard not in strand.dna_sequence:
+                continue
+            if other_strand.overlaps(strand):
+                # we do this even if other_strand has a complete DNA sequence,
+                # because we get complementarity checks this way
+                other_strand.assign_dna_complement_from(strand)
+
+    def to_idt_bulk_input_format(self,
+                                 delimiter: str = ',',
+                                 key: Optional[KeyFunction[Strand]] = None,
+                                 warn_duplicate_name: bool = False,
+                                 only_strands_with_idt: bool = False,
+                                 export_scaffold: bool = False,
+                                 export_non_modified_strand_version: bool = False) -> str:
+        """Called by :py:meth:`Design.write_idt_bulk_input_file` to determine what string to write to
+        the file. This function can be used to get the string directly without creating a file.
+
+        Parameters have the same meaning as in :py:meth:`Design.write_idt_bulk_input_file`.
+
+        :return:
+            string that is written to the file in the method :py:meth:`Design.write_idt_bulk_input_file`.
+        """
+        strands_to_export = self._idt_strands_to_export(key=key, warn_duplicate_name=warn_duplicate_name,
+                                                        only_strands_with_idt=only_strands_with_idt,
+                                                        export_scaffold=export_scaffold,
+                                                        export_non_modified_strand_version=export_non_modified_strand_version)
+
+        idt_lines: List[str] = []
+        for strand in strands_to_export:
+            if strand.idt is None and only_strands_with_idt:
+                raise AssertionError(f'cannot export strand {strand} to IDT because it has no IDT field; '
+                                     f'since only_strands_with_idt is True, '
+                                     f'this strand should have been filtered out by _idt_strands_to_export')
+            if strand.idt is not None:
+                scale = strand.idt.scale
+                purification = strand.idt.scale
+            else:
+                scale = default_idt_scale
+                purification = default_idt_purification
+            idt_lines.append(delimiter.join(
+                [strand.idt_export_name(), strand.idt_dna_sequence(),
+                 scale, purification]
+            ))
+
+        idt_string = '\n'.join(idt_lines)
+        return idt_string
+
+    def _idt_strands_to_export(self, *,
+                               key: Optional[KeyFunction[Strand]] = None,  # for sorting strands
+                               warn_duplicate_name: bool,
+                               only_strands_with_idt: bool = False,
+                               export_scaffold: bool = False,
+                               export_non_modified_strand_version: bool = False) -> List[Strand]:
+        # gets list of strands to export for IDT export functions
+        added_strands: Dict[str, Strand] = {}  # dict: name -> strand
+        for strand in self.strands:
+            # skip scaffold unless requested to export
+            if strand.is_scaffold and not export_scaffold:
+                continue
+
+            # skip strands with no IDT field unless requested to export
+            if strand.idt is None and only_strands_with_idt:
+                continue
+
+            # figure out what name to export
+            name = strand.idt_export_name()
+
+            if name in added_strands:
+                existing_strand = added_strands[name]
+                self._check_strands_with_same_name_agree_on_other_idt_fields(strand, existing_strand, name,
+                                                                             warn_duplicate_name)
+
+            added_strands[name] = strand
+            if export_non_modified_strand_version:
+                added_strands[name + '_nomods'] = strand.no_modifications_version()
+
+        strands = list(added_strands.values())
+        if key is not None:
+            strands.sort(key=key)
+        return strands
+
+    @staticmethod
+    def _check_strands_with_same_name_agree_on_other_idt_fields(strand: Strand, existing_strand: Strand,
+                                                                name: str, warn_duplicate_name: bool) -> None:
+        # Handle the case that two strands being exported, strand and existing_strand
+        # (the latter was encountered first) have the same name
+        # This is allowed in case one wants to draw multiple copies of the same strand
+        # in scadnano without having to worry about setting their idt fields differently.
+        # But then we need to check that they agree on everything being exported.
+        if existing_strand.name is not None:
+            assert existing_strand.name == name
+        domain = strand.first_domain()
+        existing_domain = existing_strand.first_domain()
+        if warn_duplicate_name:
+            print(
+                f'WARNING: two strands with same IDT name {name}:\n'
+                f'  strand 1: helix {domain.helix}, 5\' end at offset {domain.offset_5p()}\n'
+                f'  strand 2: helix {existing_domain.helix}, 5\' end at offset '
+                f'{existing_domain.offset_5p()}\n')
+        if strand.dna_sequence != existing_strand.dna_sequence:
+            raise IllegalDesignError(
+                f'two strands with same IDT name {name} but different sequences:\n'
+                f'  strand 1: helix {domain.helix}, 5\' end at offset {domain.offset_5p()}, '
+                f'sequence: {strand.dna_sequence}\n'
+                f'  strand 2: helix {existing_domain.helix}, 5\' end at offset '
+                f'{existing_domain.offset_5p()}, '
+                f'sequence: {existing_strand.dna_sequence}\n')
+        elif strand.idt is not None \
+                and existing_strand.idt is not None:
+            if strand.idt.scale != existing_strand.idt.scale:
+                raise IllegalDesignError(
+                    f'two strands with same name {name} but different IDT scales:\n'
+                    f'  strand 1: helix {domain.helix}, 5\' end at offset {domain.offset_5p()}, '
+                    f'scale: {strand.idt.scale}\n'
+                    f'  strand 2: helix {existing_domain.helix}, 5\' end at offset '
+                    f'{existing_domain.offset_5p()}, '
+                    f'scale: {existing_strand.idt.scale}\n')
+            elif strand.idt.purification != existing_strand.idt.purification:
+                raise IllegalDesignError(
+                    f'two strands with same name {name} but different purifications:\n'
+                    f'  strand 1: helix {domain.helix}, 5\' end at offset {domain.offset_5p()}, '
+                    f'purification: {strand.idt.purification}\n'
+                    f'  strand 2: helix {existing_domain.helix}, 5\' end at offset '
+                    f'{existing_domain.offset_5p()}, '
+                    f'purification: {existing_strand.idt.purification}\n')
+
+    def write_idt_bulk_input_file(self, *, directory: str = '.', filename: str = None,
+                                  key: Optional[KeyFunction[Strand]] = None,
+                                  extension: Optional[str] = None,
+                                  delimiter: str = ',',
+                                  warn_duplicate_name: bool = True,
+                                  only_strands_with_idt: bool = False,
+                                  export_scaffold: bool = False,
+                                  export_non_modified_strand_version: bool = False) -> None:
+        """Write ``.idt`` text file encoding the strands of this :any:`Design` with the field
+        :any:`Strand.idt`, suitable for pasting into the "Bulk Input" field of IDT
+        (Integrated DNA Technologies, Coralville, IA, https://www.idtdna.com/),
+        with the output file having the same name as the running script but with ``.py`` changed to ``.idt``,
+        unless `filename` is explicitly specified.
+        For instance, if the script is named ``my_origami.py``,
+        then the sequences will be written to ``my_origami.idt``.
+        If `filename` is not specified but `extension` is, then that extension is used instead of ``idt``.
+        At least one of `filename` or `extension` must be ``None``.
+
+        The string written is that returned by :meth:`Design.to_idt_bulk_input_format`.
+
+        :param directory:
+            specifies a directory in which to place the file, either absolute or relative to
+            the current working directory. Default is the current working directory.
+        :param filename:
+            optinoal custom filename to use (instead of currently running script)
+        :param key:
+            `key function <https://docs.python.org/3/howto/sorting.html#key-functions>`_ used to determine
+            order in which to output strand sequences. Some useful defaults are provided by
+            :py:meth:`strand_order_key_function`
+        :param extension:
+            alternate filename extension to use (instead of idt)
+        :param delimiter:
+            is the symbol to delimit the four IDT fields name,sequence,scale,purification.
+        :param warn_duplicate_name:
+            if ``True`` prints a warning when two different :any:`Strand`'s have the same
+            :py:attr:`IDTField.name` and the same :any:`Strand.dna_sequence`. An :any:`IllegalDesignError` is
+            raised (regardless of the value of this parameter)
+            if two different :any:`Strand`'s have the same name but different sequences, IDT scales, or IDT
+            purifications.
+        :param only_strands_with_idt:
+            If False (the default), all non-scaffold sequences are output, with reasonable default values
+            chosen if the field :py:data:`Strand.idt` is missing.
+            (though scaffold is included if `export_scaffold` is True).
+            If True, then strands lacking the field :any:`Strand.idt` will not be exported.
+        :param export_scaffold:
+            If False (the default), scaffold sequences are not exported.
+            If True, scaffold sequences on strands output according to `only_strands_with_idt`
+            (i.e., scaffolds will be exported, unless they lack the field :any:`Strand.idt` and
+            `only_strands_with_idt` is True).
+        :param export_non_modified_strand_version:
+            For any :any:`Strand` with a :any:`Modification`, also export a version of the :any:`Strand`
+            without any modifications. The name for this :any:`Strand` is the original name with
+            '_nomods' appended to it.
+        """
+        contents = self.to_idt_bulk_input_format(delimiter=delimiter,
+                                                 key=key,
+                                                 warn_duplicate_name=warn_duplicate_name,
+                                                 only_strands_with_idt=only_strands_with_idt,
+                                                 export_scaffold=export_scaffold,
+                                                 export_non_modified_strand_version=export_non_modified_strand_version)
+        if extension is None:
+            extension = 'idt'
+        _write_file_same_name_as_running_python_script(contents, extension, directory, filename)
+
+    def write_idt_plate_excel_file(self, *, directory: str = '.', filename: str = None,
+                                   key: Optional[KeyFunction[Strand]] = None,
+                                   warn_duplicate_name: bool = False,
+                                   only_strands_with_idt: bool = False,
+                                   export_scaffold: bool = False,
+                                   use_default_plates: bool = True, warn_using_default_plates: bool = True,
+                                   plate_type: PlateType = PlateType.wells96,
+                                   export_non_modified_strand_version: bool = False) -> None:
+        """
+        Write ``.xls`` (Microsoft Excel) file encoding the strands of this :any:`Design` with the field
+        :py:data:`Strand.idt`, suitable for uploading to IDT
+        (Integrated DNA Technologies, Coralville, IA, https://www.idtdna.com/)
+        to describe a 96-well or 384-well plate
+        (https://www.idtdna.com/site/order/plate/index/dna/),
+        with the output file having the same name as the running script but with ``.py`` changed to ``.xls``,
+        unless `filename` is explicitly specified.
+        For instance, if the script is named ``my_origami.py``,
+        then the sequences will be written to ``my_origami.xls``.
+
+        :param directory:
+            specifies a directory in which to place the file, either absolute or relative to
+            the current working directory. Default is the current working directory.
+        :param filename:
+            custom filename if default (explained above) is not desired
+        :param key:
+            `key function <https://docs.python.org/3/howto/sorting.html#key-functions>`_ used to determine
+            order in which to output strand sequences. Some useful defaults are provided by
+            :py:meth:`strand_order_key_function`
+        :param warn_duplicate_name:
+            if ``True`` prints a warning when two different :any:`Strand`'s have the same
+            :py:attr:`IDTField.name` and the same :any:`Strand.dna_sequence`. An :any:`IllegalDesignError` is
+            raised (regardless of the value of this parameter)
+            if two different :any:`Strand`'s have the same name but different sequences, IDT scales, or IDT
+            purifications.
+        :param only_strands_with_idt:
+            If False (the default), all non-scaffold sequences are output, with reasonable default values
+            chosen if the field :py:data:`Strand.idt` is missing.
+            (though scaffold is included if `export_scaffold` is True).
+            If True, then strands lacking the field :any:`Strand.idt` will not be exported.
+            If False, then `use_default_plates` must be True.
+        :param export_scaffold:
+            If False (the default), scaffold sequences are not exported.
+            If True, scaffold sequences on strands output according to `only_strands_with_idt`
+            (i.e., scaffolds will be exported, unless they lack the field :any:`Strand.idt` and
+            `only_strands_with_idt` is True).
+        :param use_default_plates:
+            Use default values for plate and well (ignoring those in idt fields, which may be None).
+            If False, each Strand to export must have the field :py:data:`Strand.idt`, so in particular
+            the parameter `only_strands_with_idt` must be True.
+        :param warn_using_default_plates:
+            specifies whether, if `use_default_plates` is True, to print a warning for strands whose
+            :py:data:`Strand.idt` has the fields :py:data:`IDTFields.plate` and :py:data:`IDTFields.well`,
+            since `use_default_plates` directs these fields to be ignored.
+        :param plate_type:
+            a :any:`PlateType` specifying whether to use a 96-well plate or a 384-well plate
+            if the `use_default_plates` parameter is ``True``.
+            Ignored if `use_default_plates` is ``False``, because in that case the wells are explicitly set
+            by the user, who is free to use coordinates for either plate type.
+        :param export_non_modified_strand_version:
+            For any :any:`Strand` with a :any:`Modification`, also export a version of the :any:`Strand`
+            without any modifications. The name for this :any:`Strand` is the original name with
+            '_nomods' appended to it.
+        """
+
+        strands_to_export = self._idt_strands_to_export(key=key, warn_duplicate_name=warn_duplicate_name,
+                                                        only_strands_with_idt=only_strands_with_idt,
+                                                        export_scaffold=export_scaffold,
+                                                        export_non_modified_strand_version=export_non_modified_strand_version)
+
+        if not use_default_plates:
+            if not only_strands_with_idt:
+                raise ValueError('parameters use_default_plates and only_strands_with_idt '
+                                 'cannot both be False')
+            self._write_plates_assuming_explicit_plates_in_each_strand(directory, filename, strands_to_export)
+        else:
+            self._write_plates_default(directory=directory, filename=filename,
+                                       strands_to_export=strands_to_export,
+                                       plate_type=plate_type,
+                                       warn_using_default_plates=warn_using_default_plates)
+
+    def _write_plates_assuming_explicit_plates_in_each_strand(self, directory: str, filename: Optional[str],
+                                                              strands_to_export: List[Strand]) -> None:
+        plates = list({strand.idt.plate for strand in strands_to_export if strand.idt is not None if
+                       strand.idt.plate is not None})
+        if len(plates) == 0:
+            raise ValueError('Cannot write a a plate file since no plate data exists in any Strands '
+                             'in the design.\n'
+                             'Set the option use_default_plates=True in '
+                             "Design.write_idt_plate_excel_file\nif you don't want to enter plate "
+                             'and well positions for each Strand you wish to write to the Excel file.')
+        plates.sort()
+        filename_plate, workbook = self._setup_excel_file(directory, filename)
+        for plate in plates:
+            worksheet = self._add_new_excel_plate_sheet(plate, workbook)
+
+            strands_in_plate = [strand for strand in strands_to_export if
+                                strand.idt is not None and strand.idt.plate == plate]
+
+            strands_in_plate.sort(key=lambda s: (int(s.idt.well[1:]), s.idt.well[0]))  # type: ignore
+
+            for row, strand in enumerate(strands_in_plate):
+                if strand.idt is None:
+                    raise ValueError(f'cannot export strand {strand} to IDT because it has no idt field')
+                worksheet.write(row + 1, 0, strand.idt.well)
+                worksheet.write(row + 1, 1, strand.idt_export_name())
+                worksheet.write(row + 1, 2, strand.idt_dna_sequence())
+
+            workbook.save(filename_plate)
+
+    @staticmethod
+    def _add_new_excel_plate_sheet(plate_name: str, workbook: Any) -> Any:
+        worksheet = workbook.add_sheet(plate_name)
+        worksheet.write(0, 0, 'Well Position')
+        worksheet.write(0, 1, 'Name')
+        worksheet.write(0, 2, 'Sequence')
+        return worksheet
+
+    @staticmethod
+    def _setup_excel_file(directory: str, filename: Optional[str]) -> Tuple[str, Any]:
+        import xlwt  # type: ignore
+        plate_extension = f'xls'
+        if filename is None:
+            filename_plate = _get_filename_same_name_as_running_python_script(
+                directory, plate_extension, filename)
+        else:
+            filename_plate = _create_directory_and_set_filename(directory, filename)
+        workbook = xlwt.Workbook()
+        return filename_plate, workbook
+
+    def _write_plates_default(self, directory: str, filename: Optional[str], strands_to_export: List[Strand],
+                              plate_type: PlateType = PlateType.wells96,
+                              warn_using_default_plates: bool = True) -> None:
+        plate_coord = _PlateCoordinate(plate_type=plate_type)
+        plate = 1
+        excel_row = 1
+        filename_plate, workbook = self._setup_excel_file(directory, filename)
+        worksheet = self._add_new_excel_plate_sheet(f'plate{plate}', workbook)
+
+        for strand in strands_to_export:
+            if strand.idt is not None:
+                if warn_using_default_plates and strand.idt.plate is not None:
+                    print(
+                        f"WARNING: strand {strand} has plate entry {strand.idt.plate}, "
+                        f"which is being ignored since we are using default plate/well addressing")
+                if warn_using_default_plates and strand.idt.well is not None:
+                    print(
+                        f"WARNING: strand {strand} has well entry {strand.idt.well}, "
+                        f"which is being ignored since we are using default plate/well addressing")
+
+            well = plate_coord.well()
+            worksheet.write(excel_row, 0, well)
+            worksheet.write(excel_row, 1, strand.idt_export_name())
+            worksheet.write(excel_row, 2, strand.idt_dna_sequence())
+            plate_coord.increment()
+            if plate != plate_coord.plate():
+                workbook.save(filename_plate)
+                plate = plate_coord.plate()
+                worksheet = self._add_new_excel_plate_sheet(f'plate{plate}', workbook)
+                excel_row = 1
+            else:
+                excel_row += 1
+
+        workbook.save(filename_plate)
+
+    def to_oxdna_format(self) -> Tuple[str, str]:
+        raise NotImplementedError()
+
+    # @_docstring_parameter was used to substitute sc in for the filename extension, but it is
+    # incompatible with .. code-block:: and caused a very strange and hard-to-determine error,
+    # so I removed it.
+    # @_docstring_parameter(default_extension=default_scadnano_file_extension)
+    def write_scadnano_file(self, directory: str = '.', filename: str = None, extension: str = None,
+                            suppress_indent: bool = True) -> None:
+        """Write text file representing this :any:`Design`,
+        suitable for reading by the scadnano web interface,
+        with the output file having the same name as the running script but with ``.py`` changed to
+        :attr:`default_scadnano_file_extension`,
+        unless `filename` is explicitly specified.
+        For instance, if the script is named ``my_origami.py``,
+        then the design will be written to ``my_origami.sc``.
+        If `extension` is specified (but `filename` is not), then the design will be written to
+        ``my_origami.<extension>``
+
+        The string written is that returned by :meth:`Design.to_json`.
+
+
+
+        :param directory:
+            directory in which to put file (default: current working directory)
+        :param filename:
+            filename (default: name of script with ``.py`` replaced by
+            ``.sc``).
+            Mutually exclusive with `extension`
+        :param extension:
+            extension for filename (default: ``.sc``)
+            Mutually exclusive with `filename`
+        :param suppress_indent: whether to suppress indenting JSON for "small" objects such as short lists,
+            e.g., grid coordinates. If True, something like this will be written:
+
+            .. code-block:: JSON
+
+              {
+                "grid_position": [1, 2]
+              }
+
+            instead of this:
+
+            .. code-block:: JSON
+
+              {
+                "grid_position": [
+                  1,
+                  2
+                ]
+              }
+
+        """
+        self._check_legal_design()
+        contents = self.to_json(suppress_indent)
+        if filename is not None and extension is not None:
+            raise ValueError('at least one of filename or extension must be None')
+        if extension is None:
+            extension = default_scadnano_file_extension
+        _write_file_same_name_as_running_python_script(contents, extension, directory, filename)
+
+    def export_cadnano_v2(self, directory: str = '.', filename: Optional[str] = None) -> None:
+        """Write ``.json`` file representing this :any:`Design`, suitable for reading by cadnano v2.
+
+        The string written is that returned by :meth:`Design.to_cadnano_v2`.
+
+        :param directory:
+            directory in which to place the file, either absolute or relative to
+            the current working directory. Default is the current working directory.
+
+        :param filename:
+            The output file has the same name as the running script but with ``.py`` changed to ``.json``,
+            unless `filename` is explicitly specified.
+            For instance, if the script is named ``my_origami.py``,
+            then if filename is not specified, the design will be written to ``my_origami.json``.
+        """
+        content_serializable = OrderedDict({})
+        content_serializable['name'] = _get_filename_same_name_as_running_python_script(directory, 'json',
+                                                                                        filename)
+        content_serializable_final = self.to_cadnano_v2()
+        content_serializable.update(content_serializable_final)
+
+        encoder = _SuppressableIndentEncoder
+        contents = json.dumps(content_serializable, cls=encoder, indent=2)
+
+        _write_file_same_name_as_running_python_script(contents, 'json', directory, filename)
+
+    def add_nick(self, helix: int, offset: int, forward: bool, new_color: bool = True) -> None:
+        """Add nick to :any:`Domain` on :any:`Helix` with index `helix`,
+        in direction given by `forward`, at offset `offset`. The two :any:`Domain`'s created by this nick
+        will have 5'/3' ends at offsets `offset` and `offset-1`.
+
+        For example, if there is a :any:`Domain` with
+        :py:data:`Domain.helix` = ``0``,
+        :py:data:`Domain.forward` = ``True``,
+        :py:data:`Domain.start` = ``0``,
+        :py:data:`Domain.end` = ``10``,
+        then calling ``add_nick(helix=0, offset=5, forward=True)`` will split it into two :any:`Domain`'s,
+        with one domains having the fields
+        :py:data:`Domain.helix` = ``0``,
+        :py:data:`Domain.forward` = ``True``,
+        :py:data:`Domain.start` = ``0``,
+        :py:data:`Domain.end` = ``5``,
+        (recall that :py:data:`Domain.end` is exclusive, meaning that the largest offset on this
+        :any:`Domain` is 4 = ``offset-1``)
+        and the other domain having the fields
+        :py:data:`Domain.helix` = ``0``,
+        :py:data:`Domain.forward` = ``True``,
+        :py:data:`Domain.start` = ``5``,
+        :py:data:`Domain.end` = ``10``.
+
+        If the :any:`Strand` is circular, then it will be made linear with the 5' and 3' ends at the
+        nick position, modified in place. Otherwise, this :any:`Strand` will be deleted from the design,
+        and two new :any:`Strand`'s will be added.
+
+        :param helix:
+            index of helix where nick will occur
+        :param offset:
+            offset to nick (nick will be between offset and offset-1)
+        :param forward:
+            forward or reverse :any:`Domain` on `helix` at `offset`?
+        :param new_color:
+            whether to assign a new color to one of the :any:`Strand`'s resulting from the nick.
+            If False, both :any:`Strand`'s created have the same color as the original.
+            If True, one :any:`Strand` keeps the same color as the original and the other
+            is assigned a new color.
+        """
+        for domain_to_remove in self.domains_at(helix, offset):
+            if domain_to_remove.forward == forward:
+                break
+        else:
+            raise IllegalDesignError(f'no domain at helix {helix} in direction '
+                                     f'{"forward" if forward else "reverse"} at offset {offset}')
+        strand = domain_to_remove.strand()
+        domains = strand.domains
+        order = domains.index(domain_to_remove)
+        domains_before = domains[:order]
+        domains_after = domains[order + 1:]
+        domain_left: Domain[DomainLabel] = Domain(helix, forward, domain_to_remove.start, offset)
+        domain_right: Domain[DomainLabel] = Domain(helix, forward, offset, domain_to_remove.end)
+
+        # "before" and "after" mean in the 5' --> 3' direction, i.e., if a reverse domain:
+        # <--------]
+        # nicked like this:
+        # <---]<---]
+        # The before domain is on the right and the after domain is on the left.
+        #
+        # If nicking a forward domain:
+        # [-------->
+        # nicked like this:
+        # [--->[--->
+        # The before domain is on the left and the after domain is on the right.
+        if domain_to_remove.forward:
+            domain_to_add_before = domain_left
+            domain_to_add_after = domain_right
+        else:
+            domain_to_add_before = domain_right
+            domain_to_add_after = domain_left
+
+        seq_before_whole: Optional[str]
+        seq_after_whole: Optional[str]
+        if strand.dna_sequence is not None:
+            seq_before: str = ''.join(domain.dna_sequence() for domain in domains_before)  # type: ignore
+            seq_after: str = ''.join(domain.dna_sequence() for domain in domains_after)  # type: ignore
+            seq_on_domain_left: str = domain_to_remove.dna_sequence_in(  # type: ignore
+                domain_to_remove.start,
+                offset - 1)
+            seq_on_domain_right: str = domain_to_remove.dna_sequence_in(offset,  # type: ignore
+                                                                        domain_to_remove.end - 1)
+            if domain_to_remove.forward:
+                seq_on_domain_before = seq_on_domain_left
+                seq_on_domain_after = seq_on_domain_right
+            else:
+                seq_on_domain_before = seq_on_domain_right
+                seq_on_domain_after = seq_on_domain_left
+            seq_before_whole = seq_before + seq_on_domain_before
+            seq_after_whole = seq_on_domain_after + seq_after
+        else:
+            seq_before_whole = None
+            seq_after_whole = None
+
+        domains_before = domains_before + cast(List[Union[Domain, Loopout]], [domain_to_add_before])  # noqa
+        domains_after = cast(List[Union[Domain, Loopout]], [domain_to_add_after]) + domains_after  # noqa
+
+        if strand.circular:
+            # if strand is circular, we modify its domains in place
+            domains = domains_after + domains_before
+            strand.set_domains(domains)
+
+            # DNA sequence was rotated, so re-assign it
+            if seq_before_whole is not None and seq_after_whole is not None:
+                seq = seq_before_whole + seq_after_whole
+                strand.set_dna_sequence(seq)
+
+            strand.set_linear()
+
+        else:
+            # if strand is not circular, we delete it and create two new strands
+            self.strands.remove(strand)
+
+            idt_present = strand.idt is not None
+            strand_before: Strand[StrandLabel, DomainLabel] = Strand(
+                domains=domains_before,
+                dna_sequence=seq_before_whole,
+                color=strand.color,
+                idt=strand.idt if idt_present else None,
+            )
+
+            color_after = next(self.color_cycler) if new_color else strand.color
+            strand_after: Strand[StrandLabel, DomainLabel] = Strand(
+                domains=domains_after,
+                dna_sequence=seq_after_whole,
+                color=color_after,
+            )
+
+            self.strands.extend([strand_before, strand_after])
+
+        helix_domains = self.helices[helix].domains
+        idx_domain_to_remove = helix_domains.index(domain_to_remove)
+        helix_domains[idx_domain_to_remove] = domain_left
+        helix_domains.insert(idx_domain_to_remove + 1, domain_right)
+
+    def ligate(self, helix: int, offset: int, forward: bool) -> None:
+        """
+        Reverse operation of :py:meth:`Design.add_nick`.
+        "Ligates" a nick between two adjacent :any:`Domain`'s in the same direction on a :any:`Helix`
+        with index `helix`,
+        in direction given by `forward`, at offset `offset`.
+
+        For example, if there are a :any:`Domain`'s with
+        :py:data:`Domain.helix` = ``0``,
+        :py:data:`Domain.forward` = ``True``,
+        :py:data:`Domain.start` = ``0``,
+        :py:data:`Domain.end` = ``5``,
+        (recall that :py:data:`Domain.end` is exclusive, meaning that the largest offset on this
+        :any:`Domain` is 4 = ``offset-1``)
+        and the other domain having the fields
+        :py:data:`Domain.helix` = ``0``,
+        :py:data:`Domain.forward` = ``True``,
+        :py:data:`Domain.start` = ``5``,
+        :py:data:`Domain.end` = ``10``.
+        then calling ``ligate(helix=0, offset=5, forward=True)`` will combine them into one :any:`Domain`,
+        having the fields
+        :py:data:`Domain.helix` = ``0``,
+        :py:data:`Domain.forward` = ``True``,
+        :py:data:`Domain.start` = ``0``,
+        :py:data:`Domain.end` = ``10``.
+
+        If the :any:`Domain`'s are on the same :any:`Strand` (i.e., they are the 5' and 3' ends of that
+        :any:`Strand`, which is necessarily linear), then the :any:`Strand` is made is circular in place,
+        Otherwise, the two :any:`Strand`'s of each :any:`Domain` will be joined into one,
+        replacing the previous strand on the 5'-most side of the nick (i.e., the one whose 3' end
+        terminated at the nick), and deleting the other strand.
+
+        :param helix:
+            index of helix where nick will be ligated
+        :param offset:
+            offset to ligate (nick to ligate must be between offset and offset-1)
+        :param forward:
+            forward or reverse :any:`Domain` on `helix` at `offset`?
+        """
+        for dom_right in self.domains_at(helix, offset):
+            if dom_right.forward == forward:
+                break
+        else:
+            raise IllegalDesignError(f'no domain at helix {helix} in direction '
+                                     f'{"forward" if forward else "reverse"} at offset {offset}')
+        for dom_left in self.domains_at(helix, offset - 1):
+            if dom_left.forward == forward:
+                break
+        else:
+            raise IllegalDesignError(f'no domain at helix {helix} in direction '
+                                     f'{"forward" if forward else "reverse"} at offset {offset}')
+        if dom_right.start != offset:
+            raise IllegalDesignError(f'to ligate at offset {offset}, it must be the start offset of a domain,'
+                                     f'but there is no domain at helix {helix} in direction '
+                                     f'{"forward" if forward else "reverse"} with start offset = {offset}')
+        if dom_left.end != offset:
+            raise IllegalDesignError(f'to ligate at offset {offset}, it must be the end offset of a domain,'
+                                     f'but there is no domain at helix {helix} in direction '
+                                     f'{"forward" if forward else "reverse"} with end offset = {offset}')
+
+        strand_left = dom_left.strand()
+        strand_right = dom_right.strand()
+
+        dom_new: Domain[DomainLabel] = Domain(helix=helix, forward=forward, start=dom_left.start,
+                                              end=dom_right.end,
+                                              deletions=dom_left.deletions + dom_right.deletions,
+                                              insertions=dom_left.insertions + dom_right.insertions,
+                                              name=dom_left.name, label=dom_left.label)
+
+        # normalize 5'/3' distinction; below refers to which Strand has the 5'/3' end that will be ligated
+        # So strand_5p is the one whose 3' end will be the 3' end of the whole new Strand
+        # strand_5p and dom_5p are the ones on the 5' side of the nick,
+        # CAUTION: they are on the 3' side of the nick,
+        # i.e., the 3' end of strand_5p will be the 3' end of the new strand
+        # e.g.,
+        #
+        #  strand_3p  strand_5p
+        # [--------->[--------->
+        #    dom_3p     dom_5p
+        #
+        # or
+        #
+        #  strand_5p  strand_3p
+        # <---------]<---------]
+        #    dom_5p     dom_3p
+        if not forward:
+            dom_5p = dom_left
+            dom_3p = dom_right
+            strand_5p = strand_left
+            strand_3p = strand_right
+        else:
+            dom_5p = dom_right
+            dom_3p = dom_left
+            strand_5p = strand_right
+            strand_3p = strand_left
+
+        if strand_left is strand_right:
+            # join domains and make strand circular
+            strand = strand_left
+            assert strand.first_bound_domain() is dom_5p
+            assert strand.last_bound_domain() is dom_3p
+            strand.domains[0] = dom_new  # set first domain equal to new joined domain
+            strand.domains.pop()  # remove last domain
+            strand.set_circular()
+            for domain in strand.domains:
+                domain._parent_strand = strand
+
+        else:
+            # join strands
+            strand_3p.domains.pop()
+            strand_3p.domains.append(dom_new)
+            strand_3p.domains.extend(strand_5p.domains[1:])
+            strand_3p.is_scaffold = strand_left.is_scaffold or strand_right.is_scaffold
+            strand_3p.set_modification_3p(strand_5p.modification_3p)
+            for idx, mod in strand_5p.modifications_int.items():
+                new_idx = idx + strand_3p.dna_length()
+                strand_3p.set_modification_internal(new_idx, mod)
+            if strand_3p.dna_sequence is not None and strand_5p.dna_sequence is not None:
+                strand_3p.dna_sequence += strand_5p.dna_sequence
+            if strand_5p.is_scaffold and not strand_3p.is_scaffold and strand_5p.color is not None:
+                strand_3p.set_color(strand_5p.color)
+            self.strands.remove(strand_5p)
+            for domain in strand_3p.domains:
+                domain._parent_strand = strand_3p
+
+        helix_domains = self.helices[helix].domains
+        idx_domain_to_remove_left = helix_domains.index(dom_left)
+        helix_domains[idx_domain_to_remove_left] = dom_new
+        helix_domains.remove(dom_right)
+
+    def add_half_crossover(self, helix: int, helix2: int, offset: int, forward: bool,
+                           offset2: int = None, forward2: bool = None) -> None:
+        """
+        Add a half crossover from helix `helix` at offset `offset` to `helix2`, on the strand
+        with :py:data:`Strand.forward` = `forward`.
+
+        Unlike :py:meth:`Design.add_full_crossover`, which automatically adds a nick between the two
+        half-crossovers, to call this method, there must *already* be nicks adjacent to the given
+        offsets on the given helices. (either on the left or right side)
+
+        If the crossover is within a :any:`Strand`, i.e., between its 5' and ' ends, the :any:`Strand`
+        will simply be made circular, modifying it in place. Otherwise, the old two :any:`Strand`'s will be
+        deleted, and a new :any:`Strand` added.
+
+        :param helix:
+            index of one helix of half crossover
+        :param helix2:
+            index of other helix of half crossover
+        :param offset:
+            offset on `helix` at which to add half crossover
+        :param forward:
+            direction of :any:`Strand` on `helix` to which to add half crossover
+        :param offset2:
+            offset on `helix2` at which to add half crossover.
+            If not specified, defaults to `offset`
+        :param forward2: direction of :any:`Strand` on `helix2` to which to add half crossover.
+            If not specified, defaults to the negation of `forward`
+        """
+        if offset2 is None:
+            offset2 = offset
+        if forward2 is None:
+            forward2 = not forward
+        domain1 = self.domain_at(helix, offset, forward)
+        domain2 = self.domain_at(helix2, offset2, forward2)
+        if domain1 is None:
+            raise IllegalDesignError(
+                f"Cannot add half crossover at (helix={helix}, offset={offset}). "
+                f"There is no Domain there.")
+        if domain2 is None:
+            raise IllegalDesignError(
+                f"Cannot add half crossover at (helix={helix2}, offset={offset2}). "
+                f"There is no Domain there.")
+        strand1 = domain1.strand()
+        strand2 = domain2.strand()
+
+        if strand1 == strand2:
+            strand1.set_circular()
+            return
+            # raise IllegalDesignError(f"Cannot add crossover from "
+            #                          f"(helix={helix}, offset={offset}) to "
+            #                          f"(helix={helix2}, offset={offset2}) "
+            #                          f"because that would join two Domains "
+            #                          f"already on the same Strand! "
+            #                          f"Currently circular Strands are not supported. "
+            #                          f"Instead, try adding nicks first, or rearrange the order of "
+            #                          f"crossover addition, to ensure that all strands are "
+            #                          f"non-circular, even in intermediate stages.")
+
+        if domain1.offset_3p() == offset and domain2.offset_5p() == offset2:
+            strand_first = strand1
+            strand_last = strand2
+        elif domain1.offset_5p() == offset and domain2.offset_3p() == offset2:
+            strand_first = strand2
+            strand_last = strand1
+        else:
+            raise IllegalDesignError("Cannot add half crossover. Must have one domain have its "
+                                     "5' end at the given offset and the other with its 3' end at the "
+                                     "given offset, but this is not the case.")
+
+        new_domains = strand_first.domains + strand_last.domains
+        if strand_first.dna_sequence is None and strand_last.dna_sequence is None:
+            new_dna = None
+        elif strand_first.dna_sequence is not None and strand_last.dna_sequence is not None:
+            new_dna = strand_first.dna_sequence + strand_last.dna_sequence
+        else:
+            raise IllegalDesignError(
+                'cannot add crossover between two strands if one has a DNA sequence '
+                'and the other does not')
+        new_strand: Strand[StrandLabel, DomainLabel] = Strand(domains=new_domains, color=strand_first.color,
+                                                              dna_sequence=new_dna, idt=strand_first.idt,
+                                                              is_scaffold=strand1.is_scaffold or strand2.is_scaffold)
+
+        # put new strand in place where strand_first was
+        strand_first_idx = self.strands.index(strand_first)
+        self.strands[strand_first_idx] = new_strand
+        self.strands.remove(strand_last)
+
+    def add_full_crossover(self, helix: int, helix2: int, offset: int, forward: bool,
+                           offset2: int = None, forward2: bool = None) -> None:
+        """
+        Adds two half-crossovers, one at `offset` and another at `offset`-1.
+        Other arguments have the same meaning as in :py:meth:`Design.add_half_crossover`.
+        A nick is automatically added on helix `helix` between
+        `offset` and `offset`-1 if one is not already present,
+        and similarly for `offset2` on helix `helix2`.
+
+        :param helix:
+            index of one helix of half crossover
+        :param helix2:
+            index of other helix of half crossover
+        :param offset:
+            offset on `helix` at which to add half crossover
+        :param forward:
+            direction of :any:`Strand` on `helix` to which to add half crossover
+        :param offset2:
+            offset on `helix2` at which to add half crossover.
+            If not specified, defaults to `offset`
+        :param forward2: direction of :any:`Strand` on `helix2` to which to add half crossover.
+            If not specified, defaults to the negation of `forward`
+        """
+        if offset2 is None:
+            offset2 = offset
+        if forward2 is None:
+            forward2 = not forward
+        for helix_, forward_, offset_ in [(helix, forward, offset), (helix2, forward2, offset2)]:
+            self._prepare_nicks_for_full_crossover(helix_, forward_, offset_)
+        self.add_half_crossover(helix=helix, helix2=helix2, offset=offset - 1, offset2=offset2 - 1,
+                                forward=forward, forward2=forward2)
+        self.add_half_crossover(helix=helix, helix2=helix2, offset=offset, offset2=offset2,
+                                forward=forward, forward2=forward2)
+
+    def _prepare_nicks_for_full_crossover(self, helix: int, forward: bool, offset: int) -> None:
+        domain_right = self.domain_at(helix, offset, forward)
+        if domain_right is None:
+            raise IllegalDesignError(f'You tried to create a full crossover at '
+                                     f'(helix={helix}, offset={offset}) '
+                                     f'but there is no Strand there.')
+        domain_left = self.domain_at(helix, offset - 1, forward)
+        if domain_left is None:
+            raise IllegalDesignError(f'You tried to create a full crossover at '
+                                     f'(helix={helix}, offset={offset}) '
+                                     f'but there is no Strand at offset {offset - 1}.')
+        if domain_left == domain_right:
+            self.add_nick(helix, offset, forward)
+        else:
+            assert domain_left.end == domain_right.start
+
+    def inline_deletions_insertions(self) -> None:
+        """
+        Converts deletions and insertions by "inlining" them. Insertions and deletions are removed,
+        and their domains have their lengths altered. Also, major tick marks on the helices will be
+        shifted to preserve their adjacency to bases already present. For example, if there are major
+        tick marks at 0, 8, 18, 24, and a deletion between 0 and 8:
+
+        .. code-block:: none
+
+            0      8         18    24    30
+            |--X---|---------|-----|------
+
+        then the domain is shortened by 1,
+        the tick marks become 0, 7, 15, 23,
+        and the helix's maximum offset is shrunk by 1:
+
+        .. code-block:: none
+
+            0     7         17    23    29
+            |-----|---------|-----|------
+
+        Similarly, if there are insertions (in the example below, the "2" represents an insertion of length 2,
+        which represents 3 total bases), they are expanded
+
+        .. code-block:: none
+
+            0      8         18    24    30
+            |--2---|---------|-----|------
+
+        then the domain is lengthened by 3:
+
+        .. code-block:: none
+
+            0        10        20    26    32
+            |--------|---------|-----|------
+
+        And it works if there are both insertions and deletions:
+
+        .. code-block:: none
+
+            0      8         18    24    30
+            |--2---|---------|--X--|------
+
+        then the domain is lengthened by 3:
+
+        .. code-block:: none
+
+            0        10        20   25    31
+            |--------|---------|----|------
+
+        We assume that a major tick mark appears just to the LEFT of the offset it encodes,
+        so the minimum and maximum offsets for tick marks are respectively the helix's minimum offset
+        and 1 plus its maximum offset, the latter being just to the right of the last offset on the helix.
+        """
+        for helix in self.helices.values():
+            self._inline_deletions_insertions_on_helix(helix)
+
+    def _inline_deletions_insertions_on_helix(self, helix: Helix) -> None:
+        ###################################################
+        # first gather information before changing anything
+
+        # gather all mods on helix
+        deletions = [deletion for domain in helix.domains for deletion in domain.deletions]
+        insertions = [insertion for domain in helix.domains for insertion in domain.insertions]
+
+        # change max offset
+        delta_length = sum(length for (offset, length) in insertions) - len(deletions)
+
+        # combined collection of deletions/insertions into one dict mapping offset --> None/len, where
+        # value of -1 indicates deletion, and otherwise is length of insertion
+        dels_ins = dict()
+        for deletion in deletions:
+            dels_ins[deletion] = -1
+        for insertion in insertions:
+            dels_ins[insertion[0]] = insertion[1]
+
+        # put offsets in sorted order
+        dels_ins_offsets_sorted = sorted(dels_ins.keys())
+
+        # fix helix major ticks
+        group = self.groups[helix.group]
+        major_ticks = sorted(helix.calculate_major_ticks(group.grid))
+
+        ###################################################
+        # now that info is gathered, start changing things
+
+        if helix.max_offset is not None:
+            helix.max_offset += delta_length
+
+        if len(major_ticks) > 0:
+            major_tick_idx = 0
+            delta_acc = 0  # accumulated delta; insertions add to this and deletions subtract from it
+            for offset in dels_ins_offsets_sorted:
+                # go to first major tick great than offset, updating passed ones by delta_acc
+                while major_tick_idx < len(major_ticks) and major_ticks[major_tick_idx] <= offset:
+                    major_ticks[major_tick_idx] += delta_acc
+                    major_tick_idx += 1
+                delta_acc += dels_ins[offset]
+            # if necessary, update major ticks beyond last ins/del
+            while major_tick_idx < len(major_ticks):
+                major_ticks[major_tick_idx] += delta_acc
+                major_tick_idx += 1
+            # TODO: check if regularly spaced and reaching both ends, and if so set helix.major_tick_distance
+            helix.major_ticks = major_ticks
+
+        # fix domain start/end offsets
+        domains = sorted(helix.domains, key=lambda domain: domain.start)
+        delta_acc = 0
+        for domain in domains:
+            domain.start += delta_acc
+            delta_acc += domain.dna_length() - domain.visual_length()
+            domain.end += delta_acc
+            domain.deletions = []
+            domain.insertions = []
+
+    def reverse_all(self) -> None:
+        """
+        Reverses "polarity" of every :any:`Strand` in this :any:`Design`.
+
+        No attempt is made to make any assigned DNA sequences match by reversing or rearranging them.
+        Every :any:`Strand` keeps the same DNA sequence it had before (unreversed), if one was assigned.
+        It is recommended to assign/reassign DNA sequences *after* doing this operation.
+        """
+        for strand in self.strands:
+            strand.reverse()
+
+    def set_major_tick_distance(self, major_tick_distance: int) -> None:
+        for helix in self.helices.values():
+            helix.major_tick_distance = major_tick_distance
+
+    def _ensure_helices_distinct_objects(self) -> None:
+        pair = _find_index_pair_same_object(self.helices)
+        if pair is not None:
+            i, j = pair
+            raise IllegalDesignError('helices must all be distinct objects, but those at indices '
+                                     f'{i} and {j} are the same object')
+
+    def _ensure_strands_distinct_objects(self) -> None:
+        pair = _find_index_pair_same_object(self.strands)
+        if pair is not None:
+            i, j = pair
+            raise IllegalDesignError('strands must all be distinct objects, but those at indices '
+                                     f'{i} and {j} are the same object')
+
+    def _ensure_helix_groups_exist(self) -> None:
+        for helix in self.helices.values():
+            if helix.group not in self.groups.keys():
+                raise IllegalDesignError(f'helix {helix.idx} has group {helix.group}, which does not '
+                                         f'exist in the design. The valid groups are '
+                                         f'{", ".join(self.groups.keys())}')
+
+    def _has_default_groups(self) -> bool:
+        return len(self.groups) == 1 and default_group_name in self.groups
+
+    def _assign_default_helices_view_orders_to_groups(self) -> None:
+        for name, group in self.groups.items():
+            if group.helices_view_order is None:
+                helices_in_group = {idx: helix for idx, helix in self.helices.items() if helix.group == name}
+                group._assign_default_helices_view_order(helices_in_group)  # noqa
+
+    def _warn_if_strand_names_not_unique(self) -> None:
+        names = [strand.name for strand in self.strands if strand.name is not None]
+        if len(names) > len(set(names)):
+            for name1, name2 in itertools.combinations(names, 2):
+                if name1 == name2:
+                    print(f'WARNING: there are two strands with name {name1}')
+
+    def strand_with_name(self, name: str) -> Optional[Strand]:
+        """
+        :param name: name of a :any:`Strand`.
+        :return: the :any:`Strand` with name `name`, or None if no :any:`Strand` in the :any:`Design` has
+                 that name.
+        """
+        for strand in self.strands:
+            if strand.name == name:
+                return strand
+        return None
+
+
+def _find_index_pair_same_object(elts: Union[List, Dict]) -> Optional[Tuple]:
+    # return pair of indices representing same object in elts, or None if they do not exist
+    # input can be list or dict; if dict, returns pair of keys mapping to same object
+    if isinstance(elts, list):
+        elts = dict(enumerate(elts))
+    for i, j in itertools.combinations(elts.keys(), 2):
+        if elts[i] is elts[j]:
+            return i, j
+    return None
+
+
+def _name_of_this_script() -> str:
+    """Return name of the currently running script, WITHOUT the .py extension."""
+    return os.path.basename(sys.argv[0])[:-3]
+
+
+def _write_file_same_name_as_running_python_script(contents: str, extension: str, directory: str = '.',
+                                                   filename: Optional[str] = None) -> None:
+    relative_filename = _get_filename_same_name_as_running_python_script(directory, extension, filename)
+    with open(relative_filename, 'w') as out_file:
+        out_file.write(contents)
+
+
+def _get_filename_same_name_as_running_python_script(directory: str, extension: str,
+                                                     filename: Optional[str]) -> str:
+    if filename is None:
+        filename = _name_of_this_script() + f'.{extension}'
+    relative_filename = _create_directory_and_set_filename(directory, filename)
+    return relative_filename
+
+
+def _create_directory_and_set_filename(directory: str, filename: str) -> str:
+    if not os.path.exists(directory):
+        os.makedirs(directory)
+    relative_filename = os.path.join(directory, filename)
+    return relative_filename
diff --git a/tutorial/tutorial.md b/tutorial/tutorial.md
index 59c10391..ecc42e9a 100644
--- a/tutorial/tutorial.md
+++ b/tutorial/tutorial.md
@@ -561,7 +561,7 @@ for strand in design.strands:
 Or you could use Python's I/O library to write them to a file in a format of your choosing.
 
 scadnano provides utility methods 
-[`Design.write_idt_bulk_input_file`](https://scadnano-python-package.readthedocs.io/en/latest/#scadnano.Design.write_idt_plate_excel_file) (for ordering in test tubes) and [`Design.write_idt_plate_excel_file`](https://scadnano-python-package.readthedocs.io/en/latest/#scadnano.Design.write_idt_bulk_input_file) (for ordering in 96- or 384-well plates) for exporting to file formats recognized by the DNA synthesis company IDT ([Integrated DNA Technologies](https://www.idtdna.com/pages)). 
+[`Design.write_idt_bulk_input_file`](https://scadnano-python-package.readthedocs.io/en/latest/#scadnano.Design.write_idt_plate_excel_file) (for ordering in test tubes) and [`Design.write_idt_plate_excel_file`](https://scadnano-python-package.readthedocs.io/en/latest/#scadnano.Design.write_idt_plate_excel_file) (for ordering in 96- or 384-well plates) for exporting to file formats recognized by the DNA synthesis company IDT ([Integrated DNA Technologies](https://www.idtdna.com/pages)). 
 
 Since this doesn't change the design, we put the code to export the IDT plate file outside of the `create_design()` function, in our `main()` function:
 


Test patch:
diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py
index c172458b..f5810b5f 100644
--- a/tests/scadnano_tests.py
+++ b/tests/scadnano_tests.py
@@ -2754,8 +2754,29 @@ def test_set_helix_idx(self) -> None:
         self.assertEqual(8, ss5.end)
 
 
-class TestHelixGroups(unittest.TestCase):
+class TestDesignPitchYawRollOfHelix(unittest.TestCase):
+    def setUp(self) -> None:
+        n = 'north'
+        helix = sc.Helix(max_offset=12, group=n, grid_position=(1,2), roll=4)
+
+        group_north = sc.HelixGroup(position=sc.Position3D(x=0, y=-200, z=0), grid=sc.square, pitch=12, yaw=40, roll=32)
+        self.design = sc.Design(helices=[helix], groups={n: group_north}, strands=[])
+        self.helix = helix
+
+
+    def test_design_pitch_of_helix(self) -> None:
+        self.assertEqual(12,  self.design.pitch_of_helix(self.helix))
+
+
+    def test_design_yaw_of_helix(self) -> None:
+        self.assertEqual(40,  self.design.yaw_of_helix(self.helix))
 
+
+    def test_design_roll_of_helix(self) -> None:
+        self.assertEqual(36,  self.design.roll_of_helix(self.helix))
+
+
+class TestHelixGroups(unittest.TestCase):
     def setUp(self) -> None:
         n = 'north'
         e = 'east'
@@ -2873,6 +2894,7 @@ def test_helix_groups_to_from_JSON(self) -> None:
         design_from_json = sc.Design.from_scadnano_json_str(design_json_str)
         self._asserts_for_fixture(design_from_json)
 
+
     def test_helix_groups_fail_nonexistent(self) -> None:
         helices = [
             sc.Helix(max_offset=20, group="north"),
@@ -3108,6 +3130,79 @@ def test_strand_names_can_be_nonunique(self) -> None:
 
 class TestJSON(unittest.TestCase):
 
+    def test_grid_design_level_converted_to_enum_from_string(self) -> None:
+        # reproduces an error where the grid was stored as a string instead of the Grid enum type
+        json_str = '''
+{
+  "version": "0.14.0",
+  "grid": "square",
+  "helices": [
+    {"grid_position": [0, 0]},
+    {"grid_position": [0, 1]}
+  ],
+  "strands": []
+}
+    '''
+        design = sc.Design.from_scadnano_json_str(json_str)
+        grid = design.grid
+        self.assertTrue(type(grid) is sc.Grid)
+        self.assertEqual(sc.Grid.square, grid)
+
+
+    def test_grid_helix_group_level_converted_to_enum_from_string(self) -> None:
+        # reproduces an error where the grid was stored as a string instead of the Grid enum type
+        json_str = '''
+{
+  "version": "0.15.0",
+  "groups": {
+    "north": {
+      "position": {"x": 0, "y": -10, "z": 0},
+      "grid": "honeycomb"
+    },
+    "east": {
+      "position": {"x": 0, "y": 0, "z": 10},
+      "grid": "square"
+    },
+    "south": {
+      "position": {"x": 0, "y": 10, "z": 0},
+      "grid": "hex"
+    },
+    "west": {
+      "position": {"x": 0, "y": 0, "z": -10},
+      "grid": "none"
+    }
+  },
+  "helices": [
+    {"group": "north", "max_offset": 20, "grid_position": [0, 0], "idx": 0},
+    {"group": "north", "max_offset": 21, "grid_position": [1, 0], "idx": 1},
+    {"group": "north", "max_offset": 19, "grid_position": [1, 1], "idx": 2},
+    {"group": "north", "max_offset": 18, "grid_position": [0, 1], "idx": 3},
+    {"group": "north", "max_offset": 17, "grid_position": [-1, 1], "idx": 4},
+    {"group": "north", "max_offset": 16, "grid_position": [-1, 0], "idx": 5},
+    {"group": "south", "max_offset": 24, "grid_position": [0, 1], "idx": 6},
+    {"group": "south", "max_offset": 25, "grid_position": [0, 0], "idx": 7},
+    {"group": "west", "max_offset": 26, "position": {"x": 0, "y": 0, "z": 0}, "idx": 8},
+    {"group": "west", "max_offset": 27, "position": {"x": 0, "y": 3, "z": 0}, "idx": 9},
+    {"group": "east", "max_offset": 22, "grid_position": [0, 0], "idx": 13},
+    {"group": "east", "max_offset": 23, "grid_position": [0, 1], "idx": 15}
+  ],
+  "strands": []
+}
+    '''
+        design = sc.Design.from_scadnano_json_str(json_str)
+
+        grid_n = design.groups['north'].grid
+        grid_e = design.groups['east'].grid
+        grid_s = design.groups['south'].grid
+        grid_w = design.groups['west'].grid
+        for grid in [grid_n, grid_e, grid_s, grid_w]:
+            self.assertTrue(type(grid) is sc.Grid)
+
+        self.assertEqual(sc.Grid.honeycomb, grid_n)
+        self.assertEqual(sc.Grid.square, grid_e)
+        self.assertEqual(sc.Grid.hex, grid_s)
+        self.assertEqual(sc.Grid.none, grid_w)
+
     def test_legacy_idt_name_import__no_strand_name(self) -> None:
         # tests proper importing of old format when name was a subfield of idt;
         # ensures if that exists and no Strand.name field exists, the idt.name is used as Strand.name
@@ -3544,6 +3639,332 @@ def test_color_specified_with_integer(self) -> None:
         actual_color_hex = d.strands[0].color.to_json_serializable(False)
         self.assertEqual(expected_color_hex, actual_color_hex)
 
+    def test_single_helix_group_and_helices_specify_pitch_and_yaw(self) -> None:
+        json_str = """
+        {
+          "helices": [
+            {
+              "group": "north",
+              "position": {"x": 1, "y": 2, "z": 3},
+              "pitch": 4,
+              "roll": 5,
+              "yaw": 6
+            },
+            {
+              "group": "north",
+              "position": {"x": 3, "y": 2, "z": 3},
+              "roll": 15
+            }
+          ],
+          "groups": {
+            "north": {
+              "position": {"x": 0, "y": -200, "z": 0},
+              "pitch": 21,
+              "yaw": 13,
+              "grid": "none"
+            }
+          },
+          "strands": [ 
+            { 
+              "color": "#0066cc", 
+              "domains": [ {"helix": 0, "forward": true, "start": 0, "end": 32} ]
+            } 
+          ] 
+        }
+        """
+        d = sc.Design.from_scadnano_json_str(json_str)
+        helix0 = d.helices[0]
+        helix1 = d.helices[1]
+        self.assertEqual(sc.Position3D(1, 2, 3), helix0.position)
+        self.assertEqual(5, helix0.roll)
+        # Helix 0 should have been moved to a new helix group
+        pitch_25_yaw_19_group_name = f'pitch_25_yaw_19'
+        pitch_25_yaw_19_group = d.groups[pitch_25_yaw_19_group_name]
+        self.assertEqual(25, pitch_25_yaw_19_group.pitch)
+        self.assertEqual(19, pitch_25_yaw_19_group.yaw)
+        self.assertEqual(pitch_25_yaw_19_group_name, helix0.group)
+        self.assertEqual(sc.Position3D(3, 2, 3), helix1.position)
+        self.assertEqual(15, helix1.roll)
+        self.assertEqual("north", helix1.group)
+        self.assertEqual(2, len(d.groups))
+
+    def test_only_individual_helices_specify_pitch_and_yaw(self) -> None:
+        json_str = """
+        {
+          "helices": [
+            {
+              "group": "north",
+              "position": {"x": 1, "y": 2, "z": 3},
+              "pitch": 25,
+              "yaw": 19,
+              "roll": 5
+            },
+            {
+              "group": "north",
+              "position": {"x": 3, "y": 2, "z": 3},
+              "pitch": 21,
+              "yaw": 13,
+              "roll": 15
+            }
+          ],
+          "groups": {
+            "north": {
+              "position": {"x": 0, "y": -200, "z": 0},
+              "grid": "none"
+            }
+          },
+          "strands": [ 
+            { 
+              "color": "#0066cc", 
+              "domains": [ {"helix": 0, "forward": true, "start": 0, "end": 32} ]
+            } 
+          ] 
+        }
+        """
+        d = sc.Design.from_scadnano_json_str(json_str)
+        helix0 = d.helices[0]
+        helix1 = d.helices[1]
+
+        # Helix 0 should have been moved to a new helix group
+        pitch_25_yaw_19_group_name = f'pitch_25.0_yaw_19.0'
+        pitch_25_yaw_19_group = d.groups[pitch_25_yaw_19_group_name]
+        self.assertEqual(sc.Position3D(1, 2, 3), helix0.position)
+        self.assertEqual(25, pitch_25_yaw_19_group.pitch)
+        self.assertEqual(19, pitch_25_yaw_19_group.yaw)
+        self.assertEqual(5, helix0.roll)
+        self.assertEqual(pitch_25_yaw_19_group_name, helix0.group)
+
+
+        # Helix 1 should have been moved to a new helix group
+        pitch_21_yaw_13_group_name = f'pitch_21.0_yaw_13.0'
+        pitch_21_yaw_13_group = d.groups[pitch_21_yaw_13_group_name]
+        self.assertEqual(sc.Position3D(3, 2, 3), helix1.position)
+        self.assertEqual(21, pitch_21_yaw_13_group.pitch)
+        self.assertEqual(13, pitch_21_yaw_13_group.yaw)
+        self.assertEqual(15, helix1.roll)
+        self.assertEqual(pitch_21_yaw_13_group_name, helix1.group)
+
+        self.assertEqual(3, len(d.groups))
+
+
+    def test_only_helix_groups_specify_pitch_and_yaw(self) -> None:
+        json_str = """
+        {
+          "helices": [
+            {
+              "group": "north",
+              "position": {"x": 1, "y": 2, "z": 3},
+              "roll": 5
+            },
+            {
+              "group": "south",
+              "position": {"x": 3, "y": 2, "z": 3},
+              "roll": 15
+            }
+          ],
+          "groups": {
+            "north": {
+              "position": {"x": 0, "y": -200, "z": 0},
+              "pitch": 21,
+              "yaw": 13,
+              "grid": "none"
+            },
+            "south": {
+              "position": {"x": 0, "y": -400, "z": 0},
+              "pitch": 23,
+              "yaw": 98,
+              "grid": "none"
+            }
+          },
+          "strands": [
+            {
+              "color": "#0066cc",
+              "domains": [ {"helix": 0, "forward": true, "start": 0, "end": 32} ]
+            }
+          ]
+        }
+        """
+        d = sc.Design.from_scadnano_json_str(json_str)
+        helix0 = d.helices[0]
+        helix1 = d.helices[1]
+
+        north_str= 'north'
+        south_str = 'south'
+        north_group = d.groups[north_str]
+        south_group = d.groups[south_str]
+        self.assertEqual(2, len(d.groups))
+
+        self.assertEqual(sc.Position3D(1, 2, 3), helix0.position)
+        self.assertEqual(5, helix0.roll)
+        self.assertEqual(21, north_group.pitch)
+        self.assertEqual(13, north_group.yaw)
+        self.assertEqual(north_str, helix0.group)
+
+        self.assertEqual(sc.Position3D(3, 2, 3), helix1.position)
+        self.assertEqual(15, helix1.roll)
+        self.assertEqual(23, south_group.pitch)
+        self.assertEqual(98, south_group.yaw)
+        self.assertEqual(south_str, helix1.group)
+
+
+    def test_both_helix_groups_and_helices_do_not_specify_pitch_nor_yaw(self) -> None:
+        json_str = """
+        {
+          "helices": [
+            {
+              "group": "north",
+              "position": {"x": 1, "y": 2, "z": 3},
+              "roll": 5
+            },
+            {
+              "group": "south",
+              "position": {"x": 3, "y": 2, "z": 3},
+              "roll": 15
+            }
+          ],
+          "groups": {
+            "north": {
+              "position": {"x": 0, "y": -200, "z": 0},
+              "grid": "none"
+            },
+            "south": {
+              "position": {"x": 0, "y": -400, "z": 0},
+              "grid": "none"
+            }
+          },
+          "strands": [
+            {
+              "color": "#0066cc",
+              "domains": [ {"helix": 0, "forward": true, "start": 0, "end": 32} ]
+            }
+          ]
+        }
+        """
+        d = sc.Design.from_scadnano_json_str(json_str)
+        helix0 = d.helices[0]
+        helix1 = d.helices[1]
+
+        north_str= 'north'
+        south_str = 'south'
+        north_group = d.groups[north_str]
+        south_group = d.groups[south_str]
+        self.assertEqual(2, len(d.groups))
+
+        self.assertEqual(sc.Position3D(1, 2, 3), helix0.position)
+        self.assertEqual(5, helix0.roll)
+        self.assertEqual(0, north_group.pitch)
+        self.assertEqual(0, north_group.yaw)
+        self.assertEqual(north_str, helix0.group)
+
+        self.assertEqual(sc.Position3D(3, 2, 3), helix1.position)
+        self.assertEqual(15, helix1.roll)
+        self.assertEqual(0, south_group.pitch)
+        self.assertEqual(0, south_group.yaw)
+        self.assertEqual(south_str, helix1.group)
+
+
+    def test_multiple_helix_groups_helices_specify_pitch_and_yaw(self) -> None:
+        json_str = """
+        {
+          "helices": [
+            {
+              "group": "north",
+              "position": {"x": 1, "y": 2, "z": 3},
+              "pitch": 4,
+              "roll": 5,
+              "yaw": 6            },
+            {
+              "group": "south",
+              "position": {"x": 3, "y": 2, "z": 3},
+              "roll": 15
+            }
+          ],
+          "groups": {
+            "north": {
+              "position": {"x": 0, "y": -200, "z": 0},
+              "pitch": 21,
+              "yaw": 13,
+              "grid": "none"
+            },
+            "south": {
+              "position": {"x": 0, "y": -400, "z": 0},
+              "pitch": 23,
+              "yaw": 98,
+              "grid": "none"
+            }
+          },
+          "strands": [
+            {
+              "color": "#0066cc",
+              "domains": [ {"helix": 0, "forward": true, "start": 0, "end": 32} ]
+            }
+          ]
+        }
+        """
+        d = sc.Design.from_scadnano_json_str(json_str)
+        helix0 = d.helices[0]
+        helix1 = d.helices[1]
+
+        north_str = 'north'
+        south_str = 'south'
+        north_group = d.groups[north_str]
+        south_group = d.groups[south_str]
+        self.assertEqual(2, len(d.groups))
+
+        self.assertEqual(sc.Position3D(1, 2, 3), helix0.position)
+        self.assertEqual(5, helix0.roll)
+        self.assertEqual(25, north_group.pitch)
+        self.assertEqual(19, north_group.yaw)
+        self.assertEqual(north_str, helix0.group)
+
+        self.assertEqual(sc.Position3D(3, 2, 3), helix1.position)
+        self.assertEqual(15, helix1.roll)
+        self.assertEqual(23, south_group.pitch)
+        self.assertEqual(98, south_group.yaw)
+        self.assertEqual(south_str, helix1.group)
+
+    def test_multiple_helix_groups_helices_specify_pitch_and_yaw_invalid(self) -> None:
+        json_str = """
+        {
+          "helices": [
+            {
+              "group": "north",
+              "position": {"x": 1, "y": 2, "z": 3},
+              "pitch": 4,
+              "roll": 5,
+              "yaw": 6
+            },
+            {
+              "group": "north",
+              "position": {"x": 3, "y": 2, "z": 3},
+              "pitch": 10,
+              "roll": 5,
+              "yaw": 84
+            }
+          ],
+          "groups": {
+            "north": {
+              "position": {"x": 0, "y": -200, "z": 0},
+              "grid": "none"
+            },
+            "east": {
+              "position": {"x": 0, "y": 0, "z": 100},
+              "pitch": 45,
+              "grid": "square"
+            }
+          },
+          "strands": [ 
+            { 
+              "color": "#0066cc", 
+              "domains": [ {"helix": 0, "forward": true, "start": 0, "end": 32} ]
+            } 
+          ] 
+        }
+        """
+        # Should fail because multiple helices in same helix group are non-parallel
+        with self.assertRaises(sc.IllegalDesignError):
+            sc.Design.from_scadnano_json_str(json_str)
+
     def test_position_specified_with_origin_keyword(self) -> None:
         # addresses https://github.com/UC-Davis-molecular-computing/scadnano-python-package/issues/59
         json_str = """
@@ -3569,13 +3990,16 @@ def test_position_specified_with_origin_keyword(self) -> None:
         expected_roll = 5
         expected_yaw = 6
         actual_position = d.helices[0].position
-        actual_pitch = d.helices[0].pitch
+        expected_group_name = f'pitch_{expected_pitch}_yaw_{expected_yaw}'
+        expected_group = d.groups[expected_group_name]
+        actual_pitch = expected_group.pitch
         actual_roll = d.helices[0].roll
-        actual_yaw = d.helices[0].yaw
+        actual_yaw = expected_group.yaw
         self.assertEqual(expected_position, actual_position)
         self.assertEqual(expected_pitch, actual_pitch)
         self.assertEqual(expected_roll, actual_roll)
         self.assertEqual(expected_yaw, actual_yaw)
+        self.assertEqual(expected_group_name, d.helices[0].group)
 
     def test_json_tristan_example_issue_32(self) -> None:
         json_str = """
diff --git a/tests_inputs/cadnano_v2_export/test_16_helix_origami_rectangle_no_twist.sc b/tests_inputs/cadnano_v2_export/test_16_helix_origami_rectangle_no_twist.sc
index 7ab8cc69..937aa5a7 100644
--- a/tests_inputs/cadnano_v2_export/test_16_helix_origami_rectangle_no_twist.sc
+++ b/tests_inputs/cadnano_v2_export/test_16_helix_origami_rectangle_no_twist.sc
@@ -1,5 +1,5 @@
 {
-  "version": "0.15.0",
+  "version": "0.16.0",
   "grid": "square",
   "helices": [
     {"max_offset": 448, "grid_position": [0, 0]},
diff --git a/tests_inputs/cadnano_v2_export/test_2_stape_2_helix_origami_deletions_insertions.sc b/tests_inputs/cadnano_v2_export/test_2_stape_2_helix_origami_deletions_insertions.sc
index e78d004e..cfc25b74 100644
--- a/tests_inputs/cadnano_v2_export/test_2_stape_2_helix_origami_deletions_insertions.sc
+++ b/tests_inputs/cadnano_v2_export/test_2_stape_2_helix_origami_deletions_insertions.sc
@@ -1,5 +1,5 @@
 {
-  "version": "0.15.0",
+  "version": "0.16.0",
   "grid": "square",
   "helices": [
     {"grid_position": [0, 0]},
diff --git a/tests_inputs/cadnano_v2_export/test_2_stape_2_helix_origami_extremely_simple.sc b/tests_inputs/cadnano_v2_export/test_2_stape_2_helix_origami_extremely_simple.sc
index 8e58839e..130d6941 100644
--- a/tests_inputs/cadnano_v2_export/test_2_stape_2_helix_origami_extremely_simple.sc
+++ b/tests_inputs/cadnano_v2_export/test_2_stape_2_helix_origami_extremely_simple.sc
@@ -1,5 +1,5 @@
 {
-  "version": "0.15.0",
+  "version": "0.16.0",
   "grid": "square",
   "helices": [
     {"grid_position": [0, 0]},
diff --git a/tests_inputs/cadnano_v2_export/test_2_stape_2_helix_origami_extremely_simple_2.sc b/tests_inputs/cadnano_v2_export/test_2_stape_2_helix_origami_extremely_simple_2.sc
index 6b4fe31f..46003fe4 100644
--- a/tests_inputs/cadnano_v2_export/test_2_stape_2_helix_origami_extremely_simple_2.sc
+++ b/tests_inputs/cadnano_v2_export/test_2_stape_2_helix_origami_extremely_simple_2.sc
@@ -1,5 +1,5 @@
 {
-  "version": "0.15.0",
+  "version": "0.16.0",
   "grid": "square",
   "helices": [
     {"grid_position": [0, 0]},
diff --git a/tests_inputs/cadnano_v2_export/test_6_helix_origami_rectangle.sc b/tests_inputs/cadnano_v2_export/test_6_helix_origami_rectangle.sc
index b490d285..aa030c0a 100644
--- a/tests_inputs/cadnano_v2_export/test_6_helix_origami_rectangle.sc
+++ b/tests_inputs/cadnano_v2_export/test_6_helix_origami_rectangle.sc
@@ -1,5 +1,5 @@
 {
-  "version": "0.15.0",
+  "version": "0.16.0",
   "grid": "square",
   "helices": [
     {"max_offset": 192, "grid_position": [0, 0]},
diff --git a/tests_inputs/cadnano_v2_export/test_circular_strand.sc b/tests_inputs/cadnano_v2_export/test_circular_strand.sc
index 312f87f6..4c3f5705 100644
--- a/tests_inputs/cadnano_v2_export/test_circular_strand.sc
+++ b/tests_inputs/cadnano_v2_export/test_circular_strand.sc
@@ -1,5 +1,5 @@
 {
-  "version": "0.15.0",
+  "version": "0.16.0",
   "grid": "square",
   "helices": [
     {"max_offset": 24, "grid_position": [0, 0]},
diff --git a/tests_inputs/cadnano_v2_export/test_export_design_with_helix_group.sc b/tests_inputs/cadnano_v2_export/test_export_design_with_helix_group.sc
index 441767a9..477ff07a 100644
--- a/tests_inputs/cadnano_v2_export/test_export_design_with_helix_group.sc
+++ b/tests_inputs/cadnano_v2_export/test_export_design_with_helix_group.sc
@@ -1,5 +1,5 @@
 {
-  "version": "0.15.0",
+  "version": "0.16.0",
   "groups": {
     "east": {
       "position": {"x": 10, "y": 0, "z": 0},
diff --git a/tests_inputs/cadnano_v2_export/test_export_design_with_helix_group_not_same_grid.sc b/tests_inputs/cadnano_v2_export/test_export_design_with_helix_group_not_same_grid.sc
index 63f9fc19..12f5d39e 100644
--- a/tests_inputs/cadnano_v2_export/test_export_design_with_helix_group_not_same_grid.sc
+++ b/tests_inputs/cadnano_v2_export/test_export_design_with_helix_group_not_same_grid.sc
@@ -1,5 +1,5 @@
 {
-  "version": "0.15.0",
+  "version": "0.16.0",
   "groups": {
     "east": {
       "position": {"x": 10, "y": 0, "z": 0},