Skip to content

Commit 00a94f9

Browse files
authored
Merge pull request #1033 from UC-Davis-molecular-computing/1032-convert-pair-of-extension-to-bound-domains-on-helices
fixes #1032: convert extensions to domains
2 parents 60882ee + c931027 commit 00a94f9

File tree

8 files changed

+1192
-302
lines changed

8 files changed

+1192
-302
lines changed

.claude/settings.local.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
{
2+
"permissions": {
3+
"allow": [
4+
"Bash(grep:*)",
5+
"Bash(dart pub:*)",
6+
"Bash(dart analyze:*)",
7+
"Bash(dart format:*)"
8+
],
9+
"deny": [],
10+
"ask": [],
11+
"defaultMode": "acceptEdits"
12+
}
13+
}

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Please read CONTRIBUTING.md for details about this project.

CONTRIBUTING.md

Lines changed: 719 additions & 236 deletions
Large diffs are not rendered by default.

lib/src/actions/actions.dart

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4796,3 +4796,45 @@ abstract class OxExportOnlySelectedStrandsSet
47964796
@memoized
47974797
int get hashCode;
47984798
}
4799+
4800+
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
4801+
// convert extensions to bound domains on new helix
4802+
4803+
abstract class ConvertExtensionsToBoundDomains
4804+
with BuiltJsonSerializable, UndoableAction
4805+
implements Action, Built<ConvertExtensionsToBoundDomains, ConvertExtensionsToBoundDomainsBuilder> {
4806+
Extension get extension1;
4807+
4808+
Extension? get extension2;
4809+
4810+
/************************ begin BuiltValue boilerplate ************************/
4811+
factory ConvertExtensionsToBoundDomains({required Extension extension1, Extension? extension2}) {
4812+
return ConvertExtensionsToBoundDomains.from(
4813+
(b) =>
4814+
b
4815+
..extension1.replace(extension1)
4816+
..extension2 = extension2?.toBuilder(),
4817+
);
4818+
}
4819+
4820+
ConvertExtensionsToBoundDomains._();
4821+
4822+
factory ConvertExtensionsToBoundDomains.from([
4823+
void Function(ConvertExtensionsToBoundDomainsBuilder) updates,
4824+
]) = _$ConvertExtensionsToBoundDomains;
4825+
4826+
static Serializer<ConvertExtensionsToBoundDomains> get serializer =>
4827+
_$convertExtensionsToBoundDomainsSerializer;
4828+
4829+
@override
4830+
String short_description() {
4831+
if (extension2 == null) {
4832+
return "convert extension to domain on new helix";
4833+
} else {
4834+
return "convert extensions to bound domains on new helix";
4835+
}
4836+
}
4837+
4838+
@memoized
4839+
int get hashCode;
4840+
}

lib/src/reducers/design_reducer.dart

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,17 @@ import 'helix_group_move_reducer.dart';
1010
import 'inline_insertions_deletions_reducer.dart';
1111
import 'strands_reducer.dart';
1212
import '../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

95109
Design? 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+
}

lib/src/serializers.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,7 @@ part 'serializers.g.dart';
367367
OxdnaExport,
368368
OxviewExport,
369369
OxExportOnlySelectedStrandsSet,
370+
ConvertExtensionsToBoundDomains,
370371
Design,
371372
AssignDomainNameComplementFromBoundStrands,
372373
AssignDomainNameComplementFromBoundDomains,

0 commit comments

Comments
 (0)