summaryrefslogtreecommitdiff
path: root/cad/src/operations/ops_select.py
blob: 6dedf41d7f4bbc5c67401fc05ef4287aeedd1669 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
# Copyright 2004-2009 Nanorex, Inc.  See LICENSE file for details.
"""
ops_select.py -- operations and internal methods for changing what's selected
and maintaining other selection-related state. (Not well-organized.)

@version: $Id$
@copyright: 2004-2009 Nanorex, Inc.  See LICENSE file for details.

History:

bruce 050507 made this by collecting appropriate methods from class Part.

Various improvements since then, by various developers.

TODO: probably needs some refactoring, judging by the imports
as of 080414.
"""

from utilities.constants import SELWHAT_CHUNKS, SELWHAT_ATOMS
from utilities.constants import diINVISIBLE, diDEFAULT
from model.global_model_changedicts import _changed_picked_Atoms
from model.chunk import Chunk
from model.elements import Singlet
from geometry.VQT import V, A, norm, cross
from Numeric import dot, transpose
import foundation.env as env
from utilities.Log import redmsg, greenmsg, orangemsg
from utilities.debug import print_compact_traceback
from utilities import debug_flags
from platform_dependent.PlatformDependent import fix_plurals
from utilities.GlobalPreferences import permit_atom_chunk_coselection
from utilities.icon_utilities import geticon

from dna.model.DnaGroup import DnaGroup
from dna.model.DnaStrand import DnaStrand

from foundation.Group import Group
from dna.model.DnaLadderRailChunk import DnaAxisChunk
from dna.model.DnaLadderRailChunk import DnaStrandChunk
from dna.model.DnaMarker import DnaMarker
from dna.model.DnaSegment import DnaSegment
from cnt.model.NanotubeSegment import NanotubeSegment

# Object flags, used by objectSelected() and its callers.
ATOMS = 1
CHUNKS = 2
JIGS = 4
DNASTRANDS = 8
DNASEGMENTS = 16
DNAGROUPS = 32
ALLOBJECTS = ATOMS | CHUNKS | JIGS | DNASTRANDS | DNASEGMENTS | DNAGROUPS

def objectSelected(part, objectFlags = ALLOBJECTS): # Mark 2007-06-24
    """
    Returns True if anything is selected (i.e. atoms, chunks, jigs, etc.).
    Returns False if nothing is selected.

    <objectFlags> is an enum used to test for specific object types, where:

        ATOMS = 1
        CHUNKS = 2
        JIGS = 4
        DNASTRANDS = 8
        DNASEGMENTS = 16
        DNAGROUPS = 32
        ATOMS | CHUNKS | JIGS | DNASTRANDS | DNASEGMENTS | DNAGROUPS
    """

    if objectFlags & ATOMS:
        if part.selatoms_list():
            return True

    if objectFlags & CHUNKS:
        if part.selmols:
            return True

    if objectFlags & JIGS:
        if part.getSelectedJigs():
            return True

    if objectFlags & DNASTRANDS:
        if part.getSelectedDnaStrands():
            return True

    if objectFlags & DNASEGMENTS:
        if part.getSelectedDnaSegments():
            return True

    if objectFlags & DNAGROUPS:
        if part.getSelectedDnaGroups():
            return True

    return False

def renameableLeafNode(obj, groups_renameable = False): # probably by Mark
    """
    Returns True if obj is a visible, renameable leaf node in the model tree.
    Otherwise, returns False.

    If obj is a Group or DnaGroup and groups_renameable is True,
    return True.
    """
    # TODO: refactor this so that it doesn't hardcode a lot of classes.
    # (The result is probably deducible from existing class attrs;
    #  if not, we should add one. There might already be a method on
    #  class Node related to this -- see try_rename or its calls to find out.)
    # [bruce 081124 comment]

    _nodeList = [[DnaAxisChunk,    False], # Chunk subclass
                 [DnaStrandChunk,  False], # Chunk subclass
                 [DnaMarker,       False], # Jig subclass
                 [DnaSegment,      True], # Group subclass
                 [DnaStrand,       True], # Group subclass
                 [NanotubeSegment, True], # Group subclass
                 [DnaGroup,        groups_renameable], # Group subclass
                 [Group,           groups_renameable]] # Group must be last in list.

    if not obj.rename_enabled():
        return False

    for _class, _renameable in _nodeList:
        if isinstance(obj, _class):
            return _renameable

    return True

