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

Issue:
support Extensions in oxDNA export 
Extensions are currently assumed to be loopouts in the oxDNA export code, causing it to fail.


Discussion / hints:

Fix patch:
diff --git a/examples/extension_and_loopout_oxdna_export.py b/examples/extension_and_loopout_oxdna_export.py
new file mode 100644
index 00000000..27985581
--- /dev/null
+++ b/examples/extension_and_loopout_oxdna_export.py
@@ -0,0 +1,29 @@
+import scadnano as sc
+
+
+def main() -> None:
+    d = create_design()
+    d.write_scadnano_file(directory='output_designs')
+    d.write_oxdna_files(directory='oxdna')
+
+
+def create_design() -> sc.Design:
+    width = 21
+    helices = [sc.Helix(max_offset=32) for _ in range(3)]
+    helices[0].roll = 210
+    helices[1].roll = 20
+    helices[2].roll = 210
+    design = sc.Design(helices=helices, grid=sc.square)
+
+    design.draw_strand(0, 0).extension_5p(5) \
+        .move(width) \
+        .cross(1).move(-width) \
+        .loopout(2, 32) \
+        .move(width) \
+        .extension_3p(7)
+
+    return design
+
+
+if __name__ == '__main__':
+    main()
diff --git a/examples/extensions.py b/examples/extensions.py
index 3c259cd7..bc5d6b0a 100644
--- a/examples/extensions.py
+++ b/examples/extensions.py
@@ -1,22 +1,39 @@
 import scadnano as sc
 
 
+def main() -> None:
+    d = create_design()
+    d.write_scadnano_file(directory='output_designs')
+    d.write_oxdna_files(directory='oxdna')
+
+
 def create_design() -> sc.Design:
     width = 8
     helices = [sc.Helix(max_offset=32) for _ in range(3)]
     design = sc.Design(helices=helices, grid=sc.square)
+    helices[2].roll = 30
 
-    design.draw_strand(0, 0).extension_5p(5, display_length=2.5, display_angle=45)\
-        .move(width).cross(1).move(-width).loopout(2, 3).move(width)\
-        .extension_3p(7).with_domain_name("ext_3p")
+    design.draw_strand(0, 0).extension_5p(5, display_length=2.5, display_angle=45) \
+        .with_domain_name("ext_5p 1") \
+        .move(width).with_domain_name('domain 1') \
+        .cross(1).move(-width) \
+        .loopout(2, 3).with_domain_name('loopout 1') \
+        .move(width) \
+        .extension_3p(7).with_domain_name("ext_3p 1") \
+        .with_name('strand1') \
+        .with_sequence('TATTT' + 'G' * width + 'C' * width + 'T' * 3 + 'G' * width + 'ATAAAAA') \
+        .with_modification_5p(sc.cy5_5p) \
+        .with_modification_3p(sc.cy3_3p)
 
-    design.draw_strand(0, 24).extension_5p(5, display_length=3.5, display_angle=60)\
-        .move(-width).cross(1).move(width).loopout(2, 3).move(-width)\
-        .extension_3p(7).with_domain_name("ext_3p_top")
+    design.draw_strand(0, 24).extension_5p(5, display_length=3.5, display_angle=60) \
+        .with_domain_name("ext_5p 2") \
+        .move(-width).cross(1).move(width).loopout(2, 3).move(-width) \
+        .extension_3p(7).with_domain_name("ext_3p 2") \
+        .with_name('strand2') \
+        .with_sequence('ATAAA' + 'C' * width + 'G' * width + 'A' * 3 + 'C' * width + 'TATTTTT') \
 
     return design
 
 
 if __name__ == '__main__':
-    d = create_design()
-    d.write_scadnano_file(directory='output_designs')
+    main()
diff --git a/examples/output_designs/extension_and_loopout_oxdna_export.sc b/examples/output_designs/extension_and_loopout_oxdna_export.sc
new file mode 100644
index 00000000..d8dbb93e
--- /dev/null
+++ b/examples/output_designs/extension_and_loopout_oxdna_export.sc
@@ -0,0 +1,22 @@
+{
+  "version": "0.17.6",
+  "grid": "square",
+  "helices": [
+    {"max_offset": 32, "grid_position": [0, 0], "roll": 210},
+    {"max_offset": 32, "grid_position": [0, 1], "roll": 20},
+    {"max_offset": 32, "grid_position": [0, 2], "roll": 210}
+  ],
+  "strands": [
+    {
+      "color": "#f74308",
+      "domains": [
+        {"extension_num_bases": 5},
+        {"helix": 0, "forward": true, "start": 0, "end": 21},
+        {"helix": 1, "forward": false, "start": 0, "end": 21},
+        {"loopout": 32},
+        {"helix": 2, "forward": true, "start": 0, "end": 21},
+        {"extension_num_bases": 7}
+      ]
+    }
+  ]
+}
\ No newline at end of file
diff --git a/examples/output_designs/extensions.sc b/examples/output_designs/extensions.sc
index 80d0ee2a..8525dc64 100644
--- a/examples/output_designs/extensions.sc
+++ b/examples/output_designs/extensions.sc
@@ -1,32 +1,52 @@
 {
-  "version": "0.17.3",
+  "version": "0.17.5",
   "grid": "square",
   "helices": [
     {"max_offset": 32, "grid_position": [0, 0]},
     {"max_offset": 32, "grid_position": [0, 1]},
-    {"max_offset": 32, "grid_position": [0, 2]}
+    {"max_offset": 32, "grid_position": [0, 2], "roll": 30}
   ],
+  "modifications_in_design": {
+    "/3Cy3Sp/": {
+      "display_text": "Cy3",
+      "idt_text": "/3Cy3Sp/",
+      "display_connector": false,
+      "location": "3'"
+    },
+    "/5Cy5/": {
+      "display_text": "Cy5",
+      "idt_text": "/5Cy5/",
+      "display_connector": false,
+      "location": "5'"
+    }
+  },
   "strands": [
     {
+      "name": "strand1",
       "color": "#f74308",
+      "sequence": "TATTTGGGGGGGGCCCCCCCCTTTGGGGGGGGATAAAAA",
       "domains": [
-        {"extension_num_bases": 5, "display_length": 2.5},
-        {"helix": 0, "forward": true, "start": 0, "end": 8},
+        {"extension_num_bases": 5, "display_length": 2.5, "name": "ext_5p 1"},
+        {"name": "domain 1", "helix": 0, "forward": true, "start": 0, "end": 8},
         {"helix": 1, "forward": false, "start": 0, "end": 8},
-        {"loopout": 3},
+        {"loopout": 3, "name": "loopout 1"},
         {"helix": 2, "forward": true, "start": 0, "end": 8},
-        {"extension_num_bases": 7, "name": "ext_3p"}
-      ]
+        {"extension_num_bases": 7, "name": "ext_3p 1"}
+      ],
+      "5prime_modification": "/5Cy5/",
+      "3prime_modification": "/3Cy3Sp/"
     },
     {
+      "name": "strand2",
       "color": "#57bb00",
+      "sequence": "ATAAACCCCCCCCGGGGGGGGAAACCCCCCCCTATTTTT",
       "domains": [
-        {"extension_num_bases": 5, "display_length": 3.5, "display_angle": 60},
+        {"extension_num_bases": 5, "display_length": 3.5, "display_angle": 60, "name": "ext_5p 2"},
         {"helix": 0, "forward": false, "start": 16, "end": 24},
         {"helix": 1, "forward": true, "start": 16, "end": 24},
         {"loopout": 3},
         {"helix": 2, "forward": false, "start": 16, "end": 24},
-        {"extension_num_bases": 7, "name": "ext_3p_top"}
+        {"extension_num_bases": 7, "name": "ext_3p 2"}
       ]
     }
   ]
