summaryrefslogtreecommitdiff
path: root/cad/src/model/part.py
blob: c6848891d2c176e7d608fe4e14cfcd57d0e771af (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
# Copyright 2004-2009 Nanorex, Inc.  See LICENSE file for details.
"""
part.py -- class Part, for all chunks and jigs in a single physical space,
together with their selection state and grouping structure (shown in the
model tree).

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

see assembly.py docstring, some of which is really about this module. ###@@@ revise

==

This module also contains a lot of code for specific operations on sets of molecules,
which are all in the current part. Some of this code might ideally be moved to some
other file. [As of 050507, much of that has now been moved.]

==

History:

Split out of assembly.py (the file, and more importantly the class)
by bruce 050222. The Part/assembly distinction was introduced by bruce 050222
(though some of its functionality was anticipated by the "current selection group"
introduced earlier, just before Alpha-1). [I also rewrote this entire docstring then.]

The Part/assembly distinction is unfinished, particularly in how it
relates to some modes and to movie files.

Prior history of assembly.py (and thus of much code in this file) unclear;
assembly.py was almost certainly originated by Josh.

bruce 050507 moved various methods out of this file, into more appropriate
smaller files, some existing (jigs.py) and some new (ops_*.py).

bruce 050513 replaced some == with 'is' and != with 'is not',
to avoid __getattr__ on __xxx__ attrs in python objects.
"""

from utilities import debug_flags

from utilities.debug import print_compact_traceback, print_compact_stack
from utilities.Log import redmsg

from utilities.constants import diINVISIBLE
from utilities.constants import diDEFAULT
from utilities.constants import SELWHAT_CHUNKS, SELWHAT_ATOMS

from utilities.prefs_constants import levelOfDetail_prefs_key
from utilities.prefs_constants import startup_GLPane_scale_prefs_key


from geometry.VQT import V, Q
from geometry.BoundingBox import BBox


from foundation.Utility import Node
from foundation.Group import Group
from foundation.Assembly_API import Assembly_API
from foundation.node_indices import fix_one_or_complain
from foundation.inval import InvalMixin
from foundation.state_utils import StateMixin
from foundation.state_constants import S_REF, S_DATA, S_PARENT, S_CHILD

import foundation.env as env


from model.NamedView import NamedView
from model.chunk import Chunk
from model.jigs import Jig

from model.Part_drawing_frame import Part_drawing_frame
from model.Part_drawing_frame import fake_Part_drawing_frame

from model.elements import PeriodicTable

from operations.jigmakers_Mixin import jigmakers_Mixin
from operations.ops_atoms import ops_atoms_Mixin
from operations.ops_connected import ops_connected_Mixin
from operations.ops_copy import ops_copy_Mixin
from operations.ops_motion import ops_motion_Mixin
from operations.ops_rechunk import ops_rechunk_Mixin
from operations.ops_select import ops_select_Mixin


from dna.operations.ops_pam import ops_pam_Mixin

# ==

# number of atoms for detail level 0
HUGE_MODEL = 40000
# number of atoms for detail level 1
LARGE_MODEL = 5000

debug_parts = False # set this to True in a debugger, to enable some print statements, etc

debug_1855 = False # DO NOT COMMIT WITH TRUE [bruce 060415]

# ==

class Part( jigmakers_Mixin, InvalMixin, StateMixin,
            ops_atoms_Mixin, ops_pam_Mixin, ops_connected_Mixin, ops_copy_Mixin,
            ops_motion_Mixin, ops_rechunk_Mixin, ops_select_Mixin,
            object # fyi; redundant with InstanceLike inherited via StateMixin
            ):
    """
    One Part object is created to hold any set of chunks and jigs whose
    coordinates are intended to lie in the same physical space.
    When new clipboard items come into being, new Parts are created as needed
    to hold them; and they should be destroyed when those clipboard items
    no longer exist as such (even if the chunks inside them still exist in
    some other Part).
       Note that parts are not Nodes (or at least, they are not part of the same node-tree
    as the chunks/jigs they contain); each Part has a toplevel node self.topnode,
    and a reference to its assy, used for (e.g.) finding a reference to the shared
    (per-assy) clipboard to use, self.shelf.
    """

    # default values for some instance variables
    name = "" #bruce 060227 moved this into class and made it "" rather than None, for simplicity of _s_attr defaultval code
        # this is [someday? now?] set to any name autogenerated for our topnode,
        # so it can be reused if necessary (if our topnode changes a couple times in a row)
        # rather than autogenerating another name.
        # It would also be useful if there was a Part Tree Widget...
    alive = False # set to True at end of __init__, and again to False if we're destroyed(??#k)

    # state decls (for attrs set in __init__) [bruce 060224]
    _s_attr_name = S_DATA
    _s_attr_topnode = S_PARENT
    _s_attr_nodecount = S_DATA
    _s_attr_homeView = S_CHILD
    _s_attr_lastView = S_CHILD
    ## not true, i think: _s_categorize_homeView = 'view' # [bruce 060227]
    _s_categorize_lastView = 'view' # [bruce 060227]
    _s_attr_ppa2 = S_REF
    _s_attr_ppa3 = S_REF
    _s_attr_ppm = S_REF
    _s_attr_alive = S_DATA # needed since the part can be destroyed, which sets alive to False

    def _undo_update_always(self): #bruce 060224
        """
        This is run on every Part still around after an Undo or Redo op, whether or not it was modified by that op.
        """
        # (though to be honest, that's due to a kluge, as of 060224 -- it won't yet run this on any other class!)
        attrs = self.invalidatable_attrs()
        if debug_1855:
            print "debug_1855: part %r _undo_update_always will inval %r" % (self, attrs,)
            # this looks ok
        self.invalidate_attrs( attrs) # especially selmols, selatoms, and molecules, but i guess all of them matter
            ###e should InvalMixin *always* do this? (unless overridden somehow?) guess: not quite.
        # don't call this, it can't be allowed to exist (I think):
        ## StateMixin._undo_update_always(self)
        return

    def __init__(self, assy, topnode):
        self.init_InvalMixin()
        self.assy = assy
        self.topnode = topnode
            # some old code refers to topnode as tree or root, but that's deprecated
            # since it doesn't work for setting the value (it causes bugs we won't detect)
            # so change all uses of that... maybe I have by now? ###k
        self.nodecount = 0 # doesn't yet include topnode until we self.add it, below
        prior_part = topnode.part
        if prior_part is None:
            prior_part = topnode.prior_part #bruce 050527 new feature; also might be None
        if prior_part is not None:
            # topnode.part might be destroyed when we add topnode to ourselves,
            # so we'd better first salvage from it whatever might be useful
            # (copying its view attributes fixes bug 556 [bruce 050420])
            # [now we can also get these from topnode.prior_part if necessary;
            #  it is like .part but doesn't modify part's nodecount or stats;
            #  this is added to nodes made by copying other nodes, or Groups containing those,
            #  so that view info can generally be preserved for copies -- bruce 050527]
            self.homeView = prior_part.homeView.copy()
            self.lastView = prior_part.lastView.copy()
            # (copying its name, if we were the first ones to get to it and if it doesn't
            #  any longer need its name (since it has no topnode), might be enough
            #  to make ungroup/regroup of a clipboard item preserve an autogenerated name;
            #  I'm not 100% sure it's always a good idea, but it's worth a try, I guess;
            #  if it's bad it'll be because the part still wanted its name for some reason. [bruce 050420])
            if prior_part.name and prior_part.topnode is None:
                # steal its name
                self.name = prior_part.name
                prior_part.name = None
                del prior_part.name # save RAM (in undo archives, until undo does this itself) (not important, mainly a test, 060227)
        else:
            # HomeView and LastView -- these are per-part, are switched into
            # GLPane when its current part changes (i.e. very soon after our
            # assy's current part changes), and are written into mmp file for
            # main part, and in future for all parts.
            ###e bruce 050527 comment: would it ever be better to set these to
            # fit the content? If so, we'd have to just inval them here, since
            # most of the content is probably in nodes other than topnode,
            # which are not yet added (and we don't want to assume topnode's
            # kids will all be added, though for now this might be true --
            # not sure).

            #Default scale is usually = 10.0-- obtained from the preference
            #value for startup_GLPane_scale_prefs_key
            #@see: GLPane.__init__,
            #@see:GLPane._adjust_GLPane_scale_if_needed()
            default_scale = float(env.prefs[startup_GLPane_scale_prefs_key])
            self.homeView = NamedView(self.assy,
                                      "HomeView",
                                      default_scale,
                                      V(0,0,0),
                                      1.0,
                                      Q(1.0, 0.0, 0.0, 0.0))

            self.lastView = NamedView(self.assy,
                                      "LastView",
                                      default_scale,
                                      V(0,0,0),
                                      1.0,
                                      Q(1.0, 0.0, 0.0, 0.0))

        self.add(topnode)
        # for now:
        assert isinstance(assy, Assembly_API)
        assert isinstance(topnode, Node)

        # self._modified?? not yet needed for individual parts, but will be later.


        ##bruce 050417 zapping all Datum objects, since this will have no important effect,
        ## even when old code reads our mmp files.
        ## More info about this can be found in other comments/emails.
##        self.xy = Datum(self.assy, "XY", "plane", V(0,0,0), V(0,0,1))
##        self.yz = Datum(self.assy, "YZ", "plane", V(0,0,0), V(1,0,0))
##        self.zx = Datum(self.assy, "ZX", "plane", V(0,0,0), V(0,1,0))

        ##bruce 050418 replacing this with viewdata_members method and its caller in assy:
##        grpl1 = [self.homeView, self.lastView] ## , self.xy, self.yz, self.zx] # [note: only use of .xy, .yz, .zx as of 050417]
##        self.viewdata = Group("View Data", self.assy, None, grpl1) #bruce 050418 renamed this; not a user-visible change
##        self.viewdata.open = False


        # some attrs are recomputed as needed (see below for their _recompute_ or _get_ methods):
        # e.g. molecules, bbox, center, drawLevel, alist, selatoms, selmols

        # movie ID, for future use. [bruce 050324 commenting out movieID until it's used; strategy for this will change, anyway.]
        ## self.movieID = 0

        # ppa = previous picked atoms. ###@@@ not sure these are per-part; should reset when change mode or part
        self.ppa2 = self.ppa3 = self.ppm = None

        self.alive = True # we're not yet destroyed

        if debug_parts:
            print "debug_parts: fyi: created Part:", self

        return # from Part.__init__

    def viewdata_members(self, i): #bruce 050418: this helps replace old assy.data for writing mmp files
        #bruce 050421: patch names for sake of saving per-Part views;
        # should be ok since names not otherwise used (I hope);
        # if not (someday), we can make copies and patch their names
        suffix = i and str(i) or ""
        self.homeView.name = "HomeView" + suffix
        self.lastView.name = "LastView" + suffix
        return [self.homeView, self.lastView]

    def __repr__(self):
        classname = self.__class__.__name__
        try:
            topnodename = "%r" % self.topnode.name
        except:
            topnodename = "<topnode??>"
        try:
            return "<%s %#x %s (%d nodes)>" % (classname, id(self), topnodename, self.nodecount)
        except:
            return "<some part, exception in its __repr__>" #bruce 050425

    # == updaters (###e refile??)

    def gl_update(self):
        """
        update whatever glpane is showing this part (more than one, if necessary)
        """
        self.assy.o.gl_update()

    # == membership maintenance

    # Note about selection of nodes moving between parts:
    # when nodes are removed from or added to parts, we ensure they (or their atoms) are not picked,
    # so that we needn't worry about updating selatoms, selmols, or current selection group;
    # this also seems best in terms of the UI. But note that it's not enough, if .part revision
    # follows tree revision, since picked nodes control selection group using tree structure alone.

    def add(self, node):
        if node.part is self:
            # this is normal, e.g. in ensure_one_part, so don't complain
            return
        if node.part is not None:
            if debug_parts:
                # this will be common
                print "debug_parts: fyi: node added to new part so removed from old part first:", node, self, node.part
            node.part.remove(node)
        assert node.part is None
        # this is a desired assertion, but make it a debug print
        # so as not to cause worse bugs: [bruce 080314]
        ## assert not node.picked # since remove did it, or it was not in a part and could not have been picked (I think!)
        if node.picked:
            msg = "\n***BUG: node.picked in %r.add(%r); clearing it to avoid more bugs" % \
                  (self, node)
            print_compact_stack( msg + ": ")
            node.picked = False # too dangerous to use node.unpick() here
            # Review: could we just make this legal, by doing
            # self.selmols_append(node) if node is a chunk?
            # (For now, instead, just have new nodes call .inherit_part
            #  before .pick. This fixed a bug in DnaLadderRailChunk.)
            # [bruce 080314 comment]
            pass
        #e should assert a mol's atoms not picked too (too slow to do it routinely; bugs in this are likely to be noticed)
        node.part = node.prior_part = self
            #bruce 050527 comment: I hope and guess this is the only place node.part is set to anything except None; need to check ###k
        self.nodecount += 1
        if isinstance(node, Chunk): ###@@@ #e better if we let the node add itself to our stats and lists, i think...
            self.invalidate_attrs(['molecules'], skip = ['natoms']) # this also invals bbox, center
                #e or we could append node to self.molecules... but I doubt that's worthwhile ###@@@
            self.adjust_natoms( len(node.atoms))
        # note that node is not added to any comprehensive list of nodes; in fact, we don't have one.
        # presumably this function is only called when node was just, or is about to be,
        # added to a nodetree in a place which puts it into this part's tree.
        # Therefore, in the absence of bugs and at the start of any user event handler,
        # self.topnode should serve as a comprehensive tree of this part's nodes.
        return

    def remove(self, node):
        """
        Remove node (a member of this part) from this part's lists and stats;
        reset node.part; DON'T look for interspace bonds yet (since this node
        and some of its neighbors might be moving to the same new part).
        Node (and its atoms, if it's a chunk) will be unpicked before the removal.
        """
        assert node.part is self
        node.unpick() # this maintains selmols if necessary
        if isinstance(node, Chunk):
            # need to unpick the atoms? [would be better to let the node itself have a method for this]
            ###@@@ (fix atom.unpick to not remake selatoms if missing, or to let this part maintain it)
            if (not self.__dict__.has_key('selatoms')) or self.selatoms:
                for atm in node.atoms.itervalues():
                    atm.unpick(filtered = False)
                        #bruce 060331 precaution: added filtered = False, to fix potential serious bugs (unconfirmed)
                        #e should optimize this by inlining and keeping selatoms test outside of loop
            self.invalidate_attrs(['molecules'], skip = ['natoms']) # this also invals bbox, center
            self.adjust_natoms(- len(node.atoms))
        self.nodecount -= 1
        node.part = None
        if self.topnode is node:
            self.topnode = None #k can this happen when any nodes are left??? if so, is it bad?
            if debug_parts:
                print "debug_parts: fyi: topnode leaves part, %d nodes remain" % self.nodecount
            # it can happen when I drag a Group out of clipboard: "debug_parts: fyi: topnode leaves part, 2 nodes remain"
            # and it doesn't seem to be bad (the other 2 nodes were pulled out soon).
        if self.nodecount <= 0:
            assert self.nodecount == 0
            assert not self.topnode
            self.destroy()
            # NOTE: since Node.part is undoable, a destroyed Part can come back
            # to life after Undo. I don't know if this is related to a newly found bug:
            # make duplex with dna updater on, undo to before that, redo, nodecount is wrong.
            # For more info see today's comment in assembly.py.
            # [bruce 080325]
        return

    def destroy_with_topnode(self): #bruce 050927; consider renaming this to destroy, and destroy to something else
        """
        destroy self.topnode and then self; assertionerror if self still has nodes after topnode is destroyed
        WARNING [060322]: This probably doesn't follow the semantics of other destroy methods (the issue is unreviewed). ###@@@
        """
        if self.topnode is not None:
            self.topnode.kill() # use kill, since Node.destroy is NIM [#e this should be fixed, might cause memory leaks]
        self.destroy()
        return

    def destroy(self): #bruce 050428 making this much more conservative for Alpha5 release and to fix bug 573
        """
        forget enough to prevent memory leaks; only valid if we have no nodes left; MUST NOT forget views!
        WARNING [060322]: This doesn't follow the semantics of other destroy methods; in particular, destroyed Parts might be revived
        later by Undo. This should be fixed by renaming this method (perhaps to kill), so we can add a real destroy method. ###@@@
        """
        #bruce 050527 added requirement (already true in current implem) that this not forget views,
        # so node.prior_part needn't prevent destroy, but can be used to retrieve default initial views for node.
        if debug_parts:
            print "debug_parts: fyi: destroying part", self
        assert self.nodecount == 0, "can't destroy a Part which still has nodes" # esp. since it doesn't have a list of them!
            # actually it could scan self.assy.root to find them... but for now, we'll enforce this anyway.
        if self.assy and self.assy.o: #e someday change this to self.glpane??
            self.assy.o.forget_part(self) # just in case we're its current part
        ## self.invalidate_all_attrs() # not needed
        self.alive = False # do this one first ###@@@ see if this can help a Movie who knows us see if we're safe... [050420]
        if "be conservative for now, though memory leaks might result": #bruce 050428
            return
        # bruce 050428 removed the rest for now. In fact, even what we had was probably not enough to
        # prevent memory leaks, since we've never paid attention to that, so the Nodes might have them
        # (in the topnode tree, deleted earlier, or the View nodes we still have, which might get into
        #  temporary Groups in writemmp_file code and not get properly removed from those groups).
        ## BTW, bug 573 came from self.assy = None followed by __getattr__ wanting attrs from self.assy
        ## such as 'w' or 'current_selgroup_iff_valid'.
##        # set all attrs to None, including self.alive (which is otherwise True to indicate we're not yet destroyed)
##        for attr in self.__dict__.keys():
##            if not attr.startswith('_'):
##                #bruce 050420 see if this 'if' prevents Python interpreter hang
##                # when this object is later passed as argument to other code
##                # in bug 519 (though it probably won't fix the bug);
##                # before this we were perhaps deleting Python-internal attrs too,
##                # such as __dict__ and __class__!
##                if 0 and debug_flags.atom_debug:
##                    print "atom_debug: destroying part - deleting i mean resetting attr:",attr
##                ## still causes hang in movie mode:
##                ## delattr(self,attr) # is this safe, in arb order of attrs??
##                setattr(self, attr, None)
        return

    # incremental update methods

    def selmols_append(self, mol):
        if self.__dict__.has_key('selmols'):
            assert mol not in self.selmols
            self.selmols.append(mol)
        return

    def selmols_remove(self, mol):
        if self.__dict__.has_key('selmols'):
            ## might not always be true in current code, though it should be:
            ## assert mol in self.selmols
            try:
                self.selmols.remove(mol)
            except ValueError: # not in the list
                if debug_flags.atom_debug:
                    print_compact_traceback("selmols_remove finds mol not in selmols (might not be a bug): ")
        return

    def adjust_natoms(self, delta):
        """
        adjust the number of atoms, if known. Useful since drawLevel depends on this and is often recomputed.
        """
        if self.__dict__.has_key('natoms'):
            self.natoms += delta
        return

    # == compatibility methods

    ###@@@ find and fix all sets of .tree or .root or .data (old name, should all be renamed now) or .viewdata (new name) or .shelf

    def _get_tree(self): #k this would run for part.tree; does that ever happen?
        print_compact_stack("_get_tree is deprecated: ")
        return self.topnode

    def _get_root(self): #k needed?
        print_compact_stack("_get_root is deprecated: ")
        return self.topnode

    # == properties that might be overridden by subclasses

    def immortal(self):
        """
        Should this Part be undeletable from the UI (by cut or delete operations)?
        When true, delete will delete its members (leaving it empty but with its topnode still present),
        and cut will cut its members and move them into a copy of its topnode, which is left still present and empty.
        [can be overridden in subclasses]
        """
        return False # simplest value used as default

    # == attributes which should be delegated to self.assy

    # attrnames to delegate to self.assy (ideally for writing as well as reading, until all using-code is upgraded)
    assy_attrs = ['w','o','mt','selwhat','win'] #bruce 071008 added 'win'
        ### TODO: add glpane, once we have it in assy and verify not already used here [bruce 071008 comment]
        # 050308: selwhat will be an official assy attribute;
        # some external code assigns to assy.selwhat directly,
        # and for now can keep doing that. Within the Part, perhaps we should
        # use a set_selwhat method if we need one, but for now we just assign
        # directly to self.assy.selwhat.
    assy_attrs_temporary = ['changed'] # tolerable, but might be better to track per-part changes, esp. re movies ###@@@
    assy_attrs_review = ['shelf', 'current_movie']
        #e in future, we'll split out our own methods for some of these, incl .changed
        #e and for others we'll edit our own methods' code to not call them on self but on self.assy (incl selwhat).
    assy_attrs_all = assy_attrs + assy_attrs_temporary + assy_attrs_review

    def __getattr__(self, attr): # in class Part
        """
        [overrides InvalMixin.__getattr__]
        """
        if attr.startswith('_'): # common case, be fast (even though it's done redundantly by InvalMixin.__getattr__)
            raise AttributeError, attr
        if attr in self.assy_attrs_all:
            # delegate to self.assy
            return getattr(self.assy, attr) ###@@@ detect error of infrecur, since assy getattr delegates to here??
        return InvalMixin.__getattr__(self, attr) # uses _get_xxx and _recompute_xxx methods

    # == attributes which should be invalidated and recomputed as needed (both inval and recompute methods follow)

    _inputs_for_molecules = [] # only invalidated directly ###@@@ need to do it in any other places too?
    def _recompute_molecules(self):
        """
        recompute self.molecules as a list of this part's chunks, IN ARBITRARY AND NONDETERMINISTIC ORDER.
        """
        self.molecules = 333 # not a sequence - detect bug of touching or using this during this method
        seen = {} # values will be new list of mols
        def func(n):
            "run this exactly once on all molecules that properly belong in this assy"
            if isinstance(n, Chunk):
                # check for duplicates (mol at two places in tree) using a dict, whose values accumulate our mols list
                if seen.get(id(n)):
                    print "bug: some chunk occurs twice in this part's topnode tree; semi-tolerated but not fixed"
                    msg = " that chunk is %r, and this part is %r, in assy %r, with topnode %r" % \
                          (n, self, self.assy, self.topnode) #bruce 080403, since this happened to tom
                    print_compact_stack(msg + ": ")
                    return # from func only
                seen[id(n)] = n
            return # from func only
        self.topnode.apply2all( func)
        self.molecules = seen.values()
            # warning: not in the same order as they are in the tree!
            # even if it was, it might elsewhere be incrementally updated.
        return

    def nodes_in_mmpfile_order(self, nodeclass = None):
        """
        Return a list of leaf nodes in this part (only of the given class, if provided)
        in the same order as they appear in its nodetree (depth first),
        which should be the same order they'd be written into an mmp file,
        unless something reorders them first (as happens for certain jigs
        in workaround_for_bug_296, as of 050325,
        but maybe not as of 051115 since workaround_for_bug_296 was removed some time ago).
        See also _recompute_alist.
        """
        res = []
        def func(n):
            if not nodeclass or isinstance(n, nodeclass):
                res.append(n)
            return # from func only
        self.topnode.apply2all( func)
        return res

    _inputs_for_natoms = ['molecules']
    def _recompute_natoms(self):
        #e we might not bother to inval this for indiv atom changes in mols -- not sure yet
        #e should we do it incrly? should we do it on every node, and do other stats too?
        num = 0
        for mol in self.molecules:
            num += len(mol.atoms)
        return num

    _inputs_for_drawLevel = ['natoms']
    def _recompute_drawLevel(self):
        """
        Recompute and set the value of self.drawLevel,
        which controls the detail level of spheres used to draw atoms
        (when shaders are not being used).

        @see: GLPane_minimal.get_drawLevel
        """
        num = self.natoms # note: self.natoms must be accessed whether or not
            # its value is needed, due to limitations in InvalMixin.
            # Review: it might be good to optimize by not using InvalMixin
            # so we don't need to recompute self.natoms when it's not needed.
        lod = env.prefs[ levelOfDetail_prefs_key ] # added by mark, revised by bruce, 060215
        lod = int(lod)
        if lod > 2:
            # presume we're running old code (e.g. A7)
            # using a prefs db written by newer code (e.g. A8)
            lod = 2 # max LOD current code can handle
                # (see _NUM_SPHERE_SIZES, len(drawing_globals.sphereList))
        # now set self.drawLevel from lod
        if lod < 0:
            # -1 means "Variable based on the number of atoms in the part."
            # [bruce 060215 changed that from 3, so we can expand number of
            #  LOD levels in the future.]
            self.drawLevel = 2
            if num > LARGE_MODEL:
                self.drawLevel = 1
            if num > HUGE_MODEL:
                self.drawLevel = 0
        else:
            # High (2), medium (1) or low (0)
            self.drawLevel = lod
        return

    # == scanners (maybe not all of them?)

    def enforce_permitted_members_in_groups(self, **opts): #bruce 080319
        """
        Intended to be called after self has just been read, either
        before or after update_parts and/or the dna updater has first run
        (with appropriate options passed to distinguish those cases).

        Make sure all our groups that only permit some kinds of members
        (e.g. DnaStrandOrSegment groups)
        only have that kind of members, by ejecting non-permitted members
        to higher groups, making a new toplevel group if necessary.

        FYI: As of 080319 this just means we make sure the only members
        of a DnaStrand or DnaSegment (i.e. a DnaStrandOrSegment)
        are chunks (any subclass) and DnaMarker jigs. The dna updater,
        run later, will make sure there is a 1-1 correspondence between
        controlling markers and DnaStrandOrSegments, and (nim?) that only
        DnaLadderRailChunks are left inside DnaStrandOrSegments.
        (It may have a bug in which a DnaStrandOrSegment containing only
        an ordinary chunk with non-PAM atoms would be left in that state.)

        @param opts: options to pass to group API methods permit_as_member
                     and _f_wants_to_be_killed. As of 080319, only
                     pre_updaters is recognized, default True,
                     saying whether we're running before updaters
                     (especially the dna updater) have first been run.

        @warning: implementation is mostly in friend methods in class Node
                  and/or Group, and is intended to be simple and safe, *not* fast.
                  Therefore this is not suitable to run within the dna updater,
                  only after mmp read.

        @warning: this does not check self.topnode._f_wants_to_be_killed
                  since that is nontrivial to do safely and is probably not
                  needed at present.

        @note: this method's only purpose (and that of the friend methods
               it calls) is to clean up incorrect mmp files whose UI ops
               were not properly enforcing these rules.
        """
        assert self.topnode
        orig_topnode = self.topnode
        ejected_anything = self.topnode.is_group() and \
            self.topnode._f_move_nonpermitted_members(**opts)
        # if it ejected anything, then as a special case for being at the top,
        # ensure_toplevel_group created a group to wrap the old topnode,
        # which is what contains the ejected nodes.
        # Verify this, and if it happened, repeat once, and then
        # ungroup if it has one or no members.
        if ejected_anything != (orig_topnode is not self.topnode):
            if ejected_anything:
                print "\n***BUG: sanitize_dnagroups ejected from topnode %r " \
                      "but didn't replace it" % orig_topnode
            else:
                print "\n***BUG: sanitize_dnagroups replaced topnode %r " \
                      "with %r but didn't eject anything" % \
                      (orig_topnode, self.topnode)
            pass
        else:
            # no bug, safe to proceed
            if orig_topnode is not self.topnode:
                # repeat, but only once (all new activity should be confined
                #  within the new topnode, presumably an ordinary Group
                #  made by ensure_toplevel_part)
                ejected_anything = self.topnode.is_group() and \
                    self.topnode._f_move_nonpermitted_members(**opts)
                if ejected_anything:
                    print "\n***BUG: sanitize_dnagroups ejected from new topnode in %r" % self
                    # don't print new topnode, we don't know whether or not it changed --
                    # if this ever happens, revise to print more info
                elif not self.topnode.is_group():
                    print "\n***BUG: sanitize_dnagroups replaced topnode with a non-Group in %r" % self
                else:
                    # still no bug, safe to proceed
                    if len(self.topnode) <= 1:
                        self.topnode.ungroup() #k
                    pass
                pass
            pass
        return

    # == Bounding box methods

    ### BUG: these only consider chunks (self.molecules) --
    # they would miss other model objects such as Jigs. [bruce 070919 comment]
    #
    # REVIEW: is self.bbox (which these recompute) still used for anything?
    # Could it have been superceded by the one recalculated in glpane.setViewFitToWindow?
    # (Note, it's used in glpane.setViewRecenter, but only after an explicit recomputation
    #  done by self.computeBoundingBox(), so its "auto-maintained" aspect is not being used
    #  by that.)
    # [bruce 070919 question]

    def computeBoundingBox(self):
        """
        Compute the bounding box for this Part. This should be
        called whenever the geometry model has been changed, like new
        parts added, parts/atoms deleted, parts moved/rotated(not view
        move/rotation), etc."""
        self.invalidate_attrs(['bbox','center'])
        self.bbox, self.center
        return

    _inputs_for_bbox = ['molecules'] # in principle, this should also be invalidated directly by a lot more than does it now
    def _recompute_bbox(self):
        self.bbox = BBox()
        for mol in self.molecules:
            self.bbox.merge(mol.bbox)
        self.center = self.bbox.center()

    _inputs_for_center = ['molecules']
    _recompute_center = _recompute_bbox

    # more bounding box methods [split out of GLPane methods by bruce 070919]

    def bbox_for_viewing_model(self): #bruce 070919 split this out of a GLPane method
        """
        Return a BBox object suitable for choosing a view which shows the entire model
        (visible objects only).
        BUGS:
        - considers only chunks.
        - rectilinear bbox, not screen-aligned, is a poor approximation to the
          model volume for choosing the view. (Fixing this would require
          some changes in the caller as well.)
        """
        bbox = BBox()

        for mol in self.molecules:
            if mol.hidden or mol.display == diINVISIBLE:
                continue
            bbox.merge(mol.bbox)
        return bbox

    def bbox_for_viewing_selection(self): #bruce 070919 split this out of a GLPane method
        """
        Return a BBox object suitable for choosing a view which shows all
        currently selected objects in the model.
        BUGS:
        - considers only visible objects, even though some invisible objects,
        when selected, are indirectly visible (and all ought to be).
        (If this is fixed, comments and message strings in the caller will
         need revision.)
        - rectilinear bbox, not screen-aligned, is a poor approximation to the
          model volume for choosing the view. (Fixing this would also require
          some changes in the caller.)
        """
        movables = self.getSelectedMovables()

        #We will compute a Bbox with a point list.
        #Approach to fix bug 2250. ninad060905
        pointList = []

        selatoms_list = self.selatoms_list()
        if selatoms_list:
            for atm in selatoms_list:
                if atm.display == diINVISIBLE: #ninad 060903  may not be necessary.
                #@@@ Could be buggy because user is probably seeing the selection wireframe around invisible atom
                #and you are now allowing zoom to selection. Same is true for invisible chunks.
                    continue
                pointList.append(atm.posn())

        if movables:
            for obj in movables:
                if obj.hidden:
                    continue
                if not isinstance(obj, Jig):
                    if obj.display == diINVISIBLE:
                        continue
                if isinstance(obj, Chunk):
                    for a in obj.atoms.itervalues():
                        pointList.append(a.posn())
                elif isinstance(obj, Jig):
                    pointList.append(obj.center)
        else:
            if not selatoms_list:
                return None

        bbox = BBox(pointList)

        return bbox

    # ==

    _inputs_for_alist = [] # only invalidated directly. Not sure if we'll inval this whenever we should, or before uses. ###@@@
    def _recompute_alist(self):
        """
        Recompute self.alist, a list of all atoms in this Part, in the same order in which they
        were read from, or would be written to, an mmp file --
        namely, tree order for chunks, atom.key order within chunks.
        See also nodes_in_mmpfile_order.
        """
        #bruce 050228 changed chunk.writemmp to make this possible,
        # by writing atoms in order of atom.key,
        # which is also the order they're created in when read from an mmp file.
        # Note that just after reading an mmp file, all atoms in alist are ordered by .key,
        # but this is no longer true in general after chunks are reordered, separated, merged,
        # or atoms are created or destroyed. What does remain true is that newly written mmp files
        # would have atoms (and the assy.alist computed by the old mmp-writing code)
        # in the same order as this function computes.
        #   (#e Warning: if we revise mmp file format, this might no longer be correct.
        # For example, if we wanted movies to remain valid when chunks were reordered in the MT
        # and even when atoms were divided into chunks differently,
        # we could store an array of atoms followed by chunking and grouping info, instead of
        # using tree order at all to determine the atom order in the file. Or, we could change
        # the movie file format to not depend so strongly on atom order.)
        self.alist = 333 # not a valid Python sequence
        alist = []
        def func_alist(nn):
            """
            run this exactly once on all molecules (or other nodes) in this part, in tree order
            """
            if isinstance(nn, Chunk):
                alist.extend(nn.atoms_in_mmp_file_order())
                    ### REVIEW for PAM3+5: do we need to pass a mapping to
                    # atoms_in_mmp_file_order so it will include conversion atoms?
                    # If so, does caller need to pass it in, to determine conversion options?
                    # If so, do we replace the pseudo-invalidation of this list with
                    # a get method for it, or with passing the option to the mapping
                    # to tell it to collect the atoms actually written? (guess: the latter)
                    # [bruce 080321/080327 questions]
            return # from func_alist only
        self.topnode.apply2all( func_alist)
        self.alist = alist
        return

    # == do the selmols and selatoms recomputers belong in ops_select??

    _inputs_for_selmols = [] # only inval directly, since often stays the same when molecules changes, and might be incrly updated
    def _recompute_selmols(self):
        #e not worth optimizing for selwhat... but assert it was consistent, below.
        self.selmols = 333 # not a valid Python sequence
        res = []
        def func_selmols(nn):
            """
            run this exactly once on all molecules (or other nodes) in this part (in any order)
            """
            if isinstance(nn, Chunk) and nn.picked:
                res.append(nn)
            return # from func_selmols only
        self.topnode.apply2all( func_selmols)
        self.selmols = res
        if self.selmols:
            if self.selwhat != SELWHAT_CHUNKS:
                msg = "bug: part has selmols but selwhat != SELWHAT_CHUNKS"
                if debug_flags.atom_debug:
                    print_compact_stack(msg)
                else:
                    print msg
        return

    _inputs_for_selatoms = [] # only inval directly (same reasons as selmols; this one is *usually* updated incrementally, for speed)
    def _recompute_selatoms(self):
        if debug_1855:
            print "debug_1855: part %r _recompute_selatoms, self.selwhat is %r, so we %s assume result is {} without checking" % \
                  ( self, self.selwhat, {False:"WON'T",True:"WILL (not anymore)"}[self.selwhat != SELWHAT_ATOMS] )
            # Note: this optim (below, now removed) was wrong after undo in that bug...
            # I don't trust it to be always right even aside from Undo, so I'll remove it for A7.
            # For A8 maybe we should replace it with an optim based on an accurate per-part count of picked atoms?
            # Killed nodes might fail to get uncounted, but that would be ok. ##e
#bruce 060415 zapping this to fix bug 1855...
# but if we find selatoms and this would have said not to, should we fix selwhat??
# For now we just complain (debug only) but don't fix it. ###@@@
##        if self.selwhat != SELWHAT_ATOMS:
##            # optimize, by trusting selwhat to be correct.
##            # This is slightly dangerous until changes to assy's current selgroup/part
##            # also fix up selatoms, and perhaps even verify no atoms selected in new part.
##            # But it's likely that there are no such bugs, so we can try it this way for now.
##            # BTW, someday we might permit selecting atoms and chunks at same time,
##            # and this will need revision -- perhaps we'll have a selection-enabled boolean
##            # for each type of selectable thing; perhaps we'll keep selatoms at {} when they're
##            # known to be unselectable.
##            # [bruce 050308]
##            return {} # caller (InvalMixin.__getattr__) will store this into self.selatoms
        self.selatoms = 333 # not a valid dictlike thing
        res = {}
        def func_selatoms(nn):
            "run this exactly once on all molecules (or other nodes) in this part (in any order)"
            if isinstance(nn, Chunk):
                for atm in nn.atoms.itervalues():
                    if atm.picked:
                        res[atm.key] = atm
            return # from func_selatoms only
        self.topnode.apply2all( func_selatoms)
        self.selatoms = res
        if debug_1855:
            print "debug_1855: part %r _recompute_selatoms did so, stores %r" % (self, res,)
            # guess: maybe this runs too early, before enough is updated, due to smth asking for it, maybe for incr update purposes
        if res and self.selwhat != SELWHAT_ATOMS and debug_flags.atom_debug:
            #bruce 060415; this prints, even after fix (or mitigation to nothing but debug prints) of bug 1855,
            # and I don't yet see an easy way to avoid that, so making it debug-only for A7.
            print "debug: bug: part %r found %d selatoms, even though self.selwhat != SELWHAT_ATOMS (not fixed)" % (self,len(res))
        return

    def selatoms_list(self): #bruce 051031
        """
        Return the current list of selected atoms, in order of selection (whenever that makes sense), earliest first.
        This list is recomputed whenever requested, since order can change even when set of selected atoms
        doesn't change; therefore its API looks like a method rather than like an attribute.
           Intended usage: use .selatoms_list() instead of .selatoms.values() for anything which might care about atom order.
        """
        items = [(atm.pick_order(), atm) for atm in self.selatoms.itervalues()]
        items.sort()
        return [pair[1] for pair in items]

    def selected_atoms_list(self, include_atoms_in_selected_chunks = False): #bruce 070508
        """
        Return a list of all selected atoms. If the option says to, also include
        real (i.e. selectable, ignoring selection filter) atoms in selected chunks.
        Atoms are in arbitrary order, except that if only atoms were selected (not chunks),
        then they're in order of selection.
        """
        res = self.selatoms_list() # use some private knowledge: we now own this mutable list.
        if include_atoms_in_selected_chunks:
            #e [someday it might be that chunks too will have a pick_order;
            #   then we could sort them with the atoms before expanding them into atoms,
            #   and change our spec to return all atoms in order of selection
            #   (using arb or mmp file order within picked chunks)]
            for chunk in self.selmols:
                for atom in chunk.atoms.itervalues():
                    if not atom.is_singlet():
                        res.append(atom)
        return res

    # ==

    def addmol(self, mol): # searching for "def addnode" should also find this
        """
        [Public method; the name addmol is DEPRECATED, use addnode instead:]

        Add any kind of Node to this Part (usually the "current Part"),
        at the end of the top level of its node tree
        (so it will be visible as the last node in this Part's
         section of the Model Tree, when this Part is visible).

        Invalidate part attributes which summarize part content (e.g. bbox, drawLevel).

        @param mol: the Node to add to self
        @type mol: Node

        @note: The method name addmol is deprecated. New code should use its alias, addnode.
        """
        #bruce 050228 revised this for Part (was on assy) and for inval/update of part-summary attrs.
        ## not needed since done in changed_members:
        ## self.changed() #bruce 041118
        self.ensure_toplevel_group() # needed if, e.g., we use Build mode to add to a clipboard item
        self.topnode.addchild(mol)
            #bruce 050202 comment: if you don't want this location for the added mol,
            # just call mol.moveto when you're done, like [some other code] does.
        ## done in addchild->changed_dad->inherit_part->Part.add:
        ## self.invalidate_attrs(['natoms','molecules']) # this also invals bbox and center, via molecules

        #bruce 050321 disabling the following debug code, since not yet ok for all uses of _readmmp;
        # btw does readmmp even need to call addmol anymore??
        #bruce 050322 now readmmp doesn't call addmol so I'll try reenabling this debug code:
        if debug_flags.atom_debug:
            self.assy.checkparts()

    addnode = addmol #bruce 060604/080318; should make addnode the fundamental one, and clean up above comments

    def ensure_toplevel_group(self): #bruce 080318 revised so unopenables like DnaStrand don't count
        """
        Make sure this Part's toplevel node is a Group (of a kind which
         does not mind having arbitrary new members added to it),
        by Grouping it if not.

        @note: most operations which create new nodes and want to add them
               needn't call this directly, since they can call self.addnode or
               assy.addnode instead.
        """
        topnode = self.topnode
        assert topnode is not None
        if not topnode.is_group() or not topnode.MT_DND_can_drop_inside():
            # REVIEW: is that the best condition? Do we need an argument
            # to help us know what kinds of groups are acceptable here?
            # And if the current one is not, what kind to create?
            # [bruce 080318 comment, and revised condition]
            self.create_new_toplevel_group()
        return

    def create_new_toplevel_group(self):
        """
        #doc; return newly made toplevel group
        """
        ###e should assert we're a clipboard item part
        # to do this correctly, I think we have to know that we're a "clipboard item part";
        # this implem might work even if we permit Groups of clipboard items someday
        old_top = self.topnode
        #bruce 050420 keep autogen names in self as well as in topnode
        name = self.name or self.assy.name_autogrouped_nodes_for_clipboard( [old_top])
        self.name = name
        # beginning of section during which assy's Part structure is invalid
        self.topnode = Group(name, self.assy, None)
        self.add(self.topnode)
        # now put the new Group into the node tree in place of old_top
        old_top.addsibling(self.topnode)
        self.topnode.addchild(old_top) # do this last, since it makes old_top forget its old location
        # now fix our assy's current selection group if it used to be old_top,
        # but without any of the usual effects from "selgroup changed"
        # (since in a sense it didn't -- at least the selgroup's part didn't change).
        self.assy.fyi_part_topnode_changed(old_top, self.topnode)
        # end of section during which assy's Part structure is invalid
        if debug_flags.atom_debug:
            self.assy.checkparts()
        return self.topnode

    def get_topmost_subnodes_of_class(self, clas): #Ninad 2008-08-06, revised by bruce 080807
        """
        Return a list of the topmost (direct or indirect)
        children of self.topnode (Nodes or Groups), or
        self.topnode itself, which are instances of the
        given class (or of a subclass).

        That is, scanning depth-first into self's tree of nodes,
        for each node we include in our return value, we won't
        include any of its children.

        @param clas: a class.

        @note: to avoid import cycles, it's often desirable to
               specify the class as an attribute of a convenient
               Assembly object (e.g. xxx.assy.DnaSegment)
               rather than as a global value that needs to be imported
               (e.g. DnaSegment, after "from xxx import DnaSegment").

        @see: same-named method on class Group.
        """
        node = self.topnode # not necessarily a Group
        if isinstance( node, clas):
            return node
        elif node.is_group():
            return node.get_topmost_subnodes_of_class( clas)
        else:
            return []

    # ==

    # self.drawing_frame and related methods [bruce 090218/090219]

    _drawing_frame = None # allocated on demand

    _drawing_frame_class = fake_Part_drawing_frame
        # Note: this attribute is modified dynamically.
        # This default value is appropriate for drawing which does not
        # occur between matched calls of before/after_drawing_model, since
        # drawing then is deprecated but needs to work,
        # so this class will work, but warn when created.
        # Its "normal" value is used between matched calls
        # of before/after_drawing_model.

    def __get_drawing_frame(self):
        """
        get method for self.drawing_frame property:

        Initialize self._drawing_frame if necessary, and return it.
        """
        if not self._drawing_frame:
            self._drawing_frame = self._drawing_frame_class()
            # note: self._drawing_frame_class changes dynamically
        return self._drawing_frame

    def __set_drawing_frame(self):
        """
        set method for self.drawing_frame property; should never be called
        """
        assert 0

    def __del_drawing_frame(self):
        """
        del method for self.drawing_frame property
        """
        self._drawing_frame = None

    drawing_frame = property(__get_drawing_frame, __set_drawing_frame, __del_drawing_frame)

    def _has_drawing_frame(self):
        """
        @return: whether we presently have an allocated drawing frame
                 (which would be returned by self.drawing_frame).
        @rtype: boolean
        """
        return self._drawing_frame is not None

    def draw(self, glpane):
        """
        Draw all of self's visible model objects
        using the given GLPane,
        whose OpenGL context must already be current.
        """
        self.invalidate_attr('natoms') #bruce 060215, so that natoms and drawLevel are recomputed every time
            # (needed to fix bugs caused by lack of inval of natoms when atoms die or are born;
            #  also means no need for prefs change to inval drawLevel, provided it gl_updates)
            # (could optim by only invalling drawLevel itself if the prefs value is not 'variable', I think,
            #  but recomputing natoms should be fast compared to drawing, anyway)
        self.before_drawing_model()
        error = True
        try:
            # draw all visible model objects in self
            self.topnode.draw(glpane, glpane.displayMode)
            error = False
        finally:
            self.after_drawing_model(error)
        return

    def general_appearance_prefs_summary(self, glpane): #bruce 090306
        """
        Summarize the prefs values that affect the appearance of most or all
        atoms and bonds (that can be drawn when self is drawn), using the
        graphics prefs values in glpane.glprefs and for drawing in glpane.

        Note about how this is used: when what we return changes, all
        Chunk & ExternalBondSet display lists (to be drawn in self)
        will be considered invalid (when next drawn in self).

        @note: we don't include glpane.displayMode, because it only affects
            some display lists (not the ones for which locally set display
            styles determine their appearance).

        @see: GLPane._general_appearance_change_indicator (related)

        @see: GLPane._whole_model_drawingset_change_indicator (not directly related)

        @see: GLPane._cached_bg_image_comparison_data (not directly related)
        """
        eltprefs = (PeriodicTable.color_change_counter,
                    PeriodicTable.rvdw_change_counter )
        matprefs = glpane.glprefs.materialprefs_summary() #bruce 051126
        drawLevel = glpane.get_drawLevel(self) # ok to pass assy or part
            #bruce 060215 added drawLevel (when this was in Chunk.draw)
            # review: does this drawLevel kluge belong inside
            # GLPrefs.materialprefs_summary?
        return (eltprefs, matprefs, drawLevel,)

    def before_drawing_model(self): #bruce 070928; revised 090219 ### maybe: rename _model -> _part?
        """
        Whenever self's model, or part of it, is drawn,
        that should be bracketed by calls of self.before_drawing_model()
        and self.after_drawing_model() (using try/finally to guarantee
        the latter call). This is already done by self.draw,
        but must be done explicitly if something draws a portion of
        self's model in some other way. (For examples, see our other calls.)

        Specifically, the caller must do (in this order):

        * call self.before_drawing_model()

        * call node.draw() (with proper arguments, and exception protection)
          on some subset of the nodes of self (not drawing any node twice);
          during these calls, reference can be made to attributes of
          self.drawing_frame (which is allocated on demand if/when first used
          after this method is called)

        * call self.after_drawing_model() (with proper arguments)

        Nesting of these pairs of before_drawing_model/after_drawing_model calls
        is not permitted and will cause bugs.

        This API will need revision when the model can contain repeated parts,
        since each repetition will need to be bracketed by matched calls
        of before_drawing_model and after_drawing_model, but they will need
        to behave differently to permit nesting (e.g. have a stack of prior
        values of the variables they reset). Nesting would not be needed to
        support "multiple views of one whole Part" or "views of multiple Parts"
        (but each Part-view would need to be bracketed by before/after calls);
        but nesting would be needed to support multiple views of "part of one
        Part" within a larger view of the "whole Part", if they were implemented
        by drawing "part of one Part" multiple times (due to repeated drawing
        of bonds being legitimate then). If the repetition was implemented
        at a graphical level (e.g. by reusing a DrawingSet), nesting of
        this bracketing would not be needed, and that would be faster too.
        """
        del self.drawing_frame
        self._drawing_frame_class = Part_drawing_frame
            # instantiated the first time self.drawing_frame is accessed
        return

    def after_drawing_model(self, error = False): #bruce 070928; revised 090219
        """
        @see: before_drawing_model

        @param error: if the caller knows, it can pass an error flag
                      to indicate whether drawing succeeded or failed.
                      If it's known to have failed, we might not do some
                      things we normally do. Default value is False
                      since most calls don't pass anything. (#REVIEW: good?)
        """
        del self.drawing_frame
        del self._drawing_frame_class # expose class default value
        return

    def glpane_label_text(self): #bruce 090219 renamed from glpane_text
        return "" # default implem, subclasses might override this

    def writepov(self, f, dispdef): # revised, bruce 090219
        """
        Draw self's visible model objects into an open povray file
        (which already has whatever headers & macros it needs),
        using the given display mode by default.
        """
        self.before_drawing_model()
            # This is needed at least for its setting up of
            # self.drawing_frame.repeated_bonds_dict, and using its
            # "full version" will help permit future draw methods that
            # work for either OpenGL or POV-Ray.
            # (It might also be desirable to use GLPane._before_drawing_csdls
            #  at that time.)
        error = True
        try:
            self.topnode.writepov(f, dispdef)
            error = False
        finally:
            self.after_drawing_model(error)
        return

    # ==

    def break_interpart_bonds(self): ###@@@ move elsewhere in method order? review, implem for jigs
        """
        Break all bonds between nodes in this part and nodes in other parts;
        jig-atom connections count as bonds [but might not be handled correctly as of 050308].
        #e In future we might optimize this and only do it for specific node-trees.
        """
        # Note: this implem assumes that the nodes in self are exactly the node-tree under self.topnode.
        # As of 050309 this is always true (after update_parts runs), but might not be required except here.
        self.topnode.apply2all( lambda node: node.break_interpart_bonds() )
        return

    # == these are event handlers which do their own full UI updates at the end

    # bruce 050201 for Alpha:
    #    Like I did to fix bug 370 for Delete (and cut and copy),
    # make Hide and Unhide work on jigs even when in selatoms mode.

    def Hide(self):
        """
        Hide all selected chunks and jigs
        """
        self.topnode.apply2picked(lambda x: x.hide())
        self.w.win_update()

    def Unhide(self):
        """
        Unhide all selected chunks and jigs
        """
        self.topnode.apply2picked(lambda x: x.unhide())
        self.w.win_update()

    # ==

    def place_new_geometry(self, plane):
        self.ensure_toplevel_group()
        self.addnode(plane)
        # note: fix_one_or_complain will do nothing
        # (and return 0) as long as plane has no atoms,
        # which I think is true for all ref. geometry, for now anyway
        # [bruce 071214 comment]
        def errfunc(msg):
            "local function for error message output"
            # I think this will never happen [bruce 071214]
            env.history.message( redmsg( "Internal error making new geometry: " + msg))
        fix_one_or_complain( plane, self.topnode, errfunc)
        self.assy.changed()
        self.w.win_update()
        return

    def place_new_jig(self, jig): #bruce 050415, split from all jig makers, extended, bugfixed
        """
        Place a new jig
        (created by user, from atoms which must all be in this Part)
        into a good place in this Part's model tree.
        """
        atoms = jig.atoms # public attribute of the jig
        assert atoms, "bug: new jig has no atoms: %r" % jig
        for atm in atoms:
            assert atm.molecule.part is self, \
                   "bug: new jig %r's atoms are not all in the current Part %r (e.g. %r is in %r)" % \
                   ( jig, self, atm, atm.molecule.part )
        # First just put it after any atom's chunk (as old code did); then fix that place below.
        self.ensure_toplevel_group() #bruce 050415 fix bug 452 item 17
        mol = atoms[0].molecule # arbitrary chunk involved with this jig
        mol.dad.addchild(jig)
        assert jig.part is self, "bug in place_new_jig's way of setting correct .part for jig %r" % jig
        # Now put it in the right place in the tree, if it didn't happen to end up there in addchild.
        # BTW, this is probably still good to do, even though it's no longer necessary to do
        # whenever we save the file (by workaround_for_bug_296, now removed),
        # i.e. even though the mmp format now permits forward refs to jigs. [bruce 051115 revised comment]
        def errfunc(msg):
            "local function for error message output"
            # I think this should never happen [bruce ca. 050415]
            env.history.message( redmsg( "Internal error making new jig: " + msg))
        fix_one_or_complain( jig, self.topnode, errfunc)
        # now it's after all the atoms in it, but we also need to move it
        # outside of any group it doesn't belong in (and after that group
        # so that it remains after all its atoms). [bruce 080515 bugfix]
        move_after_this_group = None
        for group in jig.containing_groups():
            if not 1: ## group.allow_this_node_inside(jig): # IMPLEM, to the extent we need it aside from permit_as_member
                move_after_this_group = group
            else:
                location = move_after_this_group or jig
                if group is location.dad and not group.permit_as_member( jig, pre_updaters = False ):
                    ###doc: explain why this option pre_updaters = False makes sense
                    # review: use the other code that calls permit_as_member instead of
                    # calling it directly?
                    move_after_this_group = group
                pass
            continue
        if move_after_this_group is not None:
            move_after_this_group.addsibling(jig)
        return

    # ==

    def resetAtomsDisplay(self):
        """
        Resets the display mode for each atom in the selected chunks
        to default display mode.
        Returns the total number of atoms that had their display setting reset.
        """
        n = 0
        for chunk in self.selmols:
            n += chunk.set_atoms_display(diDEFAULT)
        if n:
            self.changed()
        return n

    def showInvisibleAtoms(self):
        """
        Resets the display mode for each invisible (diINVISIBLE) atom in the
        selected chunks to default display mode.
        Returns the total number of invisible atoms that had their display setting reset.
        """
        n = 0
        for chunk in self.selmols:
            n += chunk.show_invisible_atoms()
        if n:
            self.changed()
        return n

    ###e refile these new methods:

    def writemmpfile(self, filename, **mapping_options): #bruce 051209 added **mapping_options
        # as of 050412 this didn't yet turn singlets into H;
        # but as of long before 051115 it does (for all calls -- so it would not be good to use for Save Selection!)
        #bruce 051209  -- now it only does that if **mapping_options ask it to.
        from files.mmp.files_mmp_writing import writemmpfile_part
        writemmpfile_part( self, filename, **mapping_options)

    pass # end of class Part

# == subclasses of Part

class MainPart(Part):
    def immortal(self):
        return True
    def location_name(self):
        return "main part"
    def movie_suffix(self):
        """
        what suffix should we use in movie filenames? None means don't permit making them.
        """
        return ""
    pass

class ClipboardItemPart(Part):
    def glpane_label_text(self):
        #e abbreviate long names...
        return "%s (%s)" % (self.topnode.name, self.location_name())
    def location_name(self):
        """
        [used in history messages and on glpane]
        """
        # bruce 050418 change:
        ## return "clipboard item %d" % ( self.clipboard_item_number(), )
        return "on Clipboard" #e might be better to rename that to Shelf, so only the current
            # pastable (someday also in OS clipboard) can be said to be "on the Clipboard"!
    def clipboard_item_number(self):
        """
        this can be different every time...
        """
        return self.assy.shelf.members.index(self.topnode) + 1
    def movie_suffix(self):
        """
        what suffix should we use in movie filenames? None means don't permit making them.
        """
        ###e stub -- not a good choice, since it changes and thus is reused...
        # it might be better to assign serial numbers to each newly made Part that needs one for this purpose...
        # actually I should store part numbers in the file, and assign new ones as 1 + max of existing ones in shelf.
        # then use them in dflt topnode name and in glpane text (unless redundant) and in this movie suffix.
        # but this stub will work for now. Would it be better to just return ""? Not sure. Probably not.
        return "-%d" % ( self.clipboard_item_number(), )
    pass

# end