98cc1ff688c85de03f7e9b8f8375a207fb440b2a
[jalview.git] / src / jalview / ext / rbvi / chimera / JalviewChimeraBinding.java
1 /*
2  * Jalview - A Sequence Alignment Editor and Viewer ($$Version-Rel$$)
3  * Copyright (C) $$Year-Rel$$ The Jalview Authors
4  * 
5  * This file is part of Jalview.
6  * 
7  * Jalview is free software: you can redistribute it and/or
8  * modify it under the terms of the GNU General Public License 
9  * as published by the Free Software Foundation, either version 3
10  * of the License, or (at your option) any later version.
11  *  
12  * Jalview is distributed in the hope that it will be useful, but 
13  * WITHOUT ANY WARRANTY; without even the implied warranty 
14  * of MERCHANTABILITY or FITNESS FOR A PARTICULAR 
15  * PURPOSE.  See the GNU General Public License for more details.
16  * 
17  * You should have received a copy of the GNU General Public License
18  * along with Jalview.  If not, see <http://www.gnu.org/licenses/>.
19  * The Jalview Authors are detailed in the 'AUTHORS' file.
20  */
21 package jalview.ext.rbvi.chimera;
22
23 import java.io.File;
24 import java.io.FileOutputStream;
25 import java.io.IOException;
26 import java.io.PrintWriter;
27 import java.net.BindException;
28 import java.util.ArrayList;
29 import java.util.Collections;
30 import java.util.Iterator;
31 import java.util.LinkedHashMap;
32 import java.util.List;
33 import java.util.Map;
34
35 import ext.edu.ucsf.rbvi.strucviz2.ChimeraManager;
36 import ext.edu.ucsf.rbvi.strucviz2.ChimeraModel;
37 import ext.edu.ucsf.rbvi.strucviz2.StructureManager;
38 import ext.edu.ucsf.rbvi.strucviz2.StructureManager.ModelType;
39 import jalview.api.AlignmentViewPanel;
40 import jalview.api.structures.JalviewStructureDisplayI;
41 import jalview.datamodel.AlignmentI;
42 import jalview.datamodel.PDBEntry;
43 import jalview.datamodel.SearchResultMatchI;
44 import jalview.datamodel.SearchResultsI;
45 import jalview.datamodel.SequenceFeature;
46 import jalview.datamodel.SequenceI;
47 import jalview.gui.StructureViewer.ViewerType;
48 import jalview.httpserver.AbstractRequestHandler;
49 import jalview.io.DataSourceType;
50 import jalview.structure.AtomSpec;
51 import jalview.structure.StructureCommand;
52 import jalview.structure.StructureCommandI;
53 import jalview.structure.StructureSelectionManager;
54 import jalview.structures.models.AAStructureBindingModel;
55
56 public abstract class JalviewChimeraBinding extends AAStructureBindingModel
57 {
58   public static final String CHIMERA_SESSION_EXTENSION = ".py";
59
60   public static final String CHIMERA_FEATURE_GROUP = "Chimera";
61
62   // Chimera clause to exclude alternate locations in atom selection
63   private static final String NO_ALTLOCS = "&~@.B-Z&~@.2-9";
64
65   private static final boolean debug = false;
66
67   private static final String PHOSPHORUS = "P";
68
69   private static final String ALPHACARBON = "CA";
70
71   /*
72    * Object through which we talk to Chimera
73    */
74   private ChimeraManager chimeraManager;
75
76   /*
77    * Object which listens to Chimera notifications
78    */
79   private AbstractRequestHandler chimeraListener;
80
81   /*
82    * Map of ChimeraModel objects keyed by PDB full local file name
83    */
84   protected Map<String, List<ChimeraModel>> chimeraMaps = new LinkedHashMap<>();
85
86   String lastHighlightCommand;
87
88   private Thread chimeraMonitor;
89
90   /**
91    * Open a PDB structure file in Chimera and set up mappings from Jalview.
92    * 
93    * We check if the PDB model id is already loaded in Chimera, if so don't reopen
94    * it. This is the case if Chimera has opened a saved session file.
95    * 
96    * @param pe
97    * @return
98    */
99   public boolean openFile(PDBEntry pe)
100   {
101     String file = pe.getFile();
102     try
103     {
104       List<ChimeraModel> modelsToMap = new ArrayList<>();
105       List<ChimeraModel> oldList = chimeraManager.getModelList();
106       boolean alreadyOpen = false;
107
108       /*
109        * If Chimera already has this model, don't reopen it, but do remap it.
110        */
111       for (ChimeraModel open : oldList)
112       {
113         if (open.getModelName().equals(pe.getId()))
114         {
115           alreadyOpen = true;
116           modelsToMap.add(open);
117         }
118       }
119
120       /*
121        * If Chimera doesn't yet have this model, ask it to open it, and retrieve
122        * the model name(s) added by Chimera.
123        */
124       if (!alreadyOpen)
125       {
126         chimeraManager.openModel(file, pe.getId(), ModelType.PDB_MODEL);
127         addChimeraModel(pe, modelsToMap);
128       }
129
130       chimeraMaps.put(file, modelsToMap);
131
132       if (getSsm() != null)
133       {
134         getSsm().addStructureViewerListener(this);
135       }
136       return true;
137     } catch (Exception q)
138     {
139       log("Exception when trying to open model " + file + "\n"
140               + q.toString());
141       q.printStackTrace();
142     }
143     return false;
144   }
145
146   /**
147    * Adds the ChimeraModel corresponding to the given PDBEntry, based on model
148    * name matching PDB id
149    * 
150    * @param pe
151    * @param modelsToMap
152    */
153   protected void addChimeraModel(PDBEntry pe,
154           List<ChimeraModel> modelsToMap)
155   {
156     /*
157      * Chimera: query for actual models and find the one with
158      * matching model name - already set in viewer.openModel()
159      */
160     List<ChimeraModel> newList = chimeraManager.getModelList();
161     // JAL-1728 newList.removeAll(oldList) does not work
162     for (ChimeraModel cm : newList)
163     {
164       if (cm.getModelName().equals(pe.getId()))
165       {
166         modelsToMap.add(cm);
167       }
168     }
169   }
170
171   /**
172    * Constructor
173    * 
174    * @param ssm
175    * @param pdbentry
176    * @param sequenceIs
177    * @param protocol
178    */
179   public JalviewChimeraBinding(StructureSelectionManager ssm,
180           PDBEntry[] pdbentry, SequenceI[][] sequenceIs,
181           DataSourceType protocol)
182   {
183     super(ssm, pdbentry, sequenceIs, protocol);
184     chimeraManager = new ChimeraManager(new StructureManager(true));
185     chimeraManager.setChimeraX(ViewerType.CHIMERAX.equals(getViewerType()));
186     setStructureCommands(new ChimeraCommands());
187   }
188
189   @Override
190   protected ViewerType getViewerType()
191   {
192     return ViewerType.CHIMERA;
193   }
194
195   /**
196    * Starts a thread that waits for the Chimera process to finish, so that we can
197    * then close the associated resources. This avoids leaving orphaned Chimera
198    * viewer panels in Jalview if the user closes Chimera.
199    */
200   protected void startChimeraProcessMonitor()
201   {
202     final Process p = chimeraManager.getChimeraProcess();
203     chimeraMonitor = new Thread(new Runnable()
204     {
205
206       @Override
207       public void run()
208       {
209         try
210         {
211           p.waitFor();
212           JalviewStructureDisplayI display = getViewer();
213           if (display != null)
214           {
215             display.closeViewer(false);
216           }
217         } catch (InterruptedException e)
218         {
219           // exit thread if Chimera Viewer is closed in Jalview
220         }
221       }
222     });
223     chimeraMonitor.start();
224   }
225
226   /**
227    * Start a dedicated HttpServer to listen for Chimera notifications, and tell it
228    * to start listening
229    */
230   public void startChimeraListener()
231   {
232     try
233     {
234       chimeraListener = new ChimeraListener(this);
235       chimeraManager.startListening(chimeraListener.getUri());
236     } catch (BindException e)
237     {
238       System.err.println(
239               "Failed to start Chimera listener: " + e.getMessage());
240     }
241   }
242
243   /**
244    * Close down the Jalview viewer and listener, and (optionally) the associated
245    * Chimera window.
246    */
247   @Override
248   public void closeViewer(boolean closeChimera)
249   {
250     super.closeViewer(closeChimera);
251     if (closeChimera)
252     {
253       chimeraManager.exitChimera();
254     }
255     if (this.chimeraListener != null)
256     {
257       chimeraListener.shutdown();
258       chimeraListener = null;
259     }
260     chimeraManager = null;
261
262     if (chimeraMonitor != null)
263     {
264       chimeraMonitor.interrupt();
265     }
266   }
267
268   /**
269    * Helper method to construct model spec in Chimera format:
270    * <ul>
271    * <li>#0 (#1 etc) for a PDB file with no sub-models</li>
272    * <li>#0.1 (#1.1 etc) for a PDB file with sub-models</li>
273    * <ul>
274    * Note for now we only ever choose the first of multiple models. This
275    * corresponds to the hard-coded Jmol equivalent (compare {1.1}). Refactor in
276    * future if there is a need to select specific sub-models.
277    * 
278    * @param pdbfnum
279    * @return
280    */
281   protected String getModelSpec(int pdbfnum)
282   {
283     if (pdbfnum < 0 || pdbfnum >= getPdbCount())
284     {
285       return "#" + pdbfnum; // temp hack for ChimeraX
286     }
287
288     /*
289      * For now, the test for having sub-models is whether multiple Chimera
290      * models are mapped for the PDB file; the models are returned as a response
291      * to the Chimera command 'list models type molecule', see
292      * ChimeraManager.getModelList().
293      */
294     List<ChimeraModel> maps = chimeraMaps.get(getStructureFiles()[pdbfnum]);
295     boolean hasSubModels = maps != null && maps.size() > 1;
296     return "#" + String.valueOf(pdbfnum) + (hasSubModels ? ".1" : "");
297   }
298
299   /**
300    * Launch Chimera, unless an instance linked to this object is already
301    * running. Returns true if Chimera is successfully launched, or already
302    * running, else false.
303    * 
304    * @return
305    */
306   public boolean launchChimera()
307   {
308     if (chimeraManager.isChimeraLaunched())
309     {
310       return true;
311     }
312
313     boolean launched = chimeraManager.launchChimera(getChimeraPaths());
314     if (launched)
315     {
316       startChimeraProcessMonitor();
317     }
318     else
319     {
320       log("Failed to launch Chimera!");
321     }
322     return launched;
323   }
324
325   /**
326    * Returns a list of candidate paths to the Chimera program executable
327    * 
328    * @return
329    */
330   protected List<String> getChimeraPaths()
331   {
332     return StructureManager.getChimeraPaths(false);
333   }
334
335   /**
336    * Answers true if the Chimera process is still running, false if ended or not
337    * started.
338    * 
339    * @return
340    */
341   @Override
342   public boolean isViewerRunning()
343   {
344     return chimeraManager.isChimeraLaunched();
345   }
346
347   /**
348    * Send a command to Chimera, and optionally log and return any responses.
349    * 
350    * @param command
351    * @param getResponse
352    */
353   @Override
354   public List<String> executeCommand(final StructureCommandI command,
355           boolean getResponse)
356   {
357     if (chimeraManager == null || command == null)
358     {
359       // ? thread running after viewer shut down
360       return null;
361     }
362     List<String> reply = null;
363     // trim command or it may never find a match in the replyLog!!
364     String cmd = command.getCommand().trim();
365     List<String> lastReply = chimeraManager
366             .sendChimeraCommand(cmd, getResponse);
367     if (getResponse)
368     {
369       reply = lastReply;
370       if (debug)
371       {
372         log("Response from command ('" + cmd + "') was:\n" + lastReply);
373       }
374     }
375
376     return reply;
377   }
378
379   @Override
380   public synchronized String[] getStructureFiles()
381   {
382     if (chimeraManager == null)
383     {
384       return new String[0];
385     }
386
387     return chimeraMaps.keySet()
388             .toArray(modelFileNames = new String[chimeraMaps.size()]);
389   }
390
391   /**
392    * Construct and send a command to highlight zero, one or more atoms. We do this
393    * by sending an "rlabel" command to show the residue label at that position.
394    */
395   @Override
396   public void highlightAtoms(List<AtomSpec> atoms)
397   {
398     if (atoms == null || atoms.size() == 0)
399     {
400       return;
401     }
402
403     boolean forChimeraX = chimeraManager.isChimeraX();
404     StringBuilder cmd = new StringBuilder(128);
405     boolean first = true;
406     boolean found = false;
407
408     for (AtomSpec atom : atoms)
409     {
410       int pdbResNum = atom.getPdbResNum();
411       String chain = atom.getChain();
412       String pdbfile = atom.getPdbFile();
413       List<ChimeraModel> cms = chimeraMaps.get(pdbfile);
414       if (cms != null && !cms.isEmpty())
415       {
416         if (first)
417         {
418           cmd.append(forChimeraX ? "label #" : "rlabel #");
419         }
420         else
421         {
422           cmd.append(",");
423         }
424         first = false;
425         if (forChimeraX)
426         {
427           cmd.append(cms.get(0).getModelNumber())
428                   .append("/").append(chain).append(":").append(pdbResNum);
429         }
430         else
431         {
432           cmd.append(cms.get(0).getModelNumber())
433                   .append(":").append(pdbResNum);
434           if (!chain.equals(" ") && !forChimeraX)
435           {
436             cmd.append(".").append(chain);
437           }
438         }
439         found = true;
440       }
441     }
442     String command = cmd.toString();
443
444     /*
445      * avoid repeated commands for the same residue
446      */
447     if (command.equals(lastHighlightCommand))
448     {
449       return;
450     }
451
452     /*
453      * unshow the label for the previous residue
454      */
455     if (lastHighlightCommand != null)
456     {
457       chimeraManager.sendChimeraCommand("~" + lastHighlightCommand, false);
458     }
459     if (found)
460     {
461       chimeraManager.sendChimeraCommand(command, false);
462     }
463     this.lastHighlightCommand = command;
464   }
465
466   /**
467    * Query Chimera for its current selection, and highlight it on the alignment
468    */
469   public void highlightChimeraSelection()
470   {
471     /*
472      * Ask Chimera for its current selection
473      */
474     List<String> selection = chimeraManager.getSelectedResidueSpecs();
475
476     /*
477      * Parse model number, residue and chain for each selected position,
478      * formatted as #0:123.A or #1.2:87.B (#model.submodel:residue.chain)
479      */
480     List<AtomSpec> atomSpecs = convertStructureResiduesToAlignment(
481             selection);
482
483     /*
484      * Broadcast the selection (which may be empty, if the user just cleared all
485      * selections)
486      */
487     getSsm().mouseOverStructure(atomSpecs);
488   }
489
490   /**
491    * Converts a list of Chimera atomspecs to a list of AtomSpec representing the
492    * corresponding residues (if any) in Jalview
493    * 
494    * @param structureSelection
495    * @return
496    */
497   protected List<AtomSpec> convertStructureResiduesToAlignment(
498           List<String> structureSelection)
499   {
500     boolean chimeraX = chimeraManager.isChimeraX();
501     List<AtomSpec> atomSpecs = new ArrayList<>();
502     for (String atomSpec : structureSelection)
503     {
504       try
505       {
506         AtomSpec spec = AtomSpec.fromChimeraAtomspec(atomSpec, chimeraX);
507         String pdbfilename = getPdbFileForModel(spec.getModelNumber());
508         spec.setPdbFile(pdbfilename);
509         atomSpecs.add(spec);
510       } catch (IllegalArgumentException e)
511       {
512         System.err.println("Failed to parse atomspec: " + atomSpec);
513       }
514     }
515     return atomSpecs;
516   }
517
518   /**
519    * @param modelId
520    * @return
521    */
522   protected String getPdbFileForModel(int modelId)
523   {
524     /*
525      * Work out the pdbfilename from the model number
526      */
527     String pdbfilename = modelFileNames[0];
528     findfileloop: for (String pdbfile : this.chimeraMaps.keySet())
529     {
530       for (ChimeraModel cm : chimeraMaps.get(pdbfile))
531       {
532         if (cm.getModelNumber() == modelId)
533         {
534           pdbfilename = pdbfile;
535           break findfileloop;
536         }
537       }
538     }
539     return pdbfilename;
540   }
541
542   private void log(String message)
543   {
544     System.err.println("## Chimera log: " + message);
545   }
546
547   /**
548    * Ask Chimera to open a session file. Returns true if successful, else false.
549    * The filename must have a .py (Chimera) or .cxs (ChimeraX) extension for
550    * this command to work.
551    * 
552    * @param filepath
553    * @return
554    */
555   public boolean openSession(String filepath)
556   {
557     /*
558      * Chimera:  https://www.cgl.ucsf.edu/chimera/current/docs/UsersGuide/midas/open.html
559      * ChimeraX: https://www.cgl.ucsf.edu/chimerax/docs/user/commands/open.html
560      */
561     executeCommand(getCommandGenerator().loadFile(filepath), true);
562     // todo: test for failure - how?
563     return true;
564   }
565
566   /**
567    * Send a 'show' command for all atoms in the currently selected columns
568    * 
569    * TODO: pull up to abstract structure viewer interface
570    * 
571    * @param vp
572    */
573   public void highlightSelection(AlignmentViewPanel vp)
574   {
575     List<Integer> cols = vp.getAlignViewport().getColumnSelection()
576             .getSelected();
577     AlignmentI alignment = vp.getAlignment();
578     StructureSelectionManager sm = getSsm();
579     for (SequenceI seq : alignment.getSequences())
580     {
581       /*
582        * convert selected columns into sequence positions
583        */
584       int[] positions = new int[cols.size()];
585       int i = 0;
586       for (Integer col : cols)
587       {
588         positions[i++] = seq.findPosition(col);
589       }
590       sm.highlightStructure(this, seq, positions);
591     }
592   }
593
594   /**
595    * Constructs and send commands to Chimera to set attributes on residues for
596    * features visible in Jalview
597    * 
598    * @param avp
599    * @return
600    */
601   public int sendFeaturesToViewer(AlignmentViewPanel avp)
602   {
603     // TODO refactor as required to pull up to an interface
604     String[] files = getStructureFiles();
605     if (files == null)
606     {
607       return 0;
608     }
609
610     List<StructureCommandI> commands = getCommandGenerator()
611             .setAttributesForFeatures(getSsm(), files, getSequence(), avp);
612     if (commands.size() > 10)
613     {
614       sendCommandsByFile(commands);
615     }
616     else
617     {
618       for (StructureCommandI command : commands)
619       {
620         sendAsynchronousCommand(command, null);
621       }
622     }
623     return commands.size();
624   }
625
626   /**
627    * Write commands to a temporary file, and send a command to Chimera to open the
628    * file as a commands script. For use when sending a large number of separate
629    * commands would overload the REST interface mechanism.
630    * 
631    * @param commands
632    */
633   protected void sendCommandsByFile(List<StructureCommandI> commands)
634   {
635     try
636     {
637       File tmp = File.createTempFile("chim", getCommandFileExtension());
638       tmp.deleteOnExit();
639       PrintWriter out = new PrintWriter(new FileOutputStream(tmp));
640       for (StructureCommandI command : commands)
641       {
642         out.println(command.getCommand());
643       }
644       out.flush();
645       out.close();
646       String path = tmp.getAbsolutePath();
647       StructureCommandI command = getCommandGenerator()
648               .openCommandFile(path);
649       sendAsynchronousCommand(command, null);
650     } catch (IOException e)
651     {
652       System.err.println("Sending commands to Chimera via file failed with "
653               + e.getMessage());
654     }
655   }
656
657   /**
658    * Returns the file extension required for a file of commands to be read by
659    * the structure viewer
660    * @return
661    */
662   protected String getCommandFileExtension()
663   {
664     return ".com";
665   }
666
667   /**
668    * Get Chimera residues which have the named attribute, find the mapped
669    * positions in the Jalview sequence(s), and set as sequence features
670    * 
671    * @param attName
672    * @param alignmentPanel
673    */
674   public void copyStructureAttributesToFeatures(String attName,
675           AlignmentViewPanel alignmentPanel)
676   {
677     // todo pull up to AAStructureBindingModel (and interface?)
678
679     /*
680      * ask Chimera to list residues with the attribute, reporting its value
681      */
682     // this alternative command
683     // list residues spec ':*/attName' attr attName
684     // doesn't report 'None' values (which is good), but
685     // fails for 'average.bfactor' (which is bad):
686
687     String cmd = "list residues attr '" + attName + "'";
688     List<String> residues = executeCommand(new StructureCommand(cmd), true);
689
690     boolean featureAdded = createFeaturesForAttributes(attName, residues);
691     if (featureAdded)
692     {
693       alignmentPanel.getFeatureRenderer().featuresAdded();
694     }
695   }
696
697   /**
698    * Create features in Jalview for the given attribute name and structure
699    * residues.
700    * 
701    * <pre>
702    * The residue list should be 0, 1 or more reply lines of the format: 
703    *     residue id #0:5.A isHelix -155.000836316 index 5 
704    * or 
705    *     residue id #0:6.A isHelix None
706    * </pre>
707    * 
708    * @param attName
709    * @param residues
710    * @return
711    */
712   protected boolean createFeaturesForAttributes(String attName,
713           List<String> residues)
714   {
715     boolean featureAdded = false;
716     String featureGroup = getViewerFeatureGroup();
717     boolean chimeraX = chimeraManager.isChimeraX();
718
719     for (String residue : residues)
720     {
721       AtomSpec spec = null;
722       String[] tokens = residue.split(" ");
723       if (tokens.length < 5)
724       {
725         continue;
726       }
727       String atomSpec = tokens[2];
728       String attValue = tokens[4];
729
730       /*
731        * ignore 'None' (e.g. for phi) or 'False' (e.g. for isHelix)
732        */
733       if ("None".equalsIgnoreCase(attValue)
734               || "False".equalsIgnoreCase(attValue))
735       {
736         continue;
737       }
738
739       try
740       {
741         spec = AtomSpec.fromChimeraAtomspec(atomSpec, chimeraX);
742       } catch (IllegalArgumentException e)
743       {
744         System.err.println("Problem parsing atomspec " + atomSpec);
745         continue;
746       }
747
748       String chainId = spec.getChain();
749       String description = attValue;
750       float score = Float.NaN;
751       try
752       {
753         score = Float.valueOf(attValue);
754         description = chainId;
755       } catch (NumberFormatException e)
756       {
757         // was not a float value
758       }
759
760       String pdbFile = getPdbFileForModel(spec.getModelNumber());
761       spec.setPdbFile(pdbFile);
762
763       List<AtomSpec> atoms = Collections.singletonList(spec);
764
765       /*
766        * locate the mapped position in the alignment (if any)
767        */
768       SearchResultsI sr = getSsm()
769               .findAlignmentPositionsForStructurePositions(atoms);
770
771       /*
772        * expect one matched alignment position, or none 
773        * (if the structure position is not mapped)
774        */
775       for (SearchResultMatchI m : sr.getResults())
776       {
777         SequenceI seq = m.getSequence();
778         int start = m.getStart();
779         int end = m.getEnd();
780         SequenceFeature sf = new SequenceFeature(attName, description,
781                 start, end, score, featureGroup);
782         // todo: should SequenceFeature have an explicit property for chain?
783         // note: repeating the action shouldn't duplicate features
784         featureAdded |= seq.addSequenceFeature(sf);
785       }
786     }
787     return featureAdded;
788   }
789
790   /**
791    * Answers the feature group name to apply to features created in Jalview from
792    * Chimera attributes
793    * 
794    * @return
795    */
796   protected String getViewerFeatureGroup()
797   {
798     // todo pull up to interface
799     return CHIMERA_FEATURE_GROUP;
800   }
801
802   @Override
803   public String getModelIdForFile(String pdbFile)
804   {
805     List<ChimeraModel> foundModels = chimeraMaps.get(pdbFile);
806     if (foundModels != null && !foundModels.isEmpty())
807     {
808       return String.valueOf(foundModels.get(0).getModelNumber());
809     }
810     return "";
811   }
812
813   /**
814    * Answers a (possibly empty) list of attribute names in Chimera[X], excluding
815    * any which were added from Jalview
816    * 
817    * @return
818    */
819   public List<String> getChimeraAttributes()
820   {
821     List<String> atts = chimeraManager.getAttrList();
822     Iterator<String> it = atts.iterator();
823     while (it.hasNext())
824     {
825       if (it.next().startsWith(ChimeraCommands.NAMESPACE_PREFIX))
826       {
827         /*
828          * attribute added from Jalview - exclude it
829          */
830         it.remove();
831       }
832     }
833     return atts;
834   }
835
836   /**
837    * Returns the file extension to use for a saved viewer session file (.py)
838    * 
839    * @return
840    */
841   @Override
842   public String getSessionFileExtension()
843   {
844     return CHIMERA_SESSION_EXTENSION;
845   }
846
847   public String getHelpURL()
848   {
849     return "https://www.cgl.ucsf.edu/chimera/docs/UsersGuide";
850   }
851 }