diff --git a/examples/oxdna/extension_and_loopout_oxdna_export.dat b/examples/oxdna/extension_and_loopout_oxdna_export.dat
new file mode 100644
index 00000000..3ce42e8d
--- /dev/null
+++ b/examples/oxdna/extension_and_loopout_oxdna_export.dat
@@ -0,0 +1,110 @@
+t = 0
+b = 27.636671979913565 27.636671979913565 27.636671979913565
+E = 0 0 0
+-0.8682408883346526 4.924038765061041 -0.6 0.0 0.0 1.0 -0.17364817766693053 0.9848077530122081 0.0 0 0 0 0 0 0
+-0.6945927106677221 3.9392310120488325 -0.6 0.0 0.0 1.0 -0.17364817766693053 0.9848077530122081 0.0 0 0 0 0 0 0
+-0.5209445330007916 2.9544232590366244 -0.6 0.0 0.0 1.0 -0.17364817766693053 0.9848077530122081 0.0 0 0 0 0 0 0
+-0.34729635533386105 1.9696155060244163 -0.6 0.0 0.0 1.0 -0.17364817766693053 0.9848077530122081 0.0 0 0 0 0 0 0
+-0.17364817766693053 0.9848077530122081 -0.6 0.0 0.0 1.0 -0.17364817766693053 0.9848077530122081 0.0 0 0 0 0 0 0
+-0.10418890660015831 0.5908846518073249 0.0 0.17364817766693053 -0.9848077530122081 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+-0.41894209085164386 0.42952010955583103 0.3897628551303123 0.6982368180860731 -0.7158668492597184 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+-0.5881034927091263 0.11888768591963843 0.7795257102606246 0.9801724878485439 -0.19814614319939738 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+-0.5528857271222446 -0.23306087776481688 1.1692885653909368 0.9214762118704076 0.3884347962746948 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+-0.3255277583194556 -0.5040155538904629 1.5590514205212491 0.5425462638657593 0.8400259231507716 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+0.014958415042843664 -0.5998135092004898 1.9488142756515616 -0.024930691738072774 0.9996891820008164 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+0.350246203340874 -0.4871628034295139 2.3385771307818737 -0.58374367223479 0.8119380057158565 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+0.5638155724715451 -0.20521208599540144 2.728339985912186 -0.9396926207859085 0.3420201433256691 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+0.5814463717374467 0.14805443861417636 3.1181028410424982 -0.969077286229078 -0.24675739769029392 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+0.3970115025581157 0.4498687217806405 3.5078656961728107 -0.6616858375968595 -0.7497812029677342 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+0.07460622278849131 0.5953435239601033 3.8976285513031232 -0.12434370464748554 -0.9922392066001722 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+-0.27372639441189744 0.5339230852868814 4.287391406433435 0.4562106573531624 -0.889871808811469 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+-0.5269329440221371 0.286952387172791 4.677154261563747 0.8782215733702285 -0.47825397862131835 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+-0.5970184652192408 -0.05974070795749027 5.06691711669406 0.9950307753654014 0.09956784659581712 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+-0.4596266658713871 -0.3856725658119233 5.456679971824372 0.7660444431189786 0.6427876096865388 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+-0.16250428088580418 -0.577574548170007 5.846442826954685 0.270840468143007 0.9626242469500117 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+0.1910919901510109 -0.568756407700279 6.2362056820849965 -0.3184866502516848 0.9479273461671317 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+0.4782795043337538 -0.362282646195286 6.625968537215309 -0.7971325072229231 0.6038044103254767 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+0.5992541527313534 -0.02990753139641845 7.0157313923456215 -0.9987569212189225 0.04984588566069742 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+0.5119745289792939 0.3128611220276981 7.405494247475934 -0.8532908816321565 -0.5214352033794969 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+0.2467722618783674 0.5469035113870037 7.7952571026062465 -0.41128710313061234 -0.9115058523116728 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+0.24677226187836748 4.068857021600668 7.7952571026062465 -0.4112871031306125 -0.9115058523116727 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+0.5119745289792939 3.834814632241363 7.405494247475934 -0.8532908816321565 -0.5214352033794967 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+0.5992541527313534 3.4920459788172464 7.0157313923456215 -0.9987569212189225 0.049845885660697586 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+0.47827950433375377 3.159670864018379 6.625968537215309 -0.797132507222923 0.6038044103254768 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+0.19109199015101078 2.953197102513386 6.2362056820849965 -0.31848665025168466 0.9479273461671317 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+-0.16250428088580432 2.944378962043658 5.846442826954685 0.2708404681430072 0.9626242469500117 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+-0.4596266658713872 3.136280944401742 5.456679971824372 0.7660444431189787 0.6427876096865387 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+-0.5970184652192408 3.462212802256175 5.06691711669406 0.9950307753654014 0.09956784659581695 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+-0.5269329440221371 3.808905897386456 4.677154261563747 0.8782215733702284 -0.47825397862131847 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+-0.27372639441189733 4.055876595500546 4.287391406433435 0.45621065735316224 -0.8898718088114691 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+0.07460622278849142 4.1172970341737685 3.8976285513031232 -0.1243437046474857 -0.9922392066001721 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+0.39701150255811574 3.9718222319943055 3.5078656961728107 -0.6616858375968596 -0.7497812029677341 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+0.5814463717374467 3.670007948827841 3.1181028410424982 -0.969077286229078 -0.24675739769029376 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+0.563815572471545 3.3167414242182636 2.728339985912186 -0.9396926207859084 0.3420201433256692 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+0.3502462033408739 3.0347907067841513 2.3385771307818737 -0.5837436722347898 0.8119380057158565 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+0.014958415042843565 2.922140001013175 1.9488142756515616 -0.024930691738072608 0.9996891820008164 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+-0.32552775831945563 3.017937956323202 1.5590514205212491 0.5425462638657594 0.8400259231507715 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+-0.5528857271222446 3.288892632448848 1.1692885653909368 0.9214762118704077 0.3884347962746947 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+-0.5881034927091263 3.6408411961333034 0.7795257102606246 0.9801724878485439 -0.19814614319939755 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+-0.41894209085164374 3.951473619769496 0.3897628551303123 0.698236818086073 -0.7158668492597184 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+-0.10418890660015821 4.1128381620209895 0.0 0.17364817766693036 -0.9848077530122081 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+0.0 3.6286793741595336 0.6 -0.0 -0.0 -1.0 0.0 1.0 0.0 0 0 0 0 0 0
+0.0 3.735405238105402 0.6 -0.0 -0.0 -1.0 0.0 1.0 0.0 0 0 0 0 0 0
+0.0 3.8421311020512707 0.6 -0.0 -0.0 -1.0 0.0 1.0 0.0 0 0 0 0 0 0
+0.0 3.9488569659971393 0.6 -0.0 -0.0 -1.0 0.0 1.0 0.0 0 0 0 0 0 0
+0.0 4.055582829943008 0.6 -0.0 -0.0 -1.0 0.0 1.0 0.0 0 0 0 0 0 0
+0.0 4.1623086938888765 0.6 -0.0 -0.0 -1.0 0.0 1.0 0.0 0 0 0 0 0 0
+0.0 4.269034557834745 0.6 -0.0 -0.0 -1.0 0.0 1.0 0.0 0 0 0 0 0 0
+0.0 4.375760421780614 0.6 -0.0 -0.0 -1.0 0.0 1.0 0.0 0 0 0 0 0 0
+0.0 4.482486285726482 0.6 -0.0 -0.0 -1.0 0.0 1.0 0.0 0 0 0 0 0 0
+0.0 4.589212149672352 0.6 -0.0 -0.0 -1.0 0.0 1.0 0.0 0 0 0 0 0 0
+0.0 4.695938013618219 0.6 -0.0 -0.0 -1.0 0.0 1.0 0.0 0 0 0 0 0 0
+0.0 4.802663877564089 0.6 -0.0 -0.0 -1.0 0.0 1.0 0.0 0 0 0 0 0 0
+0.0 4.909389741509957 0.6 -0.0 -0.0 -1.0 0.0 1.0 0.0 0 0 0 0 0 0
+0.0 5.016115605455826 0.6 -0.0 -0.0 -1.0 0.0 1.0 0.0 0 0 0 0 0 0
+0.0 5.122841469401695 0.6 -0.0 -0.0 -1.0 0.0 1.0 0.0 0 0 0 0 0 0
+0.0 5.229567333347563 0.6 -0.0 -0.0 -1.0 0.0 1.0 0.0 0 0 0 0 0 0
+0.0 5.336293197293432 0.6 -0.0 -0.0 -1.0 0.0 1.0 0.0 0 0 0 0 0 0
+0.0 5.4430190612393 0.6 -0.0 -0.0 -1.0 0.0 1.0 0.0 0 0 0 0 0 0
+0.0 5.54974492518517 0.6 -0.0 -0.0 -1.0 0.0 1.0 0.0 0 0 0 0 0 0
+0.0 5.656470789131038 0.6 -0.0 -0.0 -1.0 0.0 1.0 0.0 0 0 0 0 0 0
+0.0 5.763196653076906 0.6 -0.0 -0.0 -1.0 0.0 1.0 0.0 0 0 0 0 0 0
+0.0 5.869922517022775 0.6 -0.0 -0.0 -1.0 0.0 1.0 0.0 0 0 0 0 0 0
+0.0 5.976648380968644 0.6 -0.0 -0.0 -1.0 0.0 1.0 0.0 0 0 0 0 0 0
+0.0 6.083374244914513 0.6 -0.0 -0.0 -1.0 0.0 1.0 0.0 0 0 0 0 0 0
+0.0 6.190100108860381 0.6 -0.0 -0.0 -1.0 0.0 1.0 0.0 0 0 0 0 0 0
+0.0 6.29682597280625 0.6 -0.0 -0.0 -1.0 0.0 1.0 0.0 0 0 0 0 0 0
+0.0 6.4035518367521185 0.6 -0.0 -0.0 -1.0 0.0 1.0 0.0 0 0 0 0 0 0
+0.0 6.510277700697987 0.6 -0.0 -0.0 -1.0 0.0 1.0 0.0 0 0 0 0 0 0
+0.0 6.617003564643856 0.6 -0.0 -0.0 -1.0 0.0 1.0 0.0 0 0 0 0 0 0
+0.0 6.723729428589724 0.6 -0.0 -0.0 -1.0 0.0 1.0 0.0 0 0 0 0 0 0
+0.0 6.830455292535593 0.6 -0.0 -0.0 -1.0 0.0 1.0 0.0 0 0 0 0 0 0
+0.0 6.937181156481461 0.6 -0.0 -0.0 -1.0 0.0 1.0 0.0 0 0 0 0 0 0
+-0.10418890660015831 7.6347916722346545 0.0 0.17364817766693053 -0.9848077530122081 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+-0.41894209085164386 7.473427129983161 0.3897628551303123 0.6982368180860731 -0.7158668492597184 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+-0.5881034927091263 7.162794706346968 0.7795257102606246 0.9801724878485439 -0.19814614319939738 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+-0.5528857271222446 6.810846142662513 1.1692885653909368 0.9214762118704076 0.3884347962746948 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+-0.3255277583194556 6.539891466536867 1.5590514205212491 0.5425462638657593 0.8400259231507716 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+0.014958415042843664 6.44409351122684 1.9488142756515616 -0.024930691738072774 0.9996891820008164 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+0.350246203340874 6.556744216997816 2.3385771307818737 -0.58374367223479 0.8119380057158565 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+0.5638155724715451 6.8386949344319286 2.728339985912186 -0.9396926207859085 0.3420201433256691 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+0.5814463717374467 7.191961459041506 3.1181028410424982 -0.969077286229078 -0.24675739769029392 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+0.3970115025581157 7.493775742207971 3.5078656961728107 -0.6616858375968595 -0.7497812029677342 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+0.07460622278849131 7.6392505443874335 3.8976285513031232 -0.12434370464748554 -0.9922392066001722 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+-0.27372639441189744 7.577830105714211 4.287391406433435 0.4562106573531624 -0.889871808811469 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+-0.5269329440221371 7.330859407600121 4.677154261563747 0.8782215733702285 -0.47825397862131835 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+-0.5970184652192408 6.984166312469839 5.06691711669406 0.9950307753654014 0.09956784659581712 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+-0.4596266658713871 6.658234454615407 5.456679971824372 0.7660444431189786 0.6427876096865388 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+-0.16250428088580418 6.4663324722573226 5.846442826954685 0.270840468143007 0.9626242469500117 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+0.1910919901510109 6.475150612727051 6.2362056820849965 -0.3184866502516848 0.9479273461671317 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+0.4782795043337538 6.681624374232044 6.625968537215309 -0.7971325072229231 0.6038044103254767 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+0.5992541527313534 7.013999489030912 7.0157313923456215 -0.9987569212189225 0.04984588566069742 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+0.5119745289792939 7.356768142455028 7.405494247475934 -0.8532908816321565 -0.5214352033794969 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+0.2467722618783674 7.590810531814333 7.7952571026062465 -0.41128710313061234 -0.9115058523116728 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+0.41128710313061234 7.955412872739003 7.195257102606247 0.0 0.0 1.0 0.41128710313061234 0.9115058523116728 0.0 0 0 0 0 0 0
+0.8225742062612247 8.866918725050676 7.195257102606247 0.0 0.0 1.0 0.41128710313061234 0.9115058523116728 0.0 0 0 0 0 0 0
+1.2338613093918371 9.778424577362347 7.195257102606247 0.0 0.0 1.0 0.41128710313061234 0.9115058523116728 0.0 0 0 0 0 0 0
+1.6451484125224494 10.689930429674021 7.195257102606247 0.0 0.0 1.0 0.41128710313061234 0.9115058523116728 0.0 0 0 0 0 0 0
+2.0564355156530616 11.601436281985695 7.195257102606247 0.0 0.0 1.0 0.41128710313061234 0.9115058523116728 0.0 0 0 0 0 0 0
+2.467722618783674 12.512942134297369 7.195257102606247 0.0 0.0 1.0 0.41128710313061234 0.9115058523116728 0.0 0 0 0 0 0 0
+2.879009721914286 13.424447986609042 7.195257102606247 0.0 0.0 1.0 0.41128710313061234 0.9115058523116728 0.0 0 0 0 0 0 0
diff --git a/examples/oxdna/extension_and_loopout_oxdna_export.top b/examples/oxdna/extension_and_loopout_oxdna_export.top
new file mode 100644
index 00000000..a42e665a
--- /dev/null
+++ b/examples/oxdna/extension_and_loopout_oxdna_export.top
@@ -0,0 +1,108 @@
+107 1
+1 T 1 -1
+1 T 2 0
+1 T 3 1
+1 T 4 2
+1 T 5 3
+1 T 6 4
+1 T 7 5
+1 T 8 6
+1 T 9 7
+1 T 10 8
+1 T 11 9
+1 T 12 10
+1 T 13 11
+1 T 14 12
+1 T 15 13
+1 T 16 14
+1 T 17 15
+1 T 18 16
+1 T 19 17
+1 T 20 18
+1 T 21 19
+1 T 22 20
+1 T 23 21
+1 T 24 22
+1 T 25 23
+1 T 26 24
+1 T 27 25
+1 T 28 26
+1 T 29 27
+1 T 30 28
+1 T 31 29
+1 T 32 30
+1 T 33 31
+1 T 34 32
+1 T 35 33
+1 T 36 34
+1 T 37 35
+1 T 38 36
+1 T 39 37
+1 T 40 38
+1 T 41 39
+1 T 42 40
+1 T 43 41
+1 T 44 42
+1 T 45 43
+1 T 46 44
+1 T 47 45
+1 T 48 46
+1 T 49 47
+1 T 50 48
+1 T 51 49
+1 T 52 50
+1 T 53 51
+1 T 54 52
+1 T 55 53
+1 T 56 54
+1 T 57 55
+1 T 58 56
+1 T 59 57
+1 T 60 58
+1 T 61 59
+1 T 62 60
+1 T 63 61
+1 T 64 62
+1 T 65 63
+1 T 66 64
+1 T 67 65
+1 T 68 66
+1 T 69 67
+1 T 70 68
+1 T 71 69
+1 T 72 70
+1 T 73 71
+1 T 74 72
+1 T 75 73
+1 T 76 74
+1 T 77 75
+1 T 78 76
+1 T 79 77
+1 T 80 78
+1 T 81 79
+1 T 82 80
+1 T 83 81
+1 T 84 82
+1 T 85 83
+1 T 86 84
+1 T 87 85
+1 T 88 86
+1 T 89 87
+1 T 90 88
+1 T 91 89
+1 T 92 90
+1 T 93 91
+1 T 94 92
+1 T 95 93
+1 T 96 94
+1 T 97 95
+1 T 98 96
+1 T 99 97
+1 T 100 98
+1 T 101 99
+1 T 102 100
+1 T 103 101
+1 T 104 102
+1 T 105 103
+1 T 106 104
+1 T -1 105
diff --git a/examples/oxdna/extensions.dat b/examples/oxdna/extensions.dat
new file mode 100644
index 00000000..1445f3fc
--- /dev/null
+++ b/examples/oxdna/extensions.dat
@@ -0,0 +1,81 @@
+t = 0
+b = 28.70476669145484 28.70476669145484 28.70476669145484
+E = 0 0 0
+-1.7101007166283435 -4.698463103929543 -0.6 0.0 0.0 1.0 -0.3420201433256687 -0.9396926207859084 0.0 0 0 0 0 0 0
+-1.3680805733026749 -3.7587704831436337 -0.6 0.0 0.0 1.0 -0.3420201433256687 -0.9396926207859084 0.0 0 0 0 0 0 0
+-1.0260604299770062 -2.8190778623577253 -0.6 0.0 0.0 1.0 -0.3420201433256687 -0.9396926207859084 0.0 0 0 0 0 0 0
+-0.6840402866513374 -1.8793852415718169 -0.6 0.0 0.0 1.0 -0.3420201433256687 -0.9396926207859084 0.0 0 0 0 0 0 0
+-0.3420201433256687 -0.9396926207859084 -0.6 0.0 0.0 1.0 -0.3420201433256687 -0.9396926207859084 0.0 0 0 0 0 0 0
+-0.20521208599540122 -0.563815572471545 0.0 0.3420201433256687 0.9396926207859084 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+0.14805443861417622 -0.5814463717374467 0.3897628551303123 -0.2467573976902937 0.969077286229078 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+0.4498687217806405 -0.39701150255811557 0.7795257102606246 -0.7497812029677342 0.6616858375968593 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+0.5953435239601033 -0.07460622278849112 1.1692885653909368 -0.9922392066001721 0.1243437046474852 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+0.5339230852868811 0.2737263944118978 1.5590514205212491 -0.8898718088114685 -0.456210657353163 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+0.2869523871727911 0.5269329440221371 1.9488142756515616 -0.47825397862131847 -0.8782215733702284 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+-0.059740707957489946 0.5970184652192408 2.3385771307818737 0.09956784659581658 -0.9950307753654014 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+-0.3856725658119234 0.459626665871387 2.728339985912186 0.642787609686539 -0.7660444431189783 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+0.45962666587138673 3.136280944401741 2.728339985912186 -0.7660444431189779 0.6427876096865397 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+0.16250428088580307 2.9443789620436576 2.3385771307818737 -0.2708404681430051 0.962624246950012 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+-0.19109199015101078 2.953197102513386 1.9488142756515616 0.31848665025168466 0.9479273461671317 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+-0.4782795043337535 3.1596708640183784 1.5590514205212491 0.7971325072229225 0.6038044103254775 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+-0.5992541527313534 3.492045978817247 1.1692885653909368 0.9987569212189223 0.04984588566069714 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+-0.5119745289792934 3.834814632241364 0.7795257102606246 0.8532908816321557 -0.521435203379498 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+-0.24677226187836698 4.068857021600669 0.3897628551303123 0.4112871031306117 -0.9115058523116731 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+0.10418890660015818 4.1128381620209895 0.0 -0.1736481776669303 -0.9848077530122081 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+0.0 4.402441887767081 0.6 -0.0 -0.0 -1.0 0.0 1.0 0.0 0 0 0 0 0 0
+0.0 5.2829302653204975 0.6 -0.0 -0.0 -1.0 0.0 1.0 0.0 0 0 0 0 0 0
+0.0 6.163418642873914 0.6 -0.0 -0.0 -1.0 0.0 1.0 0.0 0 0 0 0 0 0
+0.10418890660015818 6.4530223686200054 0.0 -0.1736481776669303 0.9848077530122081 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+0.4189420908516437 6.614386910871499 0.3897628551303123 -0.6982368180860729 0.7158668492597186 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+0.5881034927091263 6.925019334507692 0.7795257102606246 -0.9801724878485439 0.1981461431993976 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+0.5528857271222446 7.276967898192146 1.1692885653909368 -0.9214762118704077 -0.3884347962746946 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+0.32552775831945563 7.547922574317793 1.5590514205212491 -0.5425462638657594 -0.8400259231507715 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+-0.01495841504284353 7.64372052962782 1.9488142756515616 0.024930691738072552 -0.9996891820008164 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+-0.3502462033408738 7.531069823856844 2.3385771307818737 0.5837436722347897 -0.8119380057158566 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+-0.563815572471545 7.249119106422731 2.728339985912186 0.9396926207859084 -0.34202014332566927 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+-0.9396926207859084 7.385927163752999 2.128339985912186 0.0 0.0 1.0 -0.9396926207859084 0.34202014332566927 0.0 0 0 0 0 0 0
+-1.8793852415718169 7.727947307078669 2.128339985912186 0.0 0.0 1.0 -0.9396926207859084 0.34202014332566927 0.0 0 0 0 0 0 0
+-2.8190778623577253 8.069967450404338 2.128339985912186 0.0 0.0 1.0 -0.9396926207859084 0.34202014332566927 0.0 0 0 0 0 0 0
+-3.7587704831436337 8.411987593730007 2.128339985912186 0.0 0.0 1.0 -0.9396926207859084 0.34202014332566927 0.0 0 0 0 0 0 0
+-4.698463103929543 8.754007737055677 2.128339985912186 0.0 0.0 1.0 -0.9396926207859084 0.34202014332566927 0.0 0 0 0 0 0 0
+-5.6381557247154515 9.096027880381346 2.128339985912186 0.0 0.0 1.0 -0.9396926207859084 0.34202014332566927 0.0 0 0 0 0 0 0
+-6.57784834550136 9.438048023707015 2.128339985912186 0.0 0.0 1.0 -0.9396926207859084 0.34202014332566927 0.0 0 0 0 0 0 0
+-4.26645440816078 2.607176016897488 9.564545667997182 -0.0 -0.0 -1.0 -0.853290881632156 0.5214352033794976 0.0 0 0 0 0 0 0
+-3.413163526528624 2.0857408135179902 9.564545667997182 -0.0 -0.0 -1.0 -0.853290881632156 0.5214352033794976 0.0 0 0 0 0 0 0
+-2.559872644896468 1.5643056101384927 9.564545667997182 -0.0 -0.0 -1.0 -0.853290881632156 0.5214352033794976 0.0 0 0 0 0 0 0
+-1.706581763264312 1.0428704067589951 9.564545667997182 -0.0 -0.0 -1.0 -0.853290881632156 0.5214352033794976 0.0 0 0 0 0 0 0
+-0.853290881632156 0.5214352033794976 9.564545667997182 -0.0 -0.0 -1.0 -0.853290881632156 0.5214352033794976 0.0 0 0 0 0 0 0
+-0.5119745289792936 0.3128611220276985 8.964545667997182 0.853290881632156 -0.5214352033794976 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+-0.24677226187836604 0.5469035113870042 8.57478281286687 0.41128710313061007 -0.9115058523116738 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+0.10418890660015846 0.5908846518073247 8.185019957736559 -0.17364817766693078 -0.984807753012208 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+0.4189420908516441 0.4295201095558306 7.7952571026062465 -0.6982368180860735 -0.7158668492597177 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+0.5881034927091264 0.11888768591963762 7.405494247475934 -0.9801724878485442 -0.19814614319939605 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+0.5528857271222446 -0.23306087776481704 7.0157313923456215 -0.9214762118704076 0.38843479627469507 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+0.3255277583194561 -0.5040155538904626 6.625968537215309 -0.5425462638657601 0.8400259231507711 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+-0.014958415042843565 -0.5998135092004898 6.2362056820849965 0.024930691738072608 0.9996891820008164 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+0.11888768591963841 4.110057002922791 6.2362056820849965 -0.19814614319939736 -0.9801724878485439 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+-0.23306087776481726 4.074839237335909 6.625968537215309 0.38843479627469546 -0.9214762118704074 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+-0.5040155538904627 3.847481268533121 7.0157313923456215 0.8400259231507712 -0.5425462638657598 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+-0.5998135092004897 3.506995095170822 7.405494247475934 0.9996891820008162 0.024930691738071276 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+-0.48716280342951424 3.1717073068727917 7.7952571026062465 0.8119380057158571 0.5837436722347891 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+-0.2052120859954015 2.95813793774212 8.185019957736559 0.34202014332566916 0.9396926207859082 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+0.14805443861417522 2.940507138476218 8.57478281286687 -0.24675739769029204 0.9690772862290784 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+0.4498687217806408 3.12494200765555 8.964545667997182 -0.7497812029677347 0.661685837596859 -0.0 -0.0 -0.0 -1.0 0 0 0 0 0 0
+0.0 4.402441887767081 9.564545667997182 -0.0 -0.0 -1.0 0.0 1.0 0.0 0 0 0 0 0 0
+0.0 5.2829302653204975 9.564545667997182 -0.0 -0.0 -1.0 0.0 1.0 0.0 0 0 0 0 0 0
+0.0 6.163418642873914 9.564545667997182 -0.0 -0.0 -1.0 0.0 1.0 0.0 0 0 0 0 0 0
+-0.5998135092004897 7.058865435470174 8.964545667997182 0.9996891820008162 -0.024930691738072275 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+-0.4871628034295133 7.394153223768205 8.57478281286687 0.8119380057158555 -0.5837436722347913 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+-0.20521208599540094 7.607722592898875 8.185019957736559 0.34202014332566827 -0.9396926207859086 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+0.1480544386141768 7.625353392164777 7.7952571026062465 -0.2467573976902947 -0.9690772862290777 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+0.4498687217806412 7.440918522985445 7.405494247475934 -0.7497812029677353 -0.6616858375968582 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+0.5953435239601033 7.118513243215821 7.0157313923456215 -0.9922392066001722 -0.1243437046474847 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+0.5339230852868814 6.770180626015432 6.625968537215309 -0.889871808811469 0.45621065735316235 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+0.286952387172791 6.516974076405193 6.2362056820849965 -0.47825397862131835 0.8782215733702284 -0.0 0.0 0.0 1.0 0 0 0 0 0 0
+0.47825397862131835 6.165685447057101 6.836205682084996 -0.0 -0.0 -1.0 0.47825397862131835 -0.8782215733702284 0.0 0 0 0 0 0 0
+0.9565079572426367 5.287463873686873 6.836205682084996 -0.0 -0.0 -1.0 0.47825397862131835 -0.8782215733702284 0.0 0 0 0 0 0 0
+1.4347619358639552 4.409242300316644 6.836205682084996 -0.0 -0.0 -1.0 0.47825397862131835 -0.8782215733702284 0.0 0 0 0 0 0 0
+1.9130159144852734 3.5310207269464158 6.836205682084996 -0.0 -0.0 -1.0 0.47825397862131835 -0.8782215733702284 0.0 0 0 0 0 0 0
+2.3912698931065917 2.6527991535761872 6.836205682084996 -0.0 -0.0 -1.0 0.47825397862131835 -0.8782215733702284 0.0 0 0 0 0 0 0
+2.86952387172791 1.7745775802059587 6.836205682084996 -0.0 -0.0 -1.0 0.47825397862131835 -0.8782215733702284 0.0 0 0 0 0 0 0
+3.347777850349228 0.8963560068357302 6.836205682084996 -0.0 -0.0 -1.0 0.47825397862131835 -0.8782215733702284 0.0 0 0 0 0 0 0
diff --git a/examples/oxdna/extensions.top b/examples/oxdna/extensions.top
new file mode 100644
index 00000000..72e79554
--- /dev/null
+++ b/examples/oxdna/extensions.top
@@ -0,0 +1,79 @@
+78 2
+1 T 1 -1
+1 A 2 0
+1 T 3 1
+1 T 4 2
+1 T 5 3
+1 G 6 4
+1 G 7 5
+1 G 8 6
+1 G 9 7
+1 G 10 8
+1 G 11 9
+1 G 12 10
+1 G 13 11
+1 C 14 12
+1 C 15 13
+1 C 16 14
+1 C 17 15
+1 C 18 16
+1 C 19 17
+1 C 20 18
+1 C 21 19
+1 T 22 20
+1 T 23 21
+1 T 24 22
+1 G 25 23
+1 G 26 24
+1 G 27 25
+1 G 28 26
+1 G 29 27
+1 G 30 28
+1 G 31 29
+1 G 32 30
+1 A 33 31
+1 T 34 32
+1 A 35 33
+1 A 36 34
+1 A 37 35
+1 A 38 36
+1 A -1 37
+2 A 40 -1
+2 T 41 39
+2 A 42 40
+2 A 43 41
+2 A 44 42
+2 C 45 43
+2 C 46 44
+2 C 47 45
+2 C 48 46
+2 C 49 47
+2 C 50 48
+2 C 51 49
+2 C 52 50
+2 G 53 51
+2 G 54 52
+2 G 55 53
+2 G 56 54
+2 G 57 55
+2 G 58 56
+2 G 59 57
+2 G 60 58
+2 A 61 59
+2 A 62 60
+2 A 63 61
+2 C 64 62
+2 C 65 63
+2 C 66 64
+2 C 67 65
+2 C 68 66
+2 C 69 67
+2 C 70 68
+2 C 71 69
+2 T 72 70
+2 A 73 71
+2 T 74 72
+2 T 75 73
+2 T 76 74
+2 T 77 75
+2 T -1 76
diff --git a/scadnano/scadnano.py b/scadnano/scadnano.py
index 7251a4d1..47d0f0fd 100644
--- a/scadnano/scadnano.py
+++ b/scadnano/scadnano.py
@@ -54,7 +54,7 @@
 # commented out for now to support Py3.6, which does not support this feature
 # from __future__ import annotations
 
