From 4e75ec269794b55141ead2b93cae347c23e2926d Mon Sep 17 00:00:00 2001 From: David Doty Date: Thu, 28 Mar 2024 14:11:29 -0700 Subject: [PATCH] closes #588: export to oxView format --- lib/src/actions/actions.dart | 37 +++++ lib/src/middleware/oxdna_export.dart | 166 +++++++++++++++++++-- lib/src/reducers/app_ui_state_reducer.dart | 4 + lib/src/serializers.dart | 2 + lib/src/state/app_ui_state.dart | 2 + lib/src/state/app_ui_state_storables.dart | 3 + lib/src/state/strand.dart | 53 +++++++ lib/src/view/menu.dart | 34 +++-- 8 files changed, 278 insertions(+), 23 deletions(-) diff --git a/lib/src/actions/actions.dart b/lib/src/actions/actions.dart index 493ad6bb..a4feaba7 100644 --- a/lib/src/actions/actions.dart +++ b/lib/src/actions/actions.dart @@ -4523,3 +4523,40 @@ abstract class OxdnaExport @memoized int get hashCode; } + +abstract class OxviewExport + with BuiltJsonSerializable + implements Action, Built { + bool get selected_strands_only; + + /************************ begin BuiltValue boilerplate ************************/ + factory OxviewExport({bool selected_strands_only = false}) { + return OxviewExport.from((b) => b..selected_strands_only = selected_strands_only); + } + + OxviewExport._(); + + factory OxviewExport.from([void Function(OxviewExportBuilder) updates]) = _$OxviewExport; + + static Serializer get serializer => _$oxviewExportSerializer; + + @memoized + int get hashCode; +} + +abstract class OxExportOnlySelectedStrandsSet + with BuiltJsonSerializable + implements Action, Built { + bool get only_selected; + + /************************ begin BuiltValue boilerplate ************************/ + factory OxExportOnlySelectedStrandsSet({bool only_selected}) = _$OxExportOnlySelectedStrandsSet._; + + OxExportOnlySelectedStrandsSet._(); + + static Serializer get serializer => + _$oxExportOnlySelectedStrandsSetSerializer; + + @memoized + int get hashCode; +} diff --git a/lib/src/middleware/oxdna_export.dart b/lib/src/middleware/oxdna_export.dart index 6483933e..eb169282 100644 --- a/lib/src/middleware/oxdna_export.dart +++ b/lib/src/middleware/oxdna_export.dart @@ -1,6 +1,8 @@ +import 'dart:convert'; import 'dart:html'; import 'dart:math'; import 'package:path/path.dart' as path; +import 'package:quiver/iterables.dart' as quiver; import 'package:redux/redux.dart'; import 'package:scadnano/src/state/design.dart'; @@ -16,9 +18,11 @@ import '../state/app_state.dart'; import '../actions/actions.dart' as actions; import '../state/helix.dart'; import '../util.dart' as util; +import 'export_cadnano_or_codenano_file.dart' as export_cadnano; +import '../constants.dart' as constants; oxdna_export_middleware(Store store, dynamic action, NextDispatcher next) { - if (action is actions.OxdnaExport) { + if (action is actions.OxdnaExport || action is actions.OxviewExport) { AppState state = store.state; List strands_to_export; @@ -34,18 +38,160 @@ First select some strands, or choose Export🡒oxDNA to export all strands in th strands_to_export = state.design.strands.toList(); } - Tuple2 dat_top = to_oxdna_format(state.design, strands_to_export); - String dat = dat_top.item1; - String top = dat_top.item2; + if (action is actions.OxdnaExport) { + Tuple2 dat_top = to_oxdna_format(state.design, strands_to_export); + String dat = dat_top.item1; + String top = dat_top.item2; + + String default_filename = state.ui_state.loaded_filename; + String default_filename_dat = path.setExtension(default_filename, '.dat'); + String default_filename_top = path.setExtension(default_filename, '.top'); + + util.save_file(default_filename_dat, dat); + util.save_file(default_filename_top, top); + } else if (action is actions.OxviewExport) { + String content = to_oxview_format(state.design, strands_to_export); + String default_filename = state.ui_state.loaded_filename; + String default_filename_ext = path.setExtension(default_filename, '.oxview'); + util.save_file(default_filename_ext, content); + } + } + next(action); +} - String default_filename = state.ui_state.loaded_filename; - String default_filename_dat = path.setExtension(default_filename, '.dat'); - String default_filename_top = path.setExtension(default_filename, '.top'); +String to_oxview_format(Design design, List strands_to_export) { + OxdnaSystem system = convert_design_to_oxdna_system(design, strands_to_export); + List> oxview_strands = []; + int nuc_count = 0; + int strand_count = 0; + List strand_nuc_start = [-1]; + assert(strands_to_export.length == system.strands.length); + + for (int i = 0; i < strands_to_export.length; i++) { + Strand sc_strand = strands_to_export[i]; + OxdnaStrand oxdna_strand = system.strands[i]; + + strand_count += 1; + List> oxvnucs = []; + strand_nuc_start.add(nuc_count); + Map oxvstrand = { + 'id': strand_count, + 'class': 'NucleicAcidStrand', + 'end5': nuc_count, + 'end3': nuc_count + system.strands[i].nucleotides.length, + 'monomers': oxvnucs + }; + + int scolor; + if (sc_strand.color != null) { + scolor = export_cadnano.to_cadnano_v2_int_hex(sc_strand.color); + } else { + scolor = null; + } - util.save_file(default_filename_dat, dat); - util.save_file(default_filename_top, top); + for (int index_in_strand = 0; index_in_strand < oxdna_strand.nucleotides.length; index_in_strand++) { + OxdnaNucleotide nuc = oxdna_strand.nucleotides[index_in_strand]; + Map 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 != oxdna_strand.nucleotides.length - 1) { + oxvnuc['n3'] = nuc_count + 1; + } + if (scolor != null) { + oxvnuc['color'] = scolor; + } + nuc_count += 1; + oxvnucs.add(oxvnuc); + } + oxview_strands.add(oxvstrand); } - next(action); + + for (int si1 = 0; si1 < strands_to_export.length; si1++) { + Strand sc_strand1 = strands_to_export[si1]; + Map oxv_strand1 = oxview_strands[si1]; + for (int si2 = 0; si2 < strands_to_export.length; si2++) { + Strand sc_strand2 = strands_to_export[si2]; + if (!sc_strand1.overlaps(sc_strand2)) { + continue; + } + int s1_nuc_idx = strand_nuc_start[si1 + 1]; + for (var domain1 in sc_strand1.domains) { + if (domain1 is Loopout || domain1 is Extension) { + continue; + } + int s2_nuc_idx = strand_nuc_start[si2 + 1]; + for (var domain2 in sc_strand2.domains) { + if (domain2 is Loopout || domain2 is Extension) { + continue; + } + if (!domain1.overlaps(domain2)) { + continue; + } + Tuple2 overlap = domain1.compute_overlap(domain2); + int overlap_left = overlap.item1; + int overlap_right = overlap.item2; + int s1_left = sc_strand1.domain_offset_to_strand_dna_idx(domain1, overlap_left, false); + int s1_right = sc_strand1.domain_offset_to_strand_dna_idx(domain1, overlap_right, false); + int s2_left = sc_strand2.domain_offset_to_strand_dna_idx(domain2, overlap_left, false); + int s2_right = sc_strand2.domain_offset_to_strand_dna_idx(domain2, overlap_right, false); + List d1range; + List d2range; + if (domain1.forward) { + d1range = List.from(quiver.range(s1_left, s1_right)); + d2range = List.from(quiver.range(s2_left, s2_right, -1)); + } else { + d1range = List.from(quiver.range(s1_right + 1, s1_left + 1)); + d2range = List.from(quiver.range(s2_right - 1, s2_left - 1, -1)); + } + assert(d1range.length == d2range.length); + + // 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 (int i = 0; i < d1range.length; i++) { + int d1 = d1range[i]; + int d2 = d2range[i]; + if (sc_strand1.dna_sequence != null && + sc_strand2.dna_sequence != null && + sc_strand1.dna_sequence[d1] != constants.DNA_BASE_WILDCARD && + sc_strand2.dna_sequence[d2] != constants.DNA_BASE_WILDCARD && + util.wc(sc_strand1.dna_sequence[d1]) != sc_strand2.dna_sequence[d2]) { + continue; + } + oxv_strand1['monomers'][d1]['bp'] = s2_nuc_idx + d2; + if (oxview_strands[si2]['monomers'][d2].containsKey('bp')) { + 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}'); + } + } + } + } + } + } + } + var b = system.compute_bounding_box(); + Map oxvsystem = { + 'box': [b.x, b.y, b.z], + 'date': DateTime.now().toIso8601String(), + 'systems': [ + {'id': 0, 'strands': oxview_strands} + ], + 'forces': [], + 'selections': [], + }; + String content = jsonEncode(oxvsystem); + + return content; } Tuple2 to_oxdna_format(Design design, [List strands_to_export = null]) { diff --git a/lib/src/reducers/app_ui_state_reducer.dart b/lib/src/reducers/app_ui_state_reducer.dart index 2f5f329c..a0dffa76 100644 --- a/lib/src/reducers/app_ui_state_reducer.dart +++ b/lib/src/reducers/app_ui_state_reducer.dart @@ -244,6 +244,9 @@ bool show_base_pair_lines_with_mismatches_reducer( bool export_svg_text_separately_reducer(bool _, actions.ExportSvgTextSeparatelySet action) => action.export_svg_text_separately; +bool ox_export_only_selected_strands_reducer(bool _, actions.OxExportOnlySelectedStrandsSet action) => + action.only_selected; + bool display_major_tick_widths_reducer(bool _, actions.SetDisplayMajorTickWidths action) => action.show; bool strand_paste_keep_color_reducer(bool _, actions.StrandPasteKeepColorSet action) => action.keep; @@ -460,6 +463,7 @@ AppUIStateStorables app_ui_state_storable_local_reducer(AppUIStateStorables stor ..show_base_pair_lines = TypedReducer(show_base_pair_lines_reducer)(storables.show_base_pair_lines, action) ..show_base_pair_lines_with_mismatches = TypedReducer(show_base_pair_lines_with_mismatches_reducer)(storables.show_base_pair_lines_with_mismatches, action) ..export_svg_text_separately = TypedReducer(export_svg_text_separately_reducer)(storables.export_svg_text_separately, action) + ..ox_export_only_selected_strands = TypedReducer(ox_export_only_selected_strands_reducer)(storables.ox_export_only_selected_strands, action) ..only_display_selected_helices = TypedReducer(only_display_selected_helices_reducer)(storables.only_display_selected_helices, action) ..default_crossover_type_scaffold_for_setting_helix_rolls = TypedReducer(default_crossover_type_scaffold_for_setting_helix_rolls_reducer)(storables.default_crossover_type_scaffold_for_setting_helix_rolls, action) ..default_crossover_type_staple_for_setting_helix_rolls = TypedReducer(default_crossover_type_staple_for_setting_helix_rolls_reducer)(storables.default_crossover_type_staple_for_setting_helix_rolls, action) diff --git a/lib/src/serializers.dart b/lib/src/serializers.dart index 3e3adf1d..efc6231b 100644 --- a/lib/src/serializers.dart +++ b/lib/src/serializers.dart @@ -363,6 +363,8 @@ part 'serializers.g.dart'; ZoomSpeedSet, NewDesignSet, OxdnaExport, + OxviewExport, + OxExportOnlySelectedStrandsSet, Design, AssignDomainNameComplementFromBoundStrands, AssignDomainNameComplementFromBoundDomains, diff --git a/lib/src/state/app_ui_state.dart b/lib/src/state/app_ui_state.dart index 25983558..3a6d03f9 100644 --- a/lib/src/state/app_ui_state.dart +++ b/lib/src/state/app_ui_state.dart @@ -246,6 +246,8 @@ abstract class AppUIState with BuiltJsonSerializable implements Built storables.selection_box_intersection; + bool get ox_export_only_selected_strands => storables.ox_export_only_selected_strands; + static void _initializeBuilder(AppUIStateBuilder b) { b.copy_info = null; b.last_mod_5p = null; diff --git a/lib/src/state/app_ui_state_storables.dart b/lib/src/state/app_ui_state_storables.dart index fdac0928..dfbba962 100644 --- a/lib/src/state/app_ui_state_storables.dart +++ b/lib/src/state/app_ui_state_storables.dart @@ -130,6 +130,8 @@ abstract class AppUIStateStorables bool get export_svg_text_separately; + bool get ox_export_only_selected_strands; + static void _initializeBuilder(AppUIStateStorablesBuilder b) { // This ensures that even if these keys are not in localStorage (e.g., due to upgrading), // then they will be populated with a default value instead of raising an exception. @@ -188,6 +190,7 @@ abstract class AppUIStateStorables b.show_mouseover_data = false; b.selection_box_intersection = false; b.export_svg_text_separately = false; + b.ox_export_only_selected_strands = false; } /************************ begin BuiltValue boilerplate ************************/ diff --git a/lib/src/state/strand.dart b/lib/src/state/strand.dart index 52ee4f9e..96d3e955 100644 --- a/lib/src/state/strand.dart +++ b/lib/src/state/strand.dart @@ -753,6 +753,59 @@ abstract class Strand return rebuild((strand) => strand..substrands.replace(substrands_new)); } + /// Convert from offset on the given Domain's Helix to string index on the parent 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. + int domain_offset_to_strand_dna_idx(Domain domain, int offset, bool offset_closer_to_5p) { + if (domain.deletions.contains(offset)) { + throw ArgumentError('offset ${offset} illegally contains a deletion from ${domain.deletions}'); + } + + int len_adjust = this._net_ins_del_length_increase_from_5p_to(domain, offset, offset_closer_to_5p); + + int domain_str_idx; + if (domain.forward) { + offset += len_adjust; + domain_str_idx = offset - domain.start; + } else { + offset -= len_adjust; + domain_str_idx = domain.end - 1 - offset; + } + + return domain_str_idx + get_seq_start_idx(domain); + } + + /// 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`. + int _net_ins_del_length_increase_from_5p_to(Domain domain, int offset_edge, bool offset_closer_to_5p) { + int length_increase = 0; + for (int deletion in domain.deletions) { + if (_between_5p_and_offset(domain, deletion, offset_edge)) { + length_increase -= 1; + } + } + for (var insertion in domain.insertions) { + if (_between_5p_and_offset(domain, insertion.offset, offset_edge)) { + length_increase += insertion.length; + } + } + if (!offset_closer_to_5p) { + Map insertion_map = + Map.fromIterable(domain.insertions, key: (e) => e.offset, value: (e) => e.length); + if (insertion_map.containsKey(offset_edge)) { + int insertion_length = insertion_map[offset_edge]; + length_increase += insertion_length; + } + } + return length_increase; + } + + bool _between_5p_and_offset(Domain domain, int offset_to_test, int offset_edge) => + (domain.forward && domain.start <= offset_to_test && offset_to_test < offset_edge) || + (!domain.forward && offset_edge < offset_to_test && offset_to_test < domain.end); + String _trim_or_pad_sequence_to_desired_length(String dna_sequence_new, int desired_length) { // truncate dna_sequence_new if too long; pad with ?'s if to short int seq_len = dna_sequence_new.length; diff --git a/lib/src/view/menu.dart b/lib/src/view/menu.dart index e787db00..973f8d48 100644 --- a/lib/src/view/menu.dart +++ b/lib/src/view/menu.dart @@ -102,6 +102,7 @@ UiFactory ConnectedMenu = connect( ..default_crossover_type_staple_for_setting_helix_rolls = state.ui_state.default_crossover_type_staple_for_setting_helix_rolls ..selection_box_intersection = state.ui_state.selection_box_intersection + ..ox_export_only_selected_strands = state.ui_state.ox_export_only_selected_strands ..undo_redo = state.undo_redo); }, // Used for component test. @@ -161,6 +162,7 @@ mixin MenuPropsMixin on UiProps { bool default_crossover_type_scaffold_for_setting_helix_rolls; bool default_crossover_type_staple_for_setting_helix_rolls; bool export_svg_text_separately; + bool ox_export_only_selected_strands; LocalStorageDesignChoice local_storage_design_choice; bool clear_helix_selection_when_loading_new_design; bool show_slice_bar; @@ -1251,22 +1253,28 @@ cadnano files that have whitespace. ("Bad .json file format is detected in ..key = 'export-cadnano-no-whitespace')(), DropdownDivider({'key': 'divider-cadnano'}), (MenuDropdownItem() - ..on_click = ((_) => props.dispatch(actions.OxdnaExport())) + ..on_click = ((_) => props + .dispatch(actions.OxviewExport(selected_strands_only: props.ox_export_only_selected_strands))) + ..tooltip = "Export design to oxView files, which can be loaded in oxView." + ..display = 'oxView' + ..key = 'export-oxview')(), + (MenuDropdownItem() + ..on_click = ((_) => + props.dispatch(actions.OxdnaExport(selected_strands_only: props.ox_export_only_selected_strands))) ..tooltip = "Export design to oxDNA .dat and .top files, which can be loaded in oxDNA or oxView." ..display = 'oxDNA' ..key = 'export-oxdna')(), - (MenuDropdownItem() - ..on_click = ((_) => props.dispatch(actions.OxdnaExport(selected_strands_only: true))) - ..tooltip = "Export design to oxDNA .dat and .top files, which can be loaded in oxDNA or oxView.\n" - "Only exports the currently selected strands." - ..display = 'oxDNA (selected strands)' - ..key = 'export-oxdna-selected-strands')(), - //TODO: figure out if ENSnano is close to codenano format; if so this might work for exporting to it. - // (MenuDropdownItem() - // ..on_click = ((_) => props.dispatch(actions.ExportCodenanoFile())) - // ..tooltip = "Export design to codenano format." - // ..display = 'codenano' - // ..key = 'export-codenano')(), + (MenuBoolean() + ..value = props.ox_export_only_selected_strands + ..display = 'export only selected strands' + ..tooltip = '''\ +When selected, only selected strands will be exported to oxDNA or oxView formats.''' + ..name = 'ox-export-only-selected-strands' + ..onChange = (_) { + props.dispatch( + actions.OxExportOnlySelectedStrandsSet(only_selected: !props.ox_export_only_selected_strands)); + } + ..key = 'ox-export-only-selected-strands')(), ); }