@@ -10,6 +10,17 @@ import 'helix_group_move_reducer.dart';
1010import 'inline_insertions_deletions_reducer.dart' ;
1111import 'strands_reducer.dart' ;
1212import '../util.dart' as util;
13+ import '../state/extension.dart' ;
14+ import '../state/helix.dart' ;
15+ import '../state/group.dart' ;
16+ import '../state/domain.dart' ;
17+ import '../state/strand.dart' ;
18+ import '../state/grid_position.dart' ;
19+ import '../state/grid.dart' ;
20+ import '../constants.dart' as constants;
21+ import '../state/position3d.dart' ;
22+ import '../state/geometry.dart' ;
23+ import 'dart:math' ;
1324
1425////////////////////////////////////////////////////////////////////////////////////////////////////////////
1526// reducer composition
@@ -75,6 +86,9 @@ GlobalReducer<Design?, AppState> design_whole_global_reducer = combineGlobalRedu
7586 helix_remove_all_selected_design_global_reducer,
7687 ),
7788 TypedGlobalReducer <Design ?, AppState , actions.HelixGroupMoveCommit >(helix_group_move_commit_global_reducer),
89+ TypedGlobalReducer <Design ?, AppState , actions.ConvertExtensionsToBoundDomains >(
90+ convert_extensions_to_bound_domains_reducer,
91+ ),
7892]);
7993
8094// need to operate on Design so we can re-set helix svg coordinates
@@ -93,3 +107,272 @@ Design? design_geometry_set_reducer(Design? design, AppState state, actions.Geom
93107}
94108
95109Design ? new_design_set_reducer (Design ? design, actions.NewDesignSet action) => action.design;
110+
111+ Design ? convert_extensions_to_bound_domains_reducer (
112+ Design ? design,
113+ AppState state,
114+ actions.ConvertExtensionsToBoundDomains action,
115+ ) {
116+ if (design == null ) {
117+ return null ;
118+ }
119+
120+ return _convert_extensions_to_bound_domains (design, action.extension1, action.extension2);
121+ }
122+
123+ Design _convert_extensions_to_bound_domains (Design design, Extension extension1, Extension ? extension2) {
124+ // Step 1: Find the strands containing these extensions
125+ var strand1 = design.strands.firstWhere ((s) => s.extensions.contains (extension1));
126+ Strand ? strand2 =
127+ extension2 != null ? design.strands.firstWhere ((s) => s.extensions.contains (extension2)) : null ;
128+
129+ // Step 2: Calculate the new helix group position and pitch angle
130+ var new_helix_idx = _get_next_helix_idx (design);
131+ var new_group_name = _get_unique_group_name (design, new_helix_idx);
132+
133+ // Get geometry from the source helix group (where the neighboring domain is)
134+ var source_helix = design.helices[extension1.adjacent_domain.helix]! ;
135+ var source_group = design.groups[source_helix.group]! ;
136+ var source_geometry = source_group.geometry ?? design.geometry;
137+
138+ // Step 3: Calculate the correct group position and pitch angle
139+ var group_positioning = _calculate_group_positioning (design, extension1, source_geometry);
140+
141+ var new_group = HelixGroup (
142+ helices_view_order: [new_helix_idx],
143+ position: group_positioning.group_position,
144+ pitch: group_positioning.pitch_angle,
145+ yaw: 0 ,
146+ roll: 0 ,
147+ geometry: source_geometry, // Use the same geometry as the source group
148+ );
149+
150+ // Step 4: Create the new helix positioned to coincide with extension1
151+ // Calculate the required helix length - should be the maximum of the two extensions
152+ // since they overlap at the connection point
153+ int total_length =
154+ extension2 != null
155+ ? [extension1.num_bases, extension2.num_bases].reduce ((a, b) => a > b ? a : b)
156+ : extension1.num_bases;
157+
158+ var new_helix = Helix (
159+ idx: new_helix_idx,
160+ grid: Grid .none,
161+ position: Position3D .origin, // Helix is at origin within its group
162+ group: new_group_name,
163+ min_offset: 0 ,
164+ max_offset: total_length,
165+ major_tick_start: 0 ,
166+ );
167+
168+ // Step 5: Convert extensions to domains and update strands
169+ var updated_strands = design.strands.toList ();
170+
171+ // Replace extension1 with a forward domain
172+ // Both domains start at offset 0 and overlap at the connection point
173+ int domain1_start = 0 ;
174+ int domain1_end = extension1.num_bases;
175+
176+ var new_domain1 = Domain (
177+ helix: new_helix_idx,
178+ forward: true ,
179+ start: domain1_start,
180+ end: domain1_end,
181+ dna_sequence: extension1.dna_sequence, // Copy DNA sequence from extension
182+ );
183+
184+ var updated_strand1 = _replace_extension_with_domain (strand1, extension1, new_domain1);
185+ // Call initialize() after modifying the strand to ensure proper setup
186+ updated_strand1 = updated_strand1.initialize ();
187+ var strand1_idx = design.strands.indexOf (strand1);
188+ updated_strands[strand1_idx] = updated_strand1;
189+
190+ // If there's a second extension, replace it with a reverse domain
191+ if (extension2 != null && strand2 != null ) {
192+ // The second domain also starts at offset 0 and overlaps with the first domain
193+ int domain2_start = 0 ;
194+ int domain2_end = extension2.num_bases;
195+
196+ var new_domain2 = Domain (
197+ helix: new_helix_idx,
198+ forward: false ,
199+ start: domain2_start,
200+ end: domain2_end,
201+ dna_sequence: extension2.dna_sequence, // Copy DNA sequence from extension
202+ );
203+
204+ var updated_strand2 = _replace_extension_with_domain (strand2, extension2, new_domain2);
205+ // Call initialize() after modifying the strand to ensure proper setup
206+ updated_strand2 = updated_strand2.initialize ();
207+ var strand2_idx = design.strands.indexOf (strand2);
208+ updated_strands[strand2_idx] = updated_strand2;
209+ }
210+
211+ // Step 6: Build the updated design
212+ var result = design.rebuild (
213+ (b) =>
214+ b
215+ ..groups.replace ({...design.groups.asMap (), new_group_name: new_group})
216+ ..helices.replace ({...design.helices.asMap (), new_helix_idx: new_helix})
217+ ..strands.replace (updated_strands),
218+ );
219+
220+ return result;
221+ }
222+
223+ class _GroupPositioning {
224+ final Position3D group_position;
225+ final double pitch_angle;
226+
227+ _GroupPositioning (this .group_position, this .pitch_angle);
228+ }
229+
230+ double _calculate_extension_world_angle (Extension extension , HelixGroup group) {
231+ // Use the same logic as compute_end_rotation from util.dart
232+ var adjacent_domain = extension .adjacent_domain;
233+ double display_angle = extension .display_angle;
234+
235+ // Apply the same transformations as compute_end_rotation
236+ var radians = display_angle * 2 * pi / 360.0 ;
237+ num x = cos (radians);
238+ num y = sin (radians);
239+
240+ // Apply the reflections from util.dart compute_end_rotation
241+ y = - y;
242+ if (! adjacent_domain.forward) {
243+ x = - x;
244+ }
245+ if ((adjacent_domain.forward && extension .is_5p) || (! adjacent_domain.forward && ! extension .is_5p)) {
246+ x = - x;
247+ }
248+
249+ // Convert back to degrees
250+ var reflected_radians = atan2 (y, x);
251+ var degrees = reflected_radians * 360.0 / (2 * pi);
252+
253+ // Account for the current group's pitch rotation
254+ double current_visual_angle = degrees + group.pitch;
255+
256+ return current_visual_angle;
257+ }
258+
259+ _GroupPositioning _calculate_group_positioning (Design design, Extension extension , Geometry geometry) {
260+ // Find the helix and group containing the adjacent domain
261+ var adjacent_domain = extension .adjacent_domain;
262+ var helix = design.helices[adjacent_domain.helix]! ;
263+ var group = design.groups[helix.group]! ;
264+
265+ // Calculate the 3D position where the extension attaches to the domain
266+ var helix_position = helix.position3d (geometry);
267+
268+ // Calculate the offset position on the helix
269+ int end_offset = extension .is_5p ? adjacent_domain.offset_5p : adjacent_domain.offset_3p;
270+ double offset_along_helix = end_offset * geometry.rise_per_base_pair;
271+
272+ var attached_position = Position3D (
273+ x: helix_position.x,
274+ y: helix_position.y,
275+ z: helix_position.z + offset_along_helix,
276+ );
277+
278+ // Transform by group position
279+ attached_position = attached_position + group.position;
280+
281+ // If the adjacent domain is reverse, we need to adjust for the vertical offset
282+ // since reverse domains are drawn on the bottom half of the helix
283+ // Convert from SVG pixels to nanometers using geometry.svg_pixels_to_nm
284+ if (! adjacent_domain.forward) {
285+ double y_offset_nm = 2 * geometry.base_height_svg * geometry.svg_pixels_to_nm;
286+ attached_position = Position3D (
287+ x: attached_position.x,
288+ y: attached_position.y + y_offset_nm,
289+ z: attached_position.z,
290+ );
291+ }
292+
293+ // Calculate the world angle of the extension
294+ double world_angle = _calculate_extension_world_angle (extension , group);
295+
296+ // Calculate target visual angle for the new forward domain
297+ double target_visual_angle = world_angle;
298+
299+ // For 5' extensions, the new domain should appear pointing in the opposite direction
300+ // because the extension was pointing towards the 5' end, but the new forward domain
301+ // starts from the 5' end and goes towards the 3' end
302+ if (extension .is_5p) {
303+ target_visual_angle += 180 ;
304+ }
305+
306+ // For extensions next to reverse domains, the display angle interpretation is inverted
307+ // compared to forward domains, so we need to add 180 degrees to compensate
308+ if (! adjacent_domain.forward) {
309+ target_visual_angle += 180 ;
310+ }
311+
312+ // The pitch angle should be set so that the forward domain at angle 0 appears
313+ // with the target visual angle: 0 + group_pitch = target_visual_angle
314+ double pitch_angle = target_visual_angle;
315+
316+ // Calculate the group position based on where we want the connection to happen
317+ Position3D group_position;
318+
319+ if (extension .is_5p) {
320+ // For 5' extensions: we want the 3' end of the new domain (at offset extension.num_bases)
321+ // to be positioned at the attached_position
322+ // The 3' end is at offset extension.num_bases along the helix, which after rotation by pitch_angle
323+ // becomes a vector in the pitched direction
324+ double connection_offset = extension .num_bases * geometry.rise_per_base_pair;
325+
326+ // Convert the offset to world coordinates using the pitch angle
327+ // The helix runs along the Z axis (left/right), and the pitch angle rotates
328+ // the helix in the Y-Z plane (up/down and left/right in main view)
329+ double pitch_radians = pitch_angle * pi / 180.0 ;
330+ double offset_y = - connection_offset * sin (pitch_radians); // Y is up/down in main view
331+ double offset_z = - connection_offset * cos (pitch_radians); // Z is left/right in main view
332+
333+ group_position = Position3D (
334+ x: attached_position.x, // X position stays the same (into/out of screen)
335+ y: attached_position.y + offset_y,
336+ z: attached_position.z + offset_z,
337+ );
338+ } else {
339+ // For 3' extensions: we want the 5' end of the new domain (at offset 0)
340+ // to be positioned at the attached_position
341+ // Since the helix is at origin in the group, the 5' end is at z = 0
342+ // So: group_position + (0, 0, 0) = attached_position
343+ // Therefore: group_position = attached_position
344+ group_position = attached_position;
345+ }
346+
347+ return _GroupPositioning (group_position, pitch_angle);
348+ }
349+
350+ int _get_next_helix_idx (Design design) {
351+ if (design.helices.isEmpty) {
352+ return 0 ;
353+ }
354+ return design.helices.keys.fold (0 , (max, idx) => idx > max ? idx : max) + 1 ;
355+ }
356+
357+ String _get_unique_group_name (Design design, int helix_idx) {
358+ String candidate = 'group_${helix_idx }' ;
359+ int counter = 0 ;
360+ while (design.groups.containsKey (candidate)) {
361+ counter++ ;
362+ candidate = 'group_${helix_idx }_${counter }' ;
363+ }
364+ return candidate;
365+ }
366+
367+ Strand _replace_extension_with_domain (Strand strand, Extension extension , Domain new_domain) {
368+ var substrands = strand.substrands.toList ();
369+ var extension_index = substrands.indexOf (extension );
370+
371+ if (extension_index == - 1 ) {
372+ throw ArgumentError ('Extension not found in strand' );
373+ }
374+
375+ substrands[extension_index] = new_domain;
376+
377+ return strand.rebuild ((b) => b..substrands.replace (substrands));
378+ }
0 commit comments