-__version__ = "0.17.5"  # version line; WARNING: do not remove or change this line or comment
+__version__ = "0.17.6"  # version line; WARNING: do not remove or change this line or comment
 
 import dataclasses
 from abc import abstractmethod, ABC, ABCMeta
@@ -64,7 +64,7 @@
 import re
 from builtins import ValueError
 from dataclasses import dataclass, field, InitVar, replace
-from typing import Tuple, List, Sequence, Iterable, Set, Dict, Union, Optional, Type, cast, Any, \
+from typing import Iterator, Tuple, List, Sequence, Iterable, Set, Dict, Union, Optional, Type, cast, Any, \
     TypeVar, Generic, Callable, AbstractSet
 from collections import defaultdict, OrderedDict, Counter
 import sys
@@ -1236,16 +1236,68 @@ def helices_view_order_inverse(self, idx: int) -> int:
         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 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()
 
 
 @dataclass
@@ -1532,6 +1584,23 @@ def major_tick_periodic_distances_is_default(self) -> bool:
     def major_ticks_is_default(self) -> bool:
         return self.major_ticks is None
 
+    def backbone_angle_at_offset(self, offset: int, forward: bool, geometry: Geometry) -> float:
+        """
+        Computes the backbone angle at *offset* for the strand in the direction given by *forward*.
+
+        :param offset:
+            offset on this helix
+        :param forward:
+            whether to compute angle for the forward or reverse strand
+        :return:
+            backbone angle at *offset* for the strand in the direction given by *forward*.
+        """
+        degrees_per_base = 360 / geometry.bases_per_turn
+        angle = self.roll + offset * degrees_per_base
+        if not forward:
+            angle += geometry.minor_groove_angle
+        return angle
+
 
 def _is_close(x1: float, x2: float) -> bool:
     return abs(x1 - x2) < _floating_point_tolerance
@@ -2078,7 +2147,7 @@ def get_seq_start_idx(self) -> int:
         return self_seq_idx_start
 
 
-default_display_angle = 45.0
+default_display_angle = 35.0
 
 default_display_length = 1.0
 
@@ -2099,8 +2168,9 @@ class Extension(_JSONSerializable, Generic[DomainLabel]):
         import scadnano as sc
 
         domain = sc.Domain(helix=0, forward=True, start=0, end=10)
-        toehold = sc.Extension(num_bases=5)
-        strand = sc.Strand([domain, toehold])
+        left_toehold = sc.Extension(num_bases=6)
+        right_toehold = sc.Extension(num_bases=5)
+        strand = sc.Strand([left_toehold, domain, right_toehold])
 
     It can also be created with chained method calls
 
@@ -2109,14 +2179,26 @@ class Extension(_JSONSerializable, Generic[DomainLabel]):
         import scadnano as sc
 
         design = sc.Design(helices=[sc.Helix(max_offset=10)])
-        design.draw_strand(0,0).move(10).extension_3p(5)
+        design.draw_strand(0,0).extension_5p(3).move(10).extension_3p(2)
+
+    which makes this strand with an :any:`Extension` on the side of the length-10 :any:`Domain`:
+
+    .. code-block:: none
+
+       [
+        \              >
+         \            /
+          \          /
+           ----------
     """
 
     num_bases: int
-    """Length (in DNA bases) of this :any:`Loopout`."""
+    """Length (in DNA bases) of this :any:`Extension`."""
 
     display_length: float = default_display_length
-    """Length (in nm) to display in the scadnano web app."""
+    """
+    Length (in nm) to display the line representing the :any:`Extension` in the scadnano web app.
+    """
 
     display_angle: float = default_display_angle
     """
@@ -2126,6 +2208,8 @@ class Extension(_JSONSerializable, Generic[DomainLabel]):
     0 degrees means parallel to the adjacent domain.
     90 degrees means pointing away from the helix.
     180 degrees means means antiparallel to the adjacent domain (overlapping).
+    If a forward strand, will go above the strand; if a reverse strand, will go below,
+    for degrees strictly between 0 and 180.
     """
 
     label: Optional[DomainLabel] = None
@@ -2161,7 +2245,7 @@ def to_json_serializable(self, suppress_indent: bool = True, **kwargs: Any) \
         return NoIndent(json_map) if suppress_indent else json_map
 
     def dna_length(self) -> int:
-        """Length of this :any:`Extension`; same as field :py:data:`Extension.length`."""
+        """Length of this :any:`Extension`; same as field :data:`Extension.num_bases`."""
         return self.num_bases
 
     def set_label(self, label: Optional[DomainLabel]) -> None:
@@ -2403,8 +2487,11 @@ def loopout(self, helix: int, length: int, offset: Optional[int] = None, move: O
         self.design.append_domain(self._strand, Loopout(length))
         return self
 
-    def extension_3p(self, num_bases: int, display_length: float = 1.0,
-                     display_angle: float = 45.0) -> 'StrandBuilder[StrandLabel, DomainLabel]':
+    def extension_3p(self,
+                     num_bases: int,
+                     display_length: float = default_display_length,
+                     display_angle: float = default_display_angle
+                     ) -> 'StrandBuilder[StrandLabel, DomainLabel]':
         """
         Creates an :any:`Extension` after verifying that it is valid to add an :any:`Extension` to
         the :any:`Strand` as a 3' :any:`Extension`.
@@ -2439,8 +2526,11 @@ def _verify_strand_is_not_circular(self):
     def _most_recently_added_substrand_is_loopout(self):
         return self._most_recently_added_substrand_is_instance_of_class(Loopout)
 
-    def extension_5p(self, num_bases: int, display_length: float = 1.0,
-                     display_angle: float = 45.0) -> 'StrandBuilder[StrandLabel, DomainLabel]':
+    def extension_5p(self,
+                     num_bases: int,
+                     display_length: float = default_display_length,
+                     display_angle: float = default_display_angle
+                     ) -> 'StrandBuilder[StrandLabel, DomainLabel]':
         """
         Creates an :any:`Extension` after verifying that it is valid to add an :any:`Extension` to
         the :any:`Strand` as a 5' :any:`Extension`.
@@ -2538,6 +2628,8 @@ def to(self, offset: int) -> 'StrandBuilder[StrandLabel, DomainLabel]':
             self._strand = Strand(domains=[domain])
             self.design.add_strand(self._strand)
 
+        self.design._check_strand_has_legal_offsets_in_helices(self._strand)
+
         self.current_offset = offset
 
         return self
@@ -3522,7 +3614,7 @@ def assign_dna_complement_from(self, other: 'Strand') -> None:  # remove quotes
                 strand_complement_builder.append(wildcards)
 
         for (domain_idx, domain_self) in enumerate(self.domains):
-            if isinstance(domain_self, Loopout):
+            if isinstance(domain_self, (Loopout, Extension)):
                 domain_self_dna_sequence = DNA_base_wildcard * domain_self.dna_length()
             else:
                 helix = domain_self.helix
@@ -4105,69 +4197,6 @@ def optional_field(default_value: Any, json_map: Dict[str, Any], main_key: str,
     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()
-
 ##############################################################################
 # plate maps
 
@@ -6925,6 +6954,125 @@ def _write_plates_default(self, directory: str, filename: Optional[str], strands
 
         workbook.save(filename_plate)
 
+    def to_oxview_format(self, warn_duplicate_strand_names: bool = True, use_strand_colors: bool = True) -> dict:
+        """
+        Exports to oxView format.
+
+        :param warn_duplicate_strand_names:
+            if True, prints a warning to the screen indicating when strands are found to
+            have duplicate names. (default: True)
+        :param use_strand_color:
+            if True (default), sets the color of each nucleotide in a strand in oxView to the color
+            of the strand.        
+        """
+        import datetime
+        self._check_legal_design(warn_duplicate_strand_names)
+        system = _convert_design_to_oxdna_system(self)
+
+        oxview_strands: List[Dict[str, Any]] = []
+        nuc_count = 0
+        strand_count = 0
+        strand_nuc_start = [-1]
+        for strand1, oxdna_strand in zip(self.strands, system.strands):
+            strand_count += 1
+            oxvnucs: List[Dict[str, Any]] = []
+            strand_nuc_start.append(nuc_count)
+            oxvstrand = {'id': strand_count, 
+                         'class': 'NucleicAcidStrand', 
+                         'end5': nuc_count, 
+                         'end3': nuc_count+len(oxdna_strand.nucleotides), 
+                         'monomers': oxvnucs}
+            if use_strand_colors and (strand1.color is not None):
+                scolor = strand1.color.to_cadnano_v2_int_hex()
+            else:
+                scolor = None
+            
+            for index_in_strand, nuc in enumerate(oxdna_strand.nucleotides):
+                oxvnuc = {'id': nuc_count, 
+                          'p': [nuc.r.x, nuc.r.y, nuc.r.z],
+                          'a1': [nuc.b.x, nuc.b.y, nuc.b.z],
+                          'a3': [nuc.n.x, nuc.n.y, nuc.n.z],
+                          'class': 'DNA', 
+                          'type': nuc.base, 
+                          'cluster': 1}
+                if index_in_strand != 0:
+                    oxvnuc['n5'] = nuc_count - 1
+                if index_in_strand != len(oxdna_strand.nucleotides) - 1:
+                    oxvnuc['n3'] = nuc_count + 1
+                if use_strand_colors and (scolor is not None):
+                    oxvnuc['color'] = scolor
+                nuc_count += 1
+                oxvnucs.append(oxvnuc)
+            oxview_strands.append(oxvstrand)
+
+        for si1, (strand1, oxv_strand1) in enumerate(zip(self.strands, oxview_strands)):
+            for si2, strand2 in enumerate(self.strands):
+                if not strand1.overlaps(strand2):
+                    continue
+                s1_nuc_idx = strand_nuc_start[si1+1]
+                for domain1 in strand1.domains:
+                    if isinstance(domain1, (Loopout, Extension)):
+                        continue
+                    s2_nuc_idx = strand_nuc_start[si2+1]
+                    for domain2 in strand2.domains:
+                        if isinstance(domain2, (Loopout, Extension)):
+                            continue
+                        if not domain1.overlaps(domain2):
+                            continue
+                        overlap_left, overlap_right = domain1.compute_overlap(domain2)
+                        s1_left = domain1.domain_offset_to_strand_dna_idx(overlap_left, False)
+                        s1_right = domain1.domain_offset_to_strand_dna_idx(overlap_right, False)
+                        s2_left = domain2.domain_offset_to_strand_dna_idx(overlap_left, False)
+                        s2_right = domain2.domain_offset_to_strand_dna_idx(overlap_right, False)
+                        if domain1.forward:
+                            d1range = range(s1_left, s1_right)
+                            d2range = range(s2_left, s2_right, -1)
+                        else:
+                            d1range = range(s1_right+1, s1_left+1)
+                            d2range = range(s2_right-1, s2_left-1, -1)
+                        assert len(d1range) == len(d2range)
+
+                        # Check for mismatches, and do not add a pair if the bases are *known*
+                        # to mismatch.  (FIXME: this must be changed if scadnano later supports
+                        # degenerate base codes.)
+                        for d1, d2 in zip(d1range, d2range):
+                            if ((strand1.dna_sequence is not None) and 
+                                (strand2.dna_sequence is not None) and
+                                (strand1.dna_sequence[d1] != "?") and
+                                (strand2.dna_sequence[d2] != "?") and
+                                (wc(strand1.dna_sequence[d1]) != strand2.dna_sequence[d2])):
+                                continue
+
+                            oxv_strand1['monomers'][d1]['bp'] = s2_nuc_idx + d2
+                            if 'bp' in oxview_strands[si2]['monomers'][d2]:
+                                if oxview_strands[si2]['monomers'][d2]['bp'] != s1_nuc_idx + d1:
+                                    print (s2_nuc_idx+d2, s1_nuc_idx+d1, oxview_strands[si2]['monomers'][d2]['bp'], domain1, domain2)
+
+        b = system.compute_bounding_box()
+        oxvsystem = {'box': [b.x, b.y, b.z], 
+                     'date': datetime.datetime.now().isoformat(), 
+                     'systems': [{'id': 0, 'strands': oxview_strands}], 
+                     'forces': [], 'selections': []}
+
+        return oxvsystem
+
+    def write_oxview_file(self, directory: str = '.', filename: Optional[str] = None, warn_duplicate_strand_names: bool = True, use_strand_colors: bool = True) -> None:
+        """Writes an oxView file rerpesenting this design.
+
+        :param directory:
+            directy in which to write the file (default: current working directory)
+        :param filename:
+            name of the file to write (default: name of the running script with .oxview extension)
+        :param warn_duplicate_strand_names:
+            if True, prints a warning to the screen indicating when strands are found to
+            have duplicate names. (default: True)
+        :param use_strand_color:
+            if True (default), sets the color of each nucleotide in a strand in oxView to the color
+            of the strand.
+        """
+        oxvsystem = self.to_oxview_format(warn_duplicate_strand_names=warn_duplicate_strand_names, use_strand_colors=use_strand_colors)
+        write_file_same_name_as_running_python_script(json.dumps(oxvsystem), 'oxview', directory, filename)
+
     def to_oxdna_format(self, warn_duplicate_strand_names: bool = True) -> Tuple[str, str]:
         """Exports to oxdna format.
 
@@ -6973,14 +7121,14 @@ def write_oxdna_files(self, directory: str = '.', filename_no_extension: Optiona
         """
         dat, top = self.to_oxdna_format(warn_duplicate_strand_names)
 
-        write_file_same_name_as_running_python_script(dat, 'dat', directory, filename_no_extension)
-        write_file_same_name_as_running_python_script(top, 'top', directory, filename_no_extension)
+        write_file_same_name_as_running_python_script(dat, 'dat', directory, filename_no_extension, add_extension=True)
+        write_file_same_name_as_running_python_script(top, 'top', directory, filename_no_extension, add_extension=True)
 
     # @_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: Optional[str] = None,
+    def write_scadnano_file(self, filename: Optional[str] = None, directory: str = '.',
                             extension: Optional[str] = None,
                             suppress_indent: bool = True,
                             warn_duplicate_strand_names: bool = True) -> None:
@@ -6996,12 +7144,12 @@ def write_scadnano_file(self, directory: str = '.', filename: Optional[str] = No
 
         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 directory:
+            directory in which to put file (default: current working directory)
         :param extension:
             extension for filename (default: ``.sc``)
             Mutually exclusive with `filename`
@@ -7683,6 +7831,31 @@ def strand_with_name(self, name: str) -> Optional[Strand]:
     def grid_of_helix(self, helix):
         pass
 
+    def add_helix(self, idx: int, helix: Helix) -> None:
+        """
+        Adds `helix` as a new :any:`Helix` with index `idx` to this Design.
+
+        :param idx:
+            index of new :any:`Helix`
+        :param helix:
+            the new :any:`Helix`
+        """
+        if idx in self.helices:
+            raise ValueError(f'there is already a helix with idx = {idx} in this design:\n'
+                             f'{self.helices[idx]}')
+        if helix.group not in self.groups:
+            raise ValueError(f'Helix group is {helix.group} but this design has no group with that name:\n'
+                             f'existing groups = {", ".join(self.groups.keys())}')
+
+        self.helices[idx] = helix
+
+        group = self.groups[helix.group]
+        group.helices_view_order.append(idx)
+
+        self._ensure_helices_distinct_objects()
+        self._set_helices_grid_positions_or_positions()
+        self._assign_default_helices_view_orders_to_groups()
+
 
 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
@@ -7701,7 +7874,7 @@ def _name_of_this_script() -> str:
 
 
 def write_file_same_name_as_running_python_script(contents: str, extension: str, directory: str = '.',
-                                                  filename: Optional[str] = None) -> None:
+                                                  filename: Optional[str] = None, add_extension: bool = False) -> None:
     """
     Writes a text file with `contents` whose name is (by default) the same as the name of the
     currently running script, but with extension ``.py`` changed to `extension`.
@@ -7715,16 +7888,18 @@ def write_file_same_name_as_running_python_script(contents: str, extension: str,
     :param filename:
         filename to use instead of the currently running script
     """
-    relative_filename = _get_filename_same_name_as_running_python_script(directory, extension, filename)
+    relative_filename = _get_filename_same_name_as_running_python_script(directory, extension, filename, add_extension=add_extension)
     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:
+                                                     filename: Optional[str], add_extension: bool = False) -> str:
     # if filename is not None, assume it has an extension
     if filename is None:
         filename = _name_of_this_script() + '.' + extension
+    elif add_extension:
+        filename += '.' + extension
     relative_filename = _create_directory_and_set_filename(directory, filename)
     return relative_filename
 
@@ -7786,6 +7961,11 @@ def __str__(self) -> str:
     def __repr__(self) -> str:
         return '_OxdnaVector({}, {}, {})'.format(self.x, self.y, self.z)
 
+    def __iter__(self) -> Iterator[float]:
+        yield self.x
+        yield self.y
+        yield self.z
+
     # counterclockwise rotation around axis
     # units of angle is degrees
     def rotate(self, angle: float, axis: "_OxdnaVector") -> "_OxdnaVector":
@@ -8069,7 +8249,7 @@ def _convert_design_to_oxdna_system(design: Design) -> _OxdnaSystem:
 
     for strand in design.strands:
         strand_domains: List[Tuple[_OxdnaStrand, bool]] = []
-        for domain in strand.domains:
+        for i, domain in enumerate(strand.domains):
             ox_strand = _OxdnaStrand()
             seq = domain.dna_sequence
             if seq is None:
@@ -8115,7 +8295,8 @@ def _convert_design_to_oxdna_system(design: Design) -> _OxdnaSystem:
                                 ox_strand.nucleotides.append(nuc)
                                 index += 1
 
-                        cen = origin_ + forward * (offset + mod) * geometry.rise_per_base_pair * NM_TO_OX_UNITS
+                        cen = origin_ + forward * (
+                                offset + mod) * geometry.rise_per_base_pair * NM_TO_OX_UNITS
                         norm = normal.rotate(step_rot * (offset + mod), forward)
                         # note oxDNA n vector points 3' to 5' opposite of scadnano forward vector
                         forw = -forward if domain.forward else forward
@@ -8130,7 +8311,7 @@ def _convert_design_to_oxdna_system(design: Design) -> _OxdnaSystem:
             # because we need to know the positions of nucleotides before and after the loopout
             # we temporarily store domain strands with a boolean that is true if it's a loopout
             # handle loopouts
-            else:
+            elif isinstance(domain, Loopout):
                 # we place the loopout nucleotides at temporary nonsense positions and orientations
                 # these will be updated later, for now we just need the base
                 for i in range(domain.length):
@@ -8141,6 +8322,15 @@ def _convert_design_to_oxdna_system(design: Design) -> _OxdnaSystem:
                     nuc = _OxdnaNucleotide(center, normal, forward, base)
                     ox_strand.nucleotides.append(nuc)
                 strand_domains.append((ox_strand, True))
+            elif isinstance(domain, Extension):
+                is_5p = i == 0
+                nucleotides = _compute_extension_nucleotides(design=design, strand=strand, is_5p=is_5p,
+                                                             helix_vectors=helix_vectors,
+                                                             mod_map=mod_map)
+                ox_strand.nucleotides.extend(nucleotides)
+                strand_domains.append((ox_strand, False))
+            else:
+                raise ValueError(f'unsupported substrand type {domain}')
 
         sstrand = _OxdnaStrand()
         # process loopouts and join strands
@@ -8166,3 +8356,56 @@ def _convert_design_to_oxdna_system(design: Design) -> _OxdnaSystem:
             sstrand = sstrand.join(dstrand)
         system.strands.append(sstrand)
     return system
+
+
+# FIXME: this is hacky and has some magic lines that I got my experimentation instead of understanding
+def _compute_extension_nucleotides(
+        design: Design,
+        strand: Strand,
+        is_5p: bool,
+        helix_vectors: Dict[int, Tuple[_OxdnaVector, _OxdnaVector, _OxdnaVector]],
+        mod_map: Dict[int, List[int]]) \
+        -> List[_OxdnaNucleotide]:
+    geometry = design.geometry
+    step_rot = -360 / geometry.bases_per_turn
+
+    adj_dom = strand.domains[1] if is_5p else strand.domains[-2]
+    adj_helix = design.helices[adj_dom.helix]
+    offset = adj_dom.offset_5p() if is_5p else adj_dom.offset_3p()  # offset of attached end of domain
+
+    origin_, forward, normal = helix_vectors[adj_dom.helix]
+
+    if not adj_dom.forward:
+        normal = normal.rotate(-geometry.minor_groove_angle, forward)
+
+    # oxDNA will rotate our backbone vector by +- _GROOVE_GAMMA (20 degrees)
+    # we apply the opposite rotation so that we get the expected vector from scadnano in oxDNA
+    groove_gamma_correction = _GROOVE_GAMMA if adj_dom.forward else -_GROOVE_GAMMA
+    normal = normal.rotate(groove_gamma_correction, forward).normalize()
+
+    # rotate normal by angle about the forward vector to get vector pointing at backbone at attached_offset
+    mod = mod_map[adj_dom.helix][offset - adj_helix.min_offset]
+    cen = origin_ + forward * (offset + mod) * geometry.rise_per_base_pair * NM_TO_OX_UNITS
+    norm = normal.rotate(step_rot * (offset + mod), forward)
+    # note oxDNA n vector points 3' to 5' opposite of scadnano forward vector
+    forw = -forward if adj_dom.forward else forward
+    ext = strand.domains[0] if is_5p else strand.domains[-1]
+
+    seq = ext.dna_sequence
+    if seq is None:
+        seq = 'T' * ext.dna_length()
+    if is_5p:
+        seq = seq[::-1]
+
+    new_forw = norm
+    new_norm = forw
+    nucs = []
+    for base in seq:
+        cen += norm
+        nuc = _OxdnaNucleotide(cen, new_norm, new_forw, base)
+        nucs.append(nuc)
+
+    if is_5p:
+        nucs = nucs[::-1]
+
+    return nucs


Test patch:
diff --git a/.github/workflows/run_unit_tests.yml b/.github/workflows/run_unit_tests.yml
index a89fd3c3..a0baddf4 100644
--- a/.github/workflows/run_unit_tests.yml
+++ b/.github/workflows/run_unit_tests.yml
@@ -8,7 +8,7 @@ jobs:
     runs-on: ubuntu-latest
     strategy:
       matrix:
-        python-version: [3.6, 3.7, 3.8, 3.9]
+        python-version: [3.7, 3.8, 3.9, "3.10", "3.11"]
 
     steps:
     - uses: actions/checkout@v2
diff --git a/tests/scadnano_tests.py b/tests/scadnano_tests.py
index cd41d2e2..fecf65d2 100644
--- a/tests/scadnano_tests.py
+++ b/tests/scadnano_tests.py
@@ -1,5 +1,7 @@
 import dataclasses
 import os
+import sys
+import tempfile
 import unittest
 import re
 import json
@@ -12,6 +14,7 @@
 import scadnano.origami_rectangle as rect
 import scadnano.modifications as mod
 
+from scadnano.scadnano import _convert_design_to_oxdna_system
 
 def strand_matching(strands: Iterable[sc.Strand], helix: int, forward: bool, start: int,
                     end: int) -> sc.Strand:
@@ -1218,10 +1221,12 @@ def test_write_idt_plate_excel_file(self) -> None:
 
         # add 10 strands in excess of 3 plates
         for plate_type in [sc.PlateType.wells96, sc.PlateType.wells384]:
+            num_strands = 3 * plate_type.num_wells_per_plate() + 10
             filename = f'test_excel_export_{plate_type.num_wells_per_plate()}.xls'
-            helices = [sc.Helix(max_offset=100) for _ in range(1)]
+            max_offset = num_strands * strand_len
+            helices = [sc.Helix(max_offset=max_offset) for _ in range(1)]
             design = sc.Design(helices=helices, strands=[], grid=sc.square)
-            for strand_idx in range(3 * plate_type.num_wells_per_plate() + 10):
+            for strand_idx in range(num_strands):
                 design.draw_strand(0, strand_len * strand_idx).move(strand_len).with_name(f's{strand_idx}')
                 design.strands[-1].set_dna_sequence('T' * strand_len)
 
@@ -5467,7 +5472,7 @@ class TestCircularStrandEdits(unittest.TestCase):
     '''
 
     def setUp(self) -> None:
-        helices = [sc.Helix(max_offset=10) for _ in range(3)]
+        helices = [sc.Helix(max_offset=50) for _ in range(3)]
         self.design = sc.Design(helices=helices, strands=[])
         self.design.draw_strand(0, 0).move(10).cross(1).move(-10)
         self.design.draw_strand(0, 15).move(5).cross(1).move(-10).cross(0).move(5)
@@ -6879,6 +6884,135 @@ def test_dna_sequence_in__right_then_left_deletions_and_insertions(self) -> None
         #     self.assertEqual("TTTACGTACGT", ss1.dna_sequence_in(2, 10))
 
 
+class TestOxviewExport(unittest.TestCase):
+    def test_export(self):
+        "Ensures that OxView export matches OxDNA export."
+        # Uses the basic design from OxdnaExport
+        helices = [sc.Helix(max_offset=7), sc.Helix(max_offset=7)]
+        design = sc.Design(helices=helices, grid=sc.square)
+        design.draw_strand(0, 0).move(7).cross(1).move(-7).with_color(
+            sc.Color(254, 123, 222))
+        design.draw_strand(0, 7).move(-7).cross(1).move(7)
+
+        oxdna_system = _convert_design_to_oxdna_system(design)
+
+        oxv = design.to_oxview_format(use_strand_colors=True)
+        oxv_no_color = design.to_oxview_format(use_strand_colors=False)
+
+        # Is the box correct?
+        self.assertEqual(list(oxdna_system.compute_bounding_box()), oxv['box'])
+
+        # Do we have the same number of strands?
+        self.assertEqual(len(oxdna_system.strands),
+                         len(oxv['systems'][0]['strands']))
+
+        for i, (oxdna_strand, oxview_strand, oxview_nocolor_strand,
+                des_strand) in enumerate(
+                    zip(oxdna_system.strands, oxv['systems'][0]['strands'],
+                        oxv_no_color['systems'][0]['strands'],
+                        design.strands)):
+            self.assertEqual(i + 1, oxview_strand['id'])
+
+            if des_strand.color:
+                scolor = des_strand.color.to_cadnano_v2_int_hex()
+            else:
+                scolor = None
+
+            self.assertEqual(len(oxdna_strand.nucleotides),
+                             len(oxview_strand['monomers']))
+            for j, (oxdna_nt, oxview_nt, oxview_nocolor_nt) in enumerate(
+                    zip(oxdna_strand.nucleotides, oxview_strand['monomers'],
+                        oxview_nocolor_strand['monomers'])):
+                self.assertListEqual(list(oxdna_nt.r), oxview_nt['p'])
+                self.assertListEqual(list(oxdna_nt.b), oxview_nt['a1'])
+                self.assertListEqual(list(oxdna_nt.n), oxview_nt['a3'])
+                if scolor is not None:
+                    self.assertEqual(scolor, oxview_nt['color'])
+                    self.assertNotIn('color', oxview_nocolor_nt)
+                else:
+                    self.assertNotIn('color', oxview_nt)
+                    self.assertNotIn('color', oxview_nocolor_nt)
+                self.assertEqual(oxdna_nt.base, oxview_nt['type'])
+
+    def test_bp(self):
+        des  = sc.Design()
+        des.set_grid(sc.Grid.square)
+        des.helices = {i :sc.Helix(max_offset=20, idx=i, grid_position=(0,i)) for i in range(3)}
+        des.draw_strand(0, 0).to(6).with_deletions(4).to(15).cross(1, 9).to(20).with_insertions((15, 2)).cross(0).to(9)
+        des.draw_strand(1, 0).to(9).cross(0).to(0).with_deletions(4)
+        des.draw_strand(1, 20).to(2).with_insertions((15, 2)).cross(2, 0).to(20).with_sequence('TTTCTCATGGGAAGCAAACTCGGTTTCCGCGTCGGATAGT')
+        des.draw_strand(2, 8).to(5).loopout(2, 5, 4).to(0)
+        des.draw_strand(2, 20).extension_5p(8).to(12).extension_3p(8).with_sequence('ATACTGGAACTACGCGCGTGAATT', assign_complement=False)
+
+        oxv = des.to_oxview_format()
+
+        strands = oxv['systems'][0]['strands']
+
+        # Basic complements with a deletion (wildcard sequences)
+        for i in range(0, 8):
+            self.assertEqual(strands[0]['monomers'][i]['bp'], strands[1]['monomers'][-i-1]['id'])
+            self.assertEqual(strands[1]['monomers'][-i-1]['bp'], strands[0]['monomers'][i]['id'])
+
+        # Self-complementary strand (wildcard sequences)
+        for i in range(8, 14):
+            self.assertEqual(strands[0]['monomers'][i]['bp'], strands[0]['monomers'][7-i]['id'])
+
+        # Insertion (defined sequences)
+        for i in range(14, 27):
+            self.assertEqual(strands[0]['monomers'][i]['bp'], strands[2]['monomers'][26-i]['id'])
+
+        # Before, in, and after a loopout (one strand with no sequence, one with defined sequence)
+        for i in range(0, 3):
+            self.assertEqual(strands[3]['monomers'][i]['bp'], strands[2]['monomers'][27-i]['id'])
+
+        for i in range(3, 8):
+            self.assertNotIn('bp', strands[3]['monomers'][i])
+
+        for i in range(8, 12):
+            self.assertEqual(strands[3]['monomers'][i]['bp'], strands[2]['monomers'][23+8-i]['id'])
+
+        # Mismatches should not be paired; also, extensions:
+        for i in range(0, 8): # 5p extension
+            self.assertNotIn('bp', strands[4]['monomers'][i])
+        for i in range(8, 12): # complementary
+            print(i)
+            self.assertEqual(strands[4]['monomers'][i]['bp'], strands[2]['monomers'][40+7-i]['id'])
+        for i in range(12, 14): # two mismatches
+            self.assertNotIn('bp', strands[4]['monomers'][i])
+            self.assertNotIn('bp', strands[2]['monomers'][32+15-i])
+        for i in range(14, 16): # complementary again
+            self.assertEqual(strands[4]['monomers'][i]['bp'], strands[2]['monomers'][32+15-i]['id'])
+        for i in range(16, len(strands[4]['monomers'])): # 3p extension
+            self.assertNotIn('bp', strands[4]['monomers'][i])
+        
+        # Unbound region
+        for i in range(28, 32):
+            self.assertNotIn('bp', strands[2]['monomers'][i])
+
+    def test_export_file(self):
+        "Ensures that file export works, and writes a suitable JSON file that matches the output."
+        self.maxDiff = None
+        helices = [sc.Helix(max_offset=7), sc.Helix(max_offset=7)]
+        design = sc.Design(helices=helices, grid=sc.square)
+        design.draw_strand(0, 0).move(7).cross(1).move(-7).with_color(
+            sc.Color(254, 123, 222))
+        design.draw_strand(0, 7).move(-7).cross(1).move(7)
+
+        oxv = design.to_oxview_format(use_strand_colors=True)
+
+        with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f:
+            design.write_oxview_file(filename=f.name)
+            filename = f.name
+        with open(filename, 'r') as f2:
+            oxv2 = json.load(f2)
+        os.unlink(filename)
+
+        # The dates won't be equal, so delete them
+        del oxv['date']
+        del oxv2['date']
+
+        self.assertEqual(oxv, oxv2)
+
 class TestOxdnaExport(unittest.TestCase):
     def setUp(self) -> None:
         self.OX_UNITS_TO_NM = 0.8518
@@ -7556,6 +7690,27 @@ def test_loopout_design(self) -> None:
 
             self.assertAlmostEqual(self.EXPECTED_ADJ_NUC_CM_DIST2, sqr_dist2)
 
+    def test_file_output(self) -> None:
+        # Arbitrary design
+        helix = [sc.Helix(max_offset=14)]
+        design = sc.Design(helices=helix, grid=sc.square)
+        design.draw_strand(0, 0).to(4).loopout(0, 4).to(7)
+        design.draw_strand(0, 7).to(0)
+
+        scriptname = os.path.basename(sys.argv[0])[:-3]
+
+        dat, top = design.to_oxdna_format()
+
+        with tempfile.TemporaryDirectory() as tmpdir:
+            # First, write to the directory, in which case the names should be the script name
+            design.write_oxdna_files(directory=tmpdir)
+            self.assertEqual(top, open(tmpdir + '/' + scriptname + '.top').read())
+            self.assertEqual(dat, open(tmpdir + '/' + scriptname + '.dat').read())
+
+            # Now, write, to a specific filename without extensions
+            design.write_oxdna_files(directory=tmpdir, filename_no_extension='oxdna-Export with spaces in name')
+            self.assertEqual(top, open(tmpdir + '/oxdna-Export with spaces in name.top').read())
+            self.assertEqual(dat, open(tmpdir + '/oxdna-Export with spaces in name.dat').read())
 
 class TestPlateMaps(unittest.TestCase):