class ops_select_Mixin:
    """
    Mixin class for providing selection methods to class L{Part}.
    """

    # functions from the "Select" menu
    # [these are called to change the set of selected things in this part,
    #  when it's the current part; these are event handlers which should
    #  do necessary updates at the end, e.g. win_update, and should print
    #  history messages, etc]

    def selectionContainsAtomsWithOverriddenDisplay(self):
        """
        Checks if the current selection contains any atoms that have
        its display mode not set to B{diDEFAULT}.

        @return: True if there is one or more selected atoms with its
                 display mode not set to B{diDEFAULT}.
        @rtype:  bool

        @note: It doesn't consider atoms in a chunk or of a jig if they
               (the atoms) are not explicitely selected.
        """
        for a in self.getOnlyAtomsSelectedByUser():
            if a.display != diDEFAULT:
                return True
        return False

    def selectionContainsInvisibleAtoms(self):
        """
        Checks if the current selection contains any atoms that have
        its display mode set to B{diINVISIBLE}.

        @return: True if there is one or more selected atoms with its display
                 mode set to B{diINVISIBLE}.
        @rtype:  bool

        @note: It doesn't consider atoms in a chunk or of a jig if they
               (the atoms) are not explicitely selected.
        """
        for a in self.getOnlyAtomsSelectedByUser():
            if a.display == diINVISIBLE:
                return True
        return False

    def getSelectedAtoms(self): #mark 060122
        """
        Returns a list of all the selected atoms, including those of selected
        chunks and jigs.

        @return: List of selected atoms.
        @rtype:  list
        """
        atoms = []

        for chunk in self.assy.selmols[:]:
            atoms += chunk.atoms.values()

        for jig in self.assy.getSelectedJigs():
            atoms += jig.atoms

        atoms += self.assy.selatoms_list()

        return atoms

    def getOnlyAtomsSelectedByUser(self): #ninad 0600818
        """
        Returns a list of atoms selected by the user. It doesn't consider atoms
        in a chunk or of a jig if they (the atoms) are not explicitely selected.
        """
        #ninad060818 is using this function to get distance and other info in the DynamiceTooltip class.
        atoms = []
        atoms += self.assy.selatoms_list()
        return atoms


    def getSelectedJigs(self):
        """
        Returns a list of all the currently selected jigs.
        @see: MWsemantics.activateDnaTool
        """
        selJigs = []
        def addSelectedJig(obj, jigs=selJigs):
            if obj.picked and isinstance(obj, self.win.assy.Jig):
                jigs += [obj]

        self.topnode.apply2all(addSelectedJig)
        return selJigs

    def getSelectedPlanes(self):
        """
        Returns a list of selected planes.
        @see: self.getSelectedJigs()
        """
        selectedJigs = self.getSelectedJigs()

        selectedPlanes = filter(lambda p:
                                isinstance(p, self.win.assy.Plane),
                                selectedJigs)
        return selectedPlanes

    def getSelectedDnaGroups(self):
        """
        Returns a list of the currently selected DnaGroup(s).

        """

        selDnaGroupList = []
        def addSelectedDnaGroup(obj, dnaList = selDnaGroupList):
            if obj.picked and isinstance(obj, DnaGroup):
                dnaList += [obj]

        self.topnode.apply2all(addSelectedDnaGroup)
        return selDnaGroupList

    def getSelectedDnaStrands(self):
        """
        Returns a list of the currently selected DnaStrand(s).

        """

        selDnaStrandList = []
        def addSelectedDnaStrand(obj, dnaList = selDnaStrandList):
            if obj.picked and isinstance(obj, DnaStrand):
                dnaList += [obj]

        self.topnode.apply2all(addSelectedDnaStrand)
        return selDnaStrandList

    def getSelectedDnaSegments(self):
        """
        Returns a list of the currently selected DnaSegment(s).

        """

        selDnaSegmentList = []
        def addSelectedDnaSegment(obj, dnaList = selDnaSegmentList):
            if obj.picked and isinstance(obj, self.win.assy.DnaSegment):
                dnaList += [obj]

        self.topnode.apply2all(addSelectedDnaSegment)
        return selDnaSegmentList

    def getSelectedNanotubeSegments(self):
        """
        @return: a list of the currently selected NanotubeSegments
        """
        selNanotubeSegmentList = []
        def addSelectedNanotubeSegment(obj, ntSegmentList = selNanotubeSegmentList):
            if obj.picked and isinstance(obj, self.win.assy.NanotubeSegment):
                ntSegmentList += [obj]
            return
        self.topnode.apply2all(addSelectedNanotubeSegment)
        return selNanotubeSegmentList

    def getSelectedNanotubeSegment(self):
        """
        Returns only the currently selected nanotubeSegment, if any.
        @return: the currently selected nanotubeSegment or None if no
                 nanotubeSegments are selected. Also returns None if more
                 than one nanotubeSegment is select.
        @rtype: L{Chunk}
        @note: use L{getSelectedNanotubeSegments()} to get the list of all
               selected nanotubeSegments.
        """
        selectedNanotubeSegmentList = self.getSelectedNanotubeSegments()
        if len(selectedNanotubeSegmentList) == 1:
            return selectedNanotubeSegmentList[0]
        else:
            return None
        return

    def getSelectedProteinChunks(self):
        """
        @return: a list of the currently selected Protein chunks
        """
        selProteinList = []
        def addSelectedProteinChunk(obj, proteinList = selProteinList):
            if obj.picked and \
               isinstance(obj, self.win.assy.Chunk) and \
               obj.isProteinChunk():
                proteinList += [obj]
            return
        self.topnode.apply2all(addSelectedProteinChunk)
        return selProteinList

    def getSelectedProteinChunk(self):
        """
        Returns only the currently selected protein chunk, if any.
        @return: the currently selected protein chunk or None if no peptide
                 chunks are selected. Also returns None if more than one
                 peptide chunk is select.
        @rtype: L{Chunk}
        @note: use L{getSelectedProteinChunks()} to get the list of all
               selected proteins.
        """
        selectedProteinList = self.getSelectedProteinChunks()
        if len(selectedProteinList) == 1:
            return selectedProteinList[0]
        else:
            return None
        return

    def getNumberOfSelectedChunks(self):
        """
        Returns the number of selected chunks.

        @note:Atoms and jigs are not counted.
        """
        return len(self.assy.selmols)

    def getNumberOfSelectedJigs(self):
        """
        Returns the number of selected jigs.

        @note:Atoms and chunks are not counted.
        """
        return len(self.assy.getSelectedJigs())

    def getSelectedMovables(self): # Renamed from getMovables().  mark 060124.
        """
        Returns the list of all selected nodes that are movable.
        """
        selected_movables = []
        def addMovableNode(obj, nodes=selected_movables):
            if obj.picked and obj.is_movable:
                nodes += [obj]
        self.topnode.apply2all(addMovableNode)
        return selected_movables

    def getSelectedRenameables(self):
        """
        Returns the list of all selected nodes that can be renamed.
        """
        selected_renameables = []
        def addRenameableNode(obj, nodes=selected_renameables):
            if obj.picked and obj.rename_enabled():
                nodes += [obj]
        self.topnode.apply2all(addRenameableNode)
        return selected_renameables

    def getSelectedNodes(self):
        """
        Return a list of all selected nodes in the MT.
        """
        selected_nodes = []
        def func(obj):
            if obj.picked:
                selected_nodes.append(obj)
        self.topnode.apply2all(func)
        return selected_nodes


    def selectAll(self):
        """
        Select all atoms or all chunks, depending on the select mode.

        @note: The selection filter is applied if it is enabled.
        """
        self.begin_select_cmd() #bruce 051031
        if self.selwhat == SELWHAT_CHUNKS:
            for m in self.molecules:
                m.pick()
            #Call Graphics mode API method to do any additinal selection
            #(example select an entire DnaGroup if all its contents are selected
            #@see: basicGraphicsMode.end_selection_from_GLPane()
            currentCommand = self.w.commandSequencer.currentCommand
            currentCommand.graphicsMode.end_selection_from_GLPane()
        else:
            assert self.selwhat == SELWHAT_ATOMS
            for m in self.molecules:
                for a in m.atoms.itervalues():
                    a.pick()
        self.w.win_update()

    def selectNone(self):
        self.begin_select_cmd() #bruce 051031
        self.unpickall_in_win()
        self.w.win_update()

    def selectInvert(self):
        """
        If some parts are selected, select the other parts instead.
        If some atoms are selected, select the other atoms instead
        (even in chunks with no atoms selected, which end up with
        all atoms selected). (And unselect all currently selected
        parts or atoms.)

        @note: when atoms are selected, only affects atoms as permitted by the
        selection filter.
        """ #bruce 060331 revised docstring
        #bruce 060721 comments: this is problematic #####@@@@@ as we move to more general selection semantics.
        # E.g. -- can it select atoms inside a CylinderChunk? It probably shouldn't, but now it can.
        # (If some in it are already selected, then maybe, but not if they are not, and maybe that should
        #  never be permitted to occur.)
        # E.g. -- what if there are atoms selected inside chunks that are selected?
        # (Even if not, how do you decide whether unselected stuff gets selected as atoms or chunks?
        #  Now, this depends on the mode (Build -> atoms, Extrude -> Chunks); maybe that makes sense --
        #  select things in the smallest units that could be done by clicking (for CylChunks this will mean chunks).)
        self.begin_select_cmd() #bruce 051031
        cmd = "Invert Selection: "
        env.history.message(greenmsg(cmd))

        # revised by bruce 041217 after discussion with Josh;
        # previous version inverted selatoms only in chunks with
        # some selected atoms.
        if self.selwhat == SELWHAT_CHUNKS:
            newpicked = filter( lambda m: not m.picked, self.molecules )
            self.unpickparts()
            for m in newpicked:
                m.pick()
            #Call Graphics mode API method to do any additinal selection
            #(example select an entire DnaGroup if all its contents are selected
            #@see: basicGraphicsMode.end_selection_from_GLPane()
            currentCommand = self.w.commandSequencer.currentCommand
            currentCommand.graphicsMode.end_selection_from_GLPane()
        else:
            assert self.selwhat == SELWHAT_ATOMS
            for m in self.molecules:
                for a in m.atoms.itervalues():
                    if a.picked: a.unpick()
                    else: a.pick()

        # Print summary msg to history widget.  Always do this before win/gl_update.
        env.history.message("Selection Inverted")

        self.w.win_update()

    def expandDnaComponentSelection(self, dnaStrandOrSegment):
        """
        Expand the DnaComponent selection. DnaComponent can be a strand or a
        segment.
        For DnaSegment -- it selects that dna segment and the adjacent segments
        reachable through crossovers.
        For DnaStrand it selects that strand and all the complementary strands.
        @see: self._expandDnaSegmentSelection()
        @see: SelectChunks_GraphicsMode.chunkLeftDouble()
        @see: DnaStrand.get_DnaStrandChunks_sharing_basepairs()
        @see: DnaSegment.get_DnaSegments_reachable_thru_crossovers()
        @see: NFR bug 2749 for details.
        """
        if isinstance(dnaStrandOrSegment, self.win.assy.DnaStrand):
            self._expandDnaStrandSelection(dnaStrandOrSegment)
        elif isinstance(dnaStrandOrSegment, self.win.assy.DnaSegment):
            self._expandDnaSegmentSelection(dnaStrandOrSegment)

        currentCommand = self.w.commandSequencer.currentCommand
        currentCommand.graphicsMode.end_selection_from_GLPane()
        self.win.win_update()


    def _expandDnaSegmentSelection(self, dnaSegment):
        """
        Expand the selection of such that the segment <dnaSegment> and all its
        adjacent DnaSegments reachable through the crossovers, are selected.
        @see:self.expandDnaComponentSelection()
        """
        assert isinstance(dnaSegment, self.win.assy.DnaSegment)
        segmentList = [dnaSegment]
        segmentList.extend(dnaSegment.get_DnaSegments_reachable_thru_crossovers())
        for segment in segmentList:
            if not segment.picked:
                segment.pick()

    def _expandDnaStrandSelection(self, dnaStrand):
        """
        Expand the selection such that the <dnaStrand> and all its complementary
        strand chunks are selected.
        """
        assert isinstance(dnaStrand, self.win.assy.DnaStrand)
        lst = dnaStrand.getStrandChunks()
        lst.extend(dnaStrand.get_DnaStrandChunks_sharing_basepairs())

        for c in lst:
            if not c.picked:
                c.pick()

    def contractDnaComponentSelection(self, dnaStrandOrSegment):
        """
        Contract the selection such that:

        If is a DnaStrand, then that strand and all its complementary
        strand chunks are deselected.

        If its a DnaSegment, then that segment and its adjacent segments reachable
        through cross overs are deselected.

        @see:self._contractDnaStrandSelection()
        @see: self._contractDnaSegmentSelection()
        @see: SelectChunks_GraphicsMode.chunkLeftDouble()
        @see: DnaStrand.get_DnaStrandChunks_sharing_basepairs()
        @see: DnaSegment.get_DnaSegments_reachable_thru_crossovers()
        @see: NFR bug 2749 for details.
        """
        if isinstance(dnaStrandOrSegment, self.win.assy.DnaStrand):
            self._contractDnaStrandSelection(dnaStrandOrSegment)
        elif isinstance(dnaStrandOrSegment, self.win.assy.DnaSegment):
            self._contractDnaSegmentSelection(dnaStrandOrSegment)

    def _contractDnaStrandSelection(self, dnaStrand):
        assert isinstance(dnaStrand, self.win.assy.DnaStrand)
        assert isinstance(dnaStrand, self.win.assy.DnaStrand)
        lst = dnaStrand.getStrandChunks()
        lst.extend(dnaStrand.get_DnaStrandChunks_sharing_basepairs())

        for c in lst:
            if c.picked:
                c.unpick()

    def _contractDnaSegmentSelection(self, dnaSegment):
        """
        Contract the selection of the picked DnaSegments such that the segment
        <dnaSegment> and all its adjacent DnaSegments reachable through the
        crossovers, are deselected.
        """
        assert isinstance(dnaSegment, self.win.assy.DnaSegment)
        segmentList = [dnaSegment]
        segmentList.extend(dnaSegment.get_DnaSegments_reachable_thru_crossovers())
        for segment in segmentList:
            if segment.picked:
                segment.unpick()


    def selectExpand(self):
        """
        Select any atom that is bonded to any currently selected atom,
        and whose selection is permitted by the selection filter.
        """ #bruce 060331 revised docstring
        # Eric really needed this.  Added by Mark 050923.
        # (See also Selection.expand_atomset method. [bruce 051129])

        self.begin_select_cmd() #bruce 051031
        cmd = "Expand Selection: "
        env.history.message(greenmsg(cmd))
            #bruce 051129 comment: redundancy of greenmsg is bad, but self.selatoms can take time to compute,
            # so I decided against fixing the redundancy by moving this below the "No atoms selected" test.

        if not self.selatoms:
            env.history.message(greenmsg(cmd) + redmsg("No atoms selected."))
            return

        num_picked = 0 # Number of atoms picked in the expand selection.

        for a in self.selatoms.values():
            if a.picked: #bruce 051129 comment: this is presumably always true
                for n in a.neighbors():
                    if not n.picked:
                        n.pick()
                        if n.picked:
                            #bruce 051129 added condition to fix two predicted miscount bugs (don't know if reported):
                            # - open bonds can't be picked (.pick is always a noop for them)
                            # - some elements can't be picked when selection filter is on (.pick is a noop for them, too)
                            # Note that these bugs might have caused those unselected atoms to be counted more than once,
                            # not merely once (corrected code counts them 0 times).
                            num_picked += 1

        # Print summary msg to history widget.  Always do this before win/gl_update.
        msg = fix_plurals(str(num_picked) + " atom(s) selected.")
        env.history.message(msg)

        self.w.win_update()

    def selectContract(self):
        """
        Unselects any atom which has a bond to an unselected atom, or which has any open bonds,
        and whose unselection is permitted by the selection filter.
        """ #bruce 060331 revised docstring
        # Added by Mark 050923.

        self.begin_select_cmd() #bruce 051031
        cmd = "Contract Selection: "
        env.history.message(greenmsg(cmd))

        if not self.selatoms:
            env.history.message(greenmsg(cmd) + redmsg("No atoms selected."))
            return

        contract_list = [] # Contains list of atoms to be unselected.

        assert self.selwhat == SELWHAT_ATOMS
        for a in self.selatoms.values():
            if a.picked:
                # If a selected atom has an unpicked neighbor, it gets added to the contract_list
                # Bruce mentions: you can just scan realNeighbors if you want to only scan
                # the non-singlet atoms. Users may desire this behavior - we can switch it on/off
                # via a dashboard checkbox or user pref if we want.  Mark 050923.
                for n in a.neighbors():
                    if not n.picked:
                        contract_list.append(a)
                        break

        # Unselect the atom in the contract_list
        #bruce 051129 comment: this appears to contain only unique picked atoms (based on above code),
        # and any atom can be unpicked (regardless of selection filter) [this later became WRONG; see below],
        # so using its len as a count of changed atoms, below, is probably correct.
        #bruce 060331 comment & bugfix: sometime since the above comment, unpick started using selection filter.
        # So I'll fix the atom count for the history message.
        natoms = 0
        for a in contract_list:
            if not a.picked:
                continue #bruce 060331 precaution, for correct count (not needed for current code above)
            a.unpick()
            if not a.picked: # condition is due to selection filter
                natoms += 1

        # Print summary msg to history widget.
        msg = fix_plurals(str(natoms) + " atom(s) unselected.")
        env.history.message(msg)

        self.w.win_update() # Needed? Mark 2008-02-14

    def lockSelection(self, lockState):
        """
        Enable/disable the mouse "selection lock". When enabled, selection
        operations using the mouse (i.e. clicks and drags) are disabled in the
        3D graphics area (glpane). All other selection commands via the
        toolbar, menus, model tree and keyboard shortcuts are not affected by
        the selection lock state.

        @param lockState: The selection lock state, where:
                        - True  = selection locked
                        - False = selection unlocked
        @type  lockState: boolean
        """
        if lockState:
            self.w.selectLockAction.setIcon(
                geticon("ui/actions/Tools/Select/Selection_Locked.png"))
        else:
            self.w.selectLockAction.setIcon(
                geticon("ui/actions/Tools/Select/Selection_Unlocked.png"))

        self.o.mouse_selection_lock_enabled = lockState
        # Update the cursor and statusbar.
        self.o.setCursor()

        if 0:
            print "mouse_selection_lock_enabled=", \
                  self.o.mouse_selection_lock_enabled

    def hideSelection(self):
        """
        Hides the current selection. Selected atoms are made invisible.
        Selected chunks and/or any other object (i.e. jigs, planes, etc.)
        are hidden.
        """
        # Added by Mark 2008-02-14. [slight revisions, bruce 080305]

        cmd = "Hide: "
        env.history.message(greenmsg(cmd))

        # Hide selected objects.
        self.assy.Hide()

        if self.selatoms:
            # Hide selected atoms by changing their display style to invisible.
            for a in self.selatoms.itervalues():
                a.setDisplayStyle(diINVISIBLE)
        return

    def unhideSelection(self):
        """
        Unhides the current selection.

        If the current selection mode is "Select Chunks", the selected nodes
        (i.e. chunks, jigs, planes, etc.) are unhidden. If all the nodes
        were already visible (unhidden), then we unhide any invisble atoms
        inside chunks by changing their display style to default (even if
        their display style before they were hidden was something different).

        If the current selection mode is "Select Atoms (i.e. Build Atoms), then
        the selected atoms are made visible by changing their display style
        to default (even if their display style before they were hidden
        was something different).
        """
        # Added by Mark 2008-02-25. [slight revisions, bruce 080305]
        # [subsequently modified and/or bugfixed by Ninad]

        # TODO: fix possible speed issue: this looks like it might be slow for
        #  deep nesting in model tree, since it may unhide selected groups
        #  as a whole, as well as each node they contain. [bruce 081124 comment]

        cmd = "Unhide: "
        env.history.message(greenmsg(cmd))

        _node_was_unhidden = False

        selectedNodes = self.getSelectedNodes()

        # Unhide any movables. This includes chunks, jigs, etc. (but not atoms).
        for node in selectedNodes:
            if node.hidden:
                _node_was_unhidden = True
                node.unhide()

        if _node_was_unhidden:
            self.w.win_update()
            return

        if not self.selatoms:
            # Unhide any invisible atoms in the selected chunks.
            for chunk in self.assy.selmols[:]:
                for a in chunk.atoms.itervalues():
                    a.setDisplayStyle(diDEFAULT)
        else:
            # Unhide selected atoms by changing their display style to default.
            for a in self.selatoms.itervalues():
                a.setDisplayStyle(diDEFAULT)
        self.w.win_update()
        return

    # ==

    def selectChunksWithSelAtoms(self): #bruce 060721 renamed from selectParts; see also permit_pick_parts
        """
        Change this Part's assy to permit selected chunks, not atoms,
        but select all chunks which contained selected atoms;
        then win_update
        [warning: not for general use -- doesn't change which select mode is in use]
        """
        # This is called by Move_GraphicsMode.Enter_GraphicsMode.
        # (Why not selectChunksMode? because SelectChunks_GraphicsMode calls it w/o update, instead:
        #   self.o.assy.selectChunksWithSelAtoms_noupdate() # josh 10/7 to avoid race in assy init
        # )
        # BTW, MainWindowUI.{py,ui} has an unused slot with the same name this method used to have [selectParts]
        # [bruce 050517/060721 comment and docstring]
        self.selectChunksWithSelAtoms_noupdate()
        self.w.win_update()

    def selectChunksWithSelAtoms_noupdate(self): #bruce 060721 renamed from pickParts; see also permit_pick_parts
        """
        Change this Part's assy to permit selected chunks, not atoms,
        but select all chunks which contained selected atoms; do no updates
        [warning: not for general use -- doesn't change which select mode is in use]
        """
        #bruce 050517 added docstring
        lis = self.selatoms.values()
        self.unpickatoms() # (not sure whether this is still always good, but probably it's ok -- bruce 060721)
        for atm in lis:
            atm.molecule.pick()
        self.assy.set_selwhat(SELWHAT_CHUNKS) #bruce 050517 revised API of this call
            #bruce 050517: do this at the end, to avoid worry about whether
            # it is later given the side effect of unpickatoms.
            # It's redundant only if lis has any atoms.
        return

    def permit_pick_parts(self): #bruce 050125; see also selectChunksWithSelAtoms_noupdate, but that can leave some chunks initially selected
        """
        Ensure it's legal to pick chunks using mouse selection, and deselect
        any selected atoms (if picking chunks does so).
        """
        #bruce 060414 revised this to try to fix bug 1819
        # (and perhaps related bugs like 1106, where atoms & chunks are both selected)
        if permit_atom_chunk_coselection(): #bruce 060721
            return

        if self.selatoms and self.assy.selwhat == SELWHAT_CHUNKS and env.debug():
            print "debug: bug: permit_pick_parts sees self.selatoms even though self.assy.selwhat == SELWHAT_CHUNKS; unpicking them"
                # Note: this happens during bug 1819, and indicates a bug in the code that led up to here,
                # probably something about selatoms being per-part, but selwhat (and MT part-switch conventions re selection)
                # being for the assy -- maybe we need to deselect atoms, not only chunks, when switching parts (not yet done).
                # In the meantime, warn only the developer, and try to fix the situation
                # by doing the following anyway (which the pre-060414 code did not).
        if self.selatoms:
            self.unpickatoms()
        self.assy.set_selwhat(SELWHAT_CHUNKS) # not super-fast (could optim using our own test), but that's ok here
        return

    def permit_pick_atoms(self): #bruce 050517 added this for use in some mode Enter methods -- but not sure they need it!
        """
        Ensure it's legal to pick atoms using mouse selection, and deselect any
        selected chunks (if picking atoms does so).
        """
        if permit_atom_chunk_coselection(): #bruce 060721
            return
        ## if self.assy.selwhat != SELWHAT_ATOMS:
        if 1: # this matters, to callers who might have jigs selected
            self.unpickchunks() # only unpick chunks, not jigs. mark 060309.
            self.assy.set_selwhat(SELWHAT_ATOMS) #bruce 050517 revised API of this call
        return

    # == selection functions using a mouse position

    # REVIEW: we should probably move some of these, especially findAtomUnderMouse,
    # to GraphicsMode instead (once it's split from basicMode), since they depend
    # on model-specific graphical properties. [bruce 071008]

    # (Note: some of these are not toplevel event handlers)

    # dumb hack: find which atom the cursor is pointing at by
    # checking every atom...
    # [bruce 041214 comment: findpick is now mostly replaced by findAtomUnderMouse;
    #  its only remaining call is in depositMode.getcoords, which uses a constant
    #  radius other than the atoms' radii, and doesn't use iPic or iInv,
    #  but that too might be replaced in the near future, once bug 269 response
    #  is fully decided upon.
    #  Meanwhile, I'll make this one only notice visible atoms, and clean it up.
    #  BTW it's now the only caller of atom.checkpick().]

    def findpick(self, p1, v1, r=None):
        distance = 1000000
        atom = None
        for mol in self.molecules:
            if mol.hidden:
                continue
            disp = mol.get_dispdef()
            for a in mol.atoms.itervalues():
                if not a.visible(disp):
                    continue
                dist = a.checkpick(p1, v1, disp, r, None)
                if dist:
                    if dist < distance:
                        distance = dist
                        atom = a
        return atom

    def _decide_water_cutoff(self): #bruce 071008 split this out
        """
        Decide what value of water_cutoff to pass to self.findAtomUnderMouse.
        """
        # I'm not sure if this is really an aspect of the currentCommand
        # or of the graphics mode -- maybe the currentCommand
        # since that does or doesn't offer UI control of water,
        # or maybe the graphicsMode since that does or doesn't display it.
        # Someone should figure this out and clean it up.
        # Best guess at the right cleanup: graphicsModes should all have
        # common boolean water_enabled and float water_depth attributes,
        # used for both drawing and picking in a uniform way.
        # But I won't do it that way here, since I'm imitating the prior code.
        # (BTW note that self.findAtomUnderMouse is not private, and has
        #  external callers which pass their own water_enabled flag to it.
        #  So we can't just inline this into it.)
        # [bruce 071008]
        #UPDATE 2008-08-01: Water surface is currently an aspect of the
        #command class rather than graphicsMode class. The graphicsmode checks
        #it by calling self.command.isWaterSurfaceEnabled() --[ Ninad comment]
        commandSequencer = self.win.commandSequencer
        if commandSequencer.currentCommand.commandName == 'DEPOSIT':
            return True
        else:
            return False
        pass

    # bruce 041214, for fixing bug 235 and some unreported ones:
    def findAtomUnderMouse(self, event, water_cutoff = False, singlet_ok = False):
        """
        Return the atom (if any) whose front surface should be visible at the
        position of the given mouse event, or None if no atom is drawn there.
        This takes into account all known effects that affect drawing, except
        bonds and other non-atom things, which are treated as invisible.
        (Someday we'll fix this by switching to OpenGL-based hit-detection. #e)

        @note: if several atoms are drawn there, the correct one to return is
        the one that obscures the others at that exact point, which is not always
        the one whose center is closest to the screen!
           When water_cutoff is true, also return None if the atom you would
        otherwise return (more precisely, if the place its surface is touched by
        the mouse) is under the "water surface".
           Normally never return a singlet (though it does prevent returning
        whatever is behind it). Optional arg singlet_ok permits returning one.
        """
        p1, p2 = self.o.mousepoints(event, 0.0)
        z = norm(p1-p2)
        if 1:
            # This computation of matrix is now doable (roughly) by geometry.matrix_putting_axis_at_z().
            # Once that's tested, these could probably be replaced by a call to it.
            # But this is not confirmed -- the question is whether we cared about this use of self.o.up
            # except as a convenient known perpendicular to z. If it matters, we can't use matrix_putting_axis_at_z here.
            # [bruce 060608 comment]
            x = cross(self.o.up,z)
            y = cross(z,x)
            matrix = transpose(V(x,y,z))
        point = p2
        cutoffs = dot( A([p1,p2]) - point, matrix)[:,2]
        near_cutoff = cutoffs[0]
        if water_cutoff:
            far_cutoff = cutoffs[1]
            # note: this can be 0.0, which is false, so an expression like
            # (water_cutoff and cutoffs[1] or None) doesn't work!
        else:
            far_cutoff = None
        z_atom_pairs = []
        for mol in self.molecules:
            if mol.hidden:
                continue
            pairs = mol.findAtomUnderMouse(point, matrix, \
                                           far_cutoff = far_cutoff, near_cutoff = near_cutoff )
            z_atom_pairs.extend( pairs)
        if not z_atom_pairs:
            return None
        z_atom_pairs.sort() # smallest z == farthest first; we want nearest
        res = z_atom_pairs[-1][1] # nearest hit atom
        if res.element == Singlet and not singlet_ok:
            return None
        return res

    #bruce 041214 renamed and rewrote the following pick_event methods, as part of
    # fixing bug 235 (and perhaps some unreported bugs).
    # I renamed them to distinguish them from the many other "pick" (etc) methods
    # for Node subclasses, with common semantics different than these have.
    # I removed some no-longer-used related methods.

    # All these methods should be rewritten to be more general;
    # for more info, see comment about findAtomUnderMouse and jigGLSelect
    # in def end_selection_curve in Select_GraphicsMode.py.
    # [bruce 080917 comment]

    def pick_at_event(self, event): #renamed from pick; modified
        """
        Pick whatever visible atom or chunk (depending on
        self.selwhat) is under the mouse, adding it to the current selection.
        You are not allowed to select a singlet.
        Print a message about what you just selected (if it was an atom).
        """
        # [bruce 041227 moved the getinfo status messages here, from the Atom
        # and Chunk pick methods, since doing them there was too verbose
        # when many items were selected at the same time. Original message
        # code was by [mark 2004-10-14].]
        self.begin_select_cmd() #bruce 051031
        atm = self.findAtomUnderMouse(event, water_cutoff = self._decide_water_cutoff())
        if atm:
            if self.selwhat == SELWHAT_CHUNKS:
                if not self.selmols:
                    self.selmols = []
                    # bruce 041214 added that, since pickpart used to do it and
                    # calls of that now come here; in theory it's never needed.
                atm.molecule.pick()
                env.history.message(atm.molecule.getinfo())
            else:
                assert self.selwhat == SELWHAT_ATOMS
                atm.pick()
                env.history.message(atm.getinfo())

        return

    def delete_at_event(self, event):
        """
        Delete whatever visible atom or chunk (depending on self.selwhat)
        is under the mouse. You are not allowed to delete a singlet.
        This leaves the selection unchanged for any atoms/chunks in the current
        selection not deleted. Print a message about what you just deleted.
        """
        self.begin_select_cmd()
        atm = self.findAtomUnderMouse(event, water_cutoff = self._decide_water_cutoff())
        if atm:
            if self.selwhat == SELWHAT_CHUNKS:
                if not self.selmols:
                    self.selmols = []
                    # bruce 041214 added that, since pickpart used to do it and
                    # calls of that now come here; in theory it's never needed.
                env.history.message("Deleted " + atm.molecule.name)
                atm.molecule.kill()
            else:
                assert self.selwhat == SELWHAT_ATOMS
                if atm.filtered():
                    # note: bruce 060331 thinks refusing to delete filtered atoms, as this does, is a bad UI design,
                    # since if the user clicked on a specific atom they probably knew what they were doing,
                    # and if (at most) we just printed a warning and deleted it anyway, they could always Undo the delete
                    # if they had hit the wrong atom. See also similar code and message in delete_atom_and_baggage (selectMode.py).
                    #bruce 060331 adding orangemsg, since we should warn user we didn't do what they asked.
                    env.history.message(orangemsg("Cannot delete " + str(atm) + " since it is being filtered. "\
                                                  "Hit Escape to clear the selection filter."))
                else:
                    env.history.message("Deleted " + str(atm) )
                    atm.kill()
        return

    def onlypick_at_event(self, event): #renamed from onlypick; modified
        """
        Unselect everything in the glpane; then select whatever visible atom
        or chunk (depending on self.selwhat) is under the mouse at event.
        If no atom or chunk is under the mouse, nothing in glpane is selected.
        """
        self.begin_select_cmd() #bruce 051031
        self.unpickall_in_GLPane() #bruce 060721, replacing the following selwhat-dependent unpickers:
##        if self.selwhat == SELWHAT_CHUNKS:
##            self.unpickparts()
##        else:
##            assert self.selwhat == SELWHAT_ATOMS
##            self.unpickparts() # Fixed bug 606, partial fix for bug 365.  Mark 050713.
##            self.unpickatoms()
        self.pick_at_event(event)

    def unpick_at_event(self, event): #renamed from unpick; modified
        """
        Make whatever visible atom or chunk (depending on self.selwhat)
        is under the mouse at event get un-selected (subject to selection filter),
        but don't change whatever else is selected.
        """ #bruce 060331 revised docstring
        self.begin_select_cmd() #bruce 051031
        atm = self.findAtomUnderMouse(event, water_cutoff = self._decide_water_cutoff())
        if atm:
            if self.selwhat == SELWHAT_CHUNKS:
                atm.molecule.unpick()
            else:
                assert self.selwhat == SELWHAT_ATOMS
                atm.unpick() # this is subject to selection filter -- is that ok?? [bruce 060331 question]
        return

    # == internal selection-related routines

    def unpickatoms(self): #e [should this be private?] [bruce 060721]
        """
        Deselect any selected atoms (but don't change selwhat or do any
        updates).
        """ #bruce 050517 added docstring
        if self.selatoms:
            ## for a in self.selatoms.itervalues():
                #bruce 060405 comment/precaution: that use of self.selatoms.itervalues might have been safe
                # (since actual .unpick (which would modify it) is not called in the loop),
                # but it looks troublesome enough that we ought to make it safer, so I will:
            selatoms = self.selatoms
            self.selatoms = {}
            mols = {}
            for a in selatoms.itervalues():
                # this inlines and optims Atom.unpick
                a.picked = False
                _changed_picked_Atoms[a.key] = a #bruce 060321 for Undo (or future general uses)
                m = a.molecule
                mols[m] = m
            for m in mols: #bruce 090119 optimized, revised call
                m.changed_selected_atoms()
            self.selatoms = {}
        return

    def unpickparts(self): ##e this is misnamed -- should be unpicknodes #e [should this be private?] [bruce 060721]
        """
        Deselect any selected nodes (e.g. chunks, Jigs, Groups) in this part
        (but don't change selwhat or do any updates).
        See also unpickchunks.
        """ #bruce 050517 added docstring; 060721 split out unpickchunks
        self.topnode.unpick()
        return

    def unpickchunks(self): #bruce 060721 made this to replace the misnamed unpick_jigs = False option of unpickparts
        """
        Deselect any selected chunks in this part
        (but don't change selwhat or do any updates).
        See also unpickparts.
        """
        # [bruce 060721 comment: unpick_jigs option to unpickparts was misnamed,
        #  since there are selectable nodes other than jigs and chunks.
        #  BTW Only one call uses this option, which will be obsolete soon
        #  (when atoms & chunks can be coselected).]
        for c in self.molecules:
            if c.picked:
                c.unpick()
        return

    def unpickall_in_GLPane(self): #bruce 060721
        """
        Unselect all things that ought to be unselected by a click in empty
        space in the GLPane.
        As of 060721 this means "everything", but we might decide that MT nodes
        that are never drawn in GLPane should remain selected in a case like
        this. ###@@@
        """
        self.unpickatoms()
        self.unpickparts()
        return

    def unpickall_in_MT(self): #bruce 060721
        """
        Unselect all things that ought to be unselected by a click in empty
        space in the Model Tree. As of 060721 this means "all nodes", but we
        might decide that it should deselect atoms too. ###@@@
        """
        self.unpickparts()
        return

    def unpickall_in_win(self): #bruce 060721
        """
        Unselect all things that a general "unselect all" tool button or menu
        command ought to. This should unselect all selectable things, and
        should be equivalent to doing both L{unpickall_in_GLPane()} and
        L{unpickall_in_MT()}.
        """
        self.unpickatoms()
        self.unpickparts()
        return

    def begin_select_cmd(self):
        # Warning: same named method exists in assembly, GLPane, and ops_select, with different implems.
        # More info in comments in assembly version. [bruce 051031]
        self.assy.begin_select_cmd() # count selection commands, for use in pick-time records
        return

    # ==

    def selection_from_glpane(self): #bruce 050404 experimental feature for initial use in Minimize Selection; renamed 050523
        """
        Return an object which represents the contents of the current selection,
        independently of part attrs... how long valid?? Include the data
        generally used when doing an op on selection from glpane (atoms and
        chunks); see also selection_from_MT().
        """
        # the idea is that this is a snapshot of selection even if it changes
        # but it's not clear how valid it is after the part contents itself starts changing...
        # so don't worry about this yet, consider it part of the experiment...
        part = self
        return selection_from_glpane( part)

    def selection_for_all(self): #bruce 050419 for use in Minimize All; revised 050523
        """
        Return a selection object referring to all our atoms (regardless of
        the current selection, and not changing it).
        """
        part = self
        return selection_for_entire_part( part)

    def selection_from_MT(self): #bruce 050523; not used as of 080414, but might be someday
        """
        [#doc]
        """
        part = self
        return selection_from_MT( part)

    def selection_from_part(self, *args, **kws): #bruce 051005
        part = self
        return selection_from_part(part, *args, **kws)

    pass # end of class ops_select_Mixin (used in class Part)

# ==

def topmost_selected_nodes(nodes):
    """
    @param nodes: a list of nodes, to be examined for selected nodes or subnodes
    @type nodes: python sequence of Nodes

    @return: a list of all selected nodes in or under the given list of nodes,
             not including any node which is inside any selected Group
             in or under the given list

    @see: same-named method in class ModelTreeGui_api and class TreeModel_api
    """
    #bruce 050523 split this out from the same-named TreeWidget method,
    # and optimized it
    res = []
    func = res.append
    for node in nodes:
        node.apply2picked( func)
    return res

# ==

def selection_from_glpane( part): #bruce 050523 split this out as intermediate helper function; revised 050523
    # note: as of 080414 this is used only in sim_commandruns.py and MinimizeEnergyProp.py
    return Selection( part, atoms = part.selatoms, chunks = part.selmols )

def selection_from_MT( part): #bruce 050523; not used as of 080414, but might be someday
    return Selection( part, atoms = {}, nodes = topmost_selected_nodes([part.topnode]) )

def selection_from_part( part, use_selatoms = True, expand_chunkset_func = None): #bruce 050523
    # note: as of 080414 all ultimate uses of this are in ModelTree.py or ops_copy.py
    if use_selatoms:
        atoms = part.selatoms
    else:
        atoms = {}
    res = Selection( part, atoms = atoms, nodes = topmost_selected_nodes([part.topnode]) )
    if expand_chunkset_func:
        res.expand_chunkset(expand_chunkset_func)
    return res

def selection_for_entire_part( part): #bruce 050523 split this out, revised it
    return Selection( part, atoms = {}, chunks = part.molecules )

def selection_from_atomlist( part, atomlist): #bruce 051129, for initial use in Local Minimize
    return Selection( part, atoms = atomdict_from_atomlist(atomlist) )

def atomdict_from_atomlist(atomlist): #bruce 051129 [#e should refile -- if I didn't already implement it somewhere else]
    """
    Given a list of atoms, return a dict mapping atom keys to those atoms.
    """
    return dict( [(a.key, a) for a in atomlist] )

class Selection: #bruce 050404 experimental feature for initial use in Minimize Selection; revised 050523
    """
    Represent a "snapshot-by-reference" of the contents of the current selection,
    or any similar set of objects passed to the constructor.

    @warning: this remains valid (and unchanged in meaning) if the
    selection-state changes, but might become invalid if the Part contents
    themselves change in any way! (Or at least if the objects passed to the
    constructor (like chunks or Groups) change in contents (atoms or child
    nodes).)
    """ #bruce 051129 revised docstring
    def __init__(self, part, atoms = {}, chunks = [], nodes = []):
        """
        Create a snapshot-by-reference of whatever sets or lists of objects
        are passed in the args atoms, chunks, and/or nodes (see details and
        limitations below).

        Objects should not be passed redundantly -- i.e. they should not
        contain atoms or nodes twice, where we define chunks as containing
        their atoms and Group nodes as containing their child nodes.

        Objects must be of the appropriate types (if passed):
        atoms must be a dict mapping atom keys to atoms;
        chunks must be a list of chunks;
        nodes must be a list of nodes, thought of as disjoint node-subtrees
        (e.g. "topmost selected nodes").

        The current implem also prohibits passing both chunks and nodes lists,
        but this limitation is just for its convenience and can be removed
        when needed.

        The object-containing arguments are shallow-copied immediately, but the
        objects they contain are never copied, and in particular, the effect
        of changes to the set of child nodes of Group nodes passed in the nodes
        argument is undefined. (In initial implem, the effect depends on when
        self.selmols is first evaluated.)

        Some methods assume only certain kinds of object arguments were
        passed (see their docstrings for details).
        """
        #bruce 051129 revised docstring -- need to review code to verify its accuracy. ###k
        # note: topnodes might not always be provided;
        # when provided it should be a list of nodes in the part compatible with selmols
        # but containing groups and jigs as well as chunks, and not containing members of groups it contains
        # (those are implicit)
        self.part = part
        ## I don't think self.topnode is used or needed [bruce 050523]
        ## self.topnode = part.topnode # might change...
        self.selatoms = dict(atoms) # copy the dict; it's ok that this does not store atoms inside chunks or nodes
        # For now, we permit passing chunks or nodes list but not both.
        if nodes:
            # nodes were passed -- store them, but let selmols be computed lazily
            assert not chunks, "don't pass both chunks and nodes arguments to Selection"
            self.topnodes = list(nodes)
            # selmols will be computed lazily if needed
            # (to avoid needlessly computing it, we don't assert not (self.selatoms and self.selmols))
        else:
            # chunks (or no chunks and no nodes) were passed -- store as both selmols and topnodes
            self.selmols = list(chunks) # copy the list
            self.topnodes = self.selmols # use the same copy we just made
            if (self.selatoms and self.selmols) and debug_flags.atom_debug: #e could this change? try not to depend on it
                print_compact_traceback( "atom_debug: self.selatoms and self.selmols: " ) #bruce 051129, replacing an assert
        return
    def nonempty(self): #e make this the object's boolean value too?
        # assume that each selmol has some real atoms, not just singlets! Should always be true.
        return self.selatoms or self.topnodes #revised 050523
    def atomslist(self):
        """
        Return a list of all selected real atoms, whether selected as atoms or
        in selected chunks; no singlets or jigs.
        """
        #e memoize this!
        # [bruce 050419 comment: memoizing it might matter for correctness
        #  if mol contents change, not only for speed. But it's not yet needed,
        #  since in the only current use of this, the atomslist is grabbed once
        #  and stored elsewhere.]
        if self.selmols:
            res = dict(self.selatoms) # dict from atom keys to atoms
            for mol in self.selmols:
                # we'll add real atoms and singlets, then remove singlets
                # (probably faster than only adding real atoms, since .update is one bytecode
                #  and (for large mols) most atoms are not singlets)
                res.update(mol.atoms)
                for s in mol.singlets:
                    del res[s.key]
        else:
            res = self.selatoms
        items = res.items()
        items.sort() # sort by atom key; might not be needed
        return [atom for key, atom in items]
    def __getattr__(self, attr): # in class Selection
        if attr == 'selmols':
            # compute from self.topnodes -- can't assume selection state of self.part
            # is same as during our init, or even know whether it was relevant then.
            res = []
            def func(node):
                if isinstance(node, Chunk):
                    res.append(node)
                return # from func
            for node in self.topnodes:
                node.apply2all(func)
            self.selmols = res
            return res
        elif attr == 'selmols_dict': #bruce 050526
            res = {}
            for mol in self.selmols:
                res[id(mol)] = mol
            self.selmols_dict = res
            return res
        raise AttributeError, attr

    def picks_atom(self, atom): #bruce 050526
        """
        Does this selection include atom, either directly or via its chunk?
        """
        return atom.key in self.selatoms or id(atom.molecule) in self.selmols_dict

    def picks_chunk(self, chunk): #bruce 080414
        """
        Does this selection include chunk, either directly or via a containing
        Group in topnodes?
        """
        return id(chunk) in self.selmols_dict

    def add_chunk(self, chunk): #bruce 080414
        """
        If not self.picks_chunk(chunk), add chunk to this selection
        (no effect on external selection state i.e. chunk.picked).
        Otherwise do nothing.
        """
        if not self.picks_chunk(chunk):
            self.selmols_dict[id(chunk)] = chunk
            self.selmols.append(chunk)
            # sometimes self.selmols is the same mutable list as self.topnodes.
            # if so, preserve this, otherwise also add to self.topnodes
            if self.topnodes and self.topnodes[-1] is chunk:
                pass
            else:
                self.topnodes.append(chunk)
            # (note: modifying those attrs is only permissible because
            #  __init__ shallow-copies those lists)
            pass
        return

    def describe_objects_for_history(self):
        """
        Return a string like "5 items" but more explicit if possible, for use
        in history messages.
        """
        if self.topnodes:
            res = fix_plurals( "%d item(s)" % len(self.topnodes) )
            #e could figure out their common class if any (esp. useful for Jig and below); for Groups say what they contain; etc
        elif self.selmols:
            res = fix_plurals( "%d chunk(s)" % len(self.selmols) )
        else:
            res = ""
        if self.selatoms:
            if res:
                res += " and "
            res += fix_plurals( "%d atom(s)" % len(self.selatoms) )
            #e could say "other atoms" if the selected nodes contain any atoms
        return res

    def expand_atomset(self, ntimes = 1): #bruce 051129 for use in Local Minimize; compare to selectExpand
        """
        Expand self's set of atoms (to include all their real-atom neighbors)
        (repeating this ntimes), much like "Expand Selection" but using no
        element filter, and of course having no influence by or effect on
        "current selection state" (atom.picked attribute).

        Ignore issue of self having selected chunks (as opposed to the atoms
        in them); if this ever becomes possible we can decide how to generalize
        this method for that case (ignore them, turn them to atoms, etc).

        @warning: Current implem [051129] is not optimized for lots of atoms
        and ntimes > 1 (which doesn't matter for its initial use).
        """
        assert not self.selmols and not self.topnodes # (since current implem would be incorrect otherwise)
        atoms = self.selatoms # mutable dict, modified in following loop
            # [name 'selatoms' is historical, but also warns that it doesn't include atoms in selmols --
            #  present implem is only correct on selection objects made only from atoms.]
        for i in range(ntimes):
            for a1 in atoms.values(): # this list remains fixed as atoms dict is modified by this loop
                for a2 in a1.realNeighbors():
                    atoms[a2.key] = a2 # analogous to a2.pick()
        return

    def expand_chunkset(self, func): #bruce 080414
        """
        func can be called on one chunk to return a list of chunks.
        Expand the set of chunks in self (once, not recursively)
        by including all chunks in func(orig) for orig being any chunk
        originally in self (when this is called). Independently protect
        from exceptions in each call of func, and also from the bug
        of func returning None.
        """
        for chunk in list(self.selmols):
            try:
                more_chunks = func(chunk)
                assert more_chunks is not None
            except:
                print_compact_traceback("ignoring exception in, or returning of None by, %r(%r): " % (func, chunk))
                more_chunks = ()
            if more_chunks:
                for chunk2 in more_chunks:
                    self.add_chunk(chunk2)
            continue
        return

    pass # end of class Selection

# end