All Downloads are FREE. Search and download functionalities are using the official Maven repository.

com.actelion.research.gui.editor.GenericEditorArea Maven / Gradle / Ivy

There is a newer version: 2024.11.2
Show newest version
/*
 * Copyright (c) 1997 - 2016
 * Actelion Pharmaceuticals Ltd.
 * Gewerbestrasse 16
 * CH-4123 Allschwil, Switzerland
 *
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions are met:
 *
 * 1. Redistributions of source code must retain the above copyright notice, this
 *    list of conditions and the following disclaimer.
 * 2. Redistributions in binary form must reproduce the above copyright notice,
 *    this list of conditions and the following disclaimer in the documentation
 *    and/or other materials provided with the distribution.
 * 3. Neither the name of the the copyright holder nor the
 *    names of its contributors may be used to endorse or promote products
 *    derived from this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
 * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
 * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
 * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
 * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
 * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
 * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
 * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 *
 */

package com.actelion.research.gui.editor;

import com.actelion.research.chem.*;
import com.actelion.research.chem.coords.CoordinateInventor;
import com.actelion.research.chem.io.RDFileParser;
import com.actelion.research.chem.io.RXNFileParser;
import com.actelion.research.chem.name.StructureNameResolver;
import com.actelion.research.chem.reaction.*;
import com.actelion.research.gui.FileHelper;
import com.actelion.research.gui.LookAndFeelHelper;
import com.actelion.research.gui.clipboard.IClipboardHandler;
import com.actelion.research.gui.generic.*;
import com.actelion.research.gui.hidpi.HiDPIHelper;
import com.actelion.research.util.ColorHelper;

import java.awt.geom.Point2D;
import java.io.File;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.TreeMap;

public class GenericEditorArea implements GenericEventListener {
	public static final int MODE_MULTIPLE_FRAGMENTS = 1;
	public static final int MODE_MARKUSH_STRUCTURE = 2;
	public static final int MODE_REACTION = 4;
	public static final int MODE_DRAWING_OBJECTS = 8;

	public static final String TEMPLATE_TYPE_KEY = "TEMPLATE";
	public static final String TEMPLATE_TYPE_REACTION_QUERIES = "REACTION_QUERIES";
	public static final String TEMPLATE_SECTION_KEY = "SECTION";

	public static final int DEFAULT_ALLOWED_PSEUDO_ATOMS
			= Molecule.cPseudoAtomsHydrogenIsotops
			| Molecule.cPseudoAtomsAminoAcids
			| Molecule.cPseudoAtomR
			| Molecule.cPseudoAtomsRGroups
			| Molecule.cPseudoAtomAttachmentPoint;

	private static final int MAX_CONNATOMS = 8;
	private static final int MIN_BOND_LENGTH_SQUARE = 100;

	private static final int KEY_IS_ATOM_LABEL = 1;
	private static final int KEY_IS_SUBSTITUENT = 2;
	private static final int KEY_IS_VALID_START = 3;
	private static final int KEY_IS_INVALID = 4;

	private static final int RGB_BLUE = 0xFF0000FF;
	private static final int RGB_RED = 0xFFFF0000;
	private static final int RGB_DARK_RED = 0xFF800000;
	private static final int RGB_BLACK = 0xFF000000;
	private static final int RGB_GRAY = 0xFF808080;

	private static final String ITEM_COPY_STRUCTURE = "Copy Structure";
	private static final String ITEM_COPY_REACTION = "Copy Reaction";
	private static final String ITEM_PASTE_STRUCTURE = "Paste Structure";
	private static final String ITEM_PASTE_REACTION = "Paste Reaction";
	private static final String ITEM_USE_TEMPLATE = "Use Reaction Template...";
	private static final String ITEM_PASTE_WITH_NAME = ITEM_PASTE_STRUCTURE + " or Name";
	private static final String ITEM_LOAD_REACTION = "Open Reaction File...";
	private static final String ITEM_ADD_AUTO_MAPPING = "Auto-Map Reaction";
	private static final String ITEM_REMOVE_MAPPING = "Remove Manual Atom Mapping";
	private static final String ITEM_FLIP_HORIZONTALLY = "Flip Horizontally";
	private static final String ITEM_FLIP_VERTICALLY = "Flip Vertically";
	private static final String ITEM_FLIP_ROTATE180 = "Rotate 180°";
	private static final String ITEM_SHOW_HELP = "Help Me";

	// development items
	private static final String ITEM_SHOW_ATOM_BOND_NUMBERS = "Show Atom & Bond Numbers";
	private static final String ITEM_SHOW_SYMMETRY = "Show Symmetry";
	private static final String ITEM_SHOW_NORMAL = "Show Normal";

	private static final long WARNING_MILLIS = 1200;

	private static final float FRAGMENT_MAX_CLICK_DISTANCE = 24.0f;
	private static final float FRAGMENT_GROUPING_DISTANCE = CoordinateInventor.JOIN_DISTANCE_UNCHARGED_FRAGMENTS + 0.1f;    // in average bond lengths
	private static final float FRAGMENT_CLEANUP_DISTANCE = CoordinateInventor.JOIN_DISTANCE_UNCHARGED_FRAGMENTS + 0.2f; // in average bond lengths
	private static final float DEFAULT_ARROW_LENGTH = 0.08f;        // relative to panel width

	protected static final int UPDATE_NONE = 0;
	protected static final int UPDATE_REDRAW = 1;
	// redraw molecules and drawing objects with their current coordinates
	protected static final int UPDATE_CHECK_VIEW = 2;
	// redraw with on-the-fly coordinate transformation only if current coords do not fit within view area
	// (new coords are generated for one draw() only; the original coords are not changed)
	protected static final int UPDATE_CHECK_COORDS = 3;
	// redraw with in-place coordinate transformation only if current coords do not fit within view area
	// (the original atom and object coords are replaced by the new ones)
	protected static final int UPDATE_SCALE_COORDS = 4;
	// redraw with in-place coordinate transformation; molecules and objects are scaled to fill
	// the view unless the maximum average bond length reaches the optimum.
	protected static final int UPDATE_SCALE_COORDS_USE_FRAGMENTS = 5;
	// as UPDATE_SCALE_COORDS but uses fragments from mFragment rather than creating a
	// fresh mFragment list from mMol. Used for setting a reaction or fragment list from outside.
	protected static final int UPDATE_INVENT_COORDS = 6;
	// redraw with in-place coordinate transformation; first all molecules' coordinates
	// are generated from scratch, then molecules and objects are scaled to fill
	// the view unless the maximum average bond length reaches the optimum.

	private static final int DEFAULT_SELECTION_BACKGROUND = 0xFF80A4C0;

	private static final int cRequestNone = 0;
	private static final int cRequestNewBond = 1;
	private static final int cRequestNewChain = 2;
	private static final int cRequestMoveSingle = 3;
	private static final int cRequestMoveSelected = 4;
	private static final int cRequestLassoSelect = 5;
	private static final int cRequestSelectRect = 6;
	private static final int cRequestZoomAndRotate = 7;
	private static final int cRequestMapAtoms = 8;
	private static final int cRequestCopySelected = 9;
	private static final int cRequestMoveObject = 10;
	private static final int cRequestCopyObject = 11;

	private static IReactionMapper sMapper;
	private static String[][] sReactionQueryTemplates;
	private final int mMaxAVBL;
	private int mMode, mChainAtoms, mCurrentTool, mCustomAtomicNo, mCustomAtomMass, mCustomAtomValence, mCustomAtomRadical,
			mCurrentHiliteAtom, mCurrentHiliteBond, mPendingRequest, mEventsScheduled, mFirstAtomKey,
			mCurrentCursor, mReactantCount, mUpdateMode, mDisplayMode, mAtom1, mAtom2, mAllowedPseudoAtoms;
	private int[] mChainAtom, mFragmentNo, mHiliteBondSet;
	private final double mUIScaling;
	private double mX1, mY1, mX2, mY2, mWidth, mHeight, mTextSizeFactor;
	private double[] mX, mY, mChainAtomX, mChainAtomY;
	private boolean mAltIsDown, mShiftIsDown, mMouseIsDown, mIsAddingToSelection, mAtomColorSupported, mAllowQueryFeatures,
			mAllowFragmentChangeOnPasteOrDrop;
	private boolean[] mIsSelectedAtom, mIsSelectedObject;
	private String mCustomAtomLabel,mWarningMessage,mAtomKeyStrokeSuggestion;
	private String[] mAtomText;
	private ExtendedDepictor mDepictor;
	private StereoMolecule mMol;        // molecule being modified directly by the drawing editor
	private Molecule mUndoMol;          // molecule in undo buffer
	private StereoMolecule[] mFragment;    // in case of MODE_MULTIPLE_FRAGMENTS contains valid stereo fragments
	// for internal and external read-only-access (reconstructed at any change)
	private DrawingObjectList mDrawingObjectList, mUndoDrawingObjectList;
	private AbstractDrawingObject mCurrentHiliteObject;
	private GenericPolygon mLassoRegion;
	private ArrayList mListeners;
	private IClipboardHandler mClipboardHandler;
	private final StringBuilder mAtomKeyStrokeBuffer;
	private final GenericUIHelper mUIHelper;
	private final GenericCanvas mCanvas;

	/**
	 * @param mol  an empty or valid stereo molecule
	 * @param mode 0 or a meaningful combination of the mode flags, e.g. MODE_REACTION | MODE_DRAWING_OBJECTS
	 */
	public GenericEditorArea(StereoMolecule mol, int mode, GenericUIHelper helper, GenericCanvas canvas) {
		mMol = mol;
		mMode = mode;
		mUIHelper = helper;
		mCanvas = canvas;

		mListeners = new ArrayList<>();

		mCurrentTool = GenericEditorToolbar.cToolStdBond;
		mCurrentHiliteAtom = -1;
		mCurrentHiliteBond = -1;
		mCurrentHiliteObject = null;
		mAtom1 = -1;
		mCustomAtomicNo = 6;
		mCustomAtomMass = 0;
		mCustomAtomValence = -1;
		mCustomAtomRadical = 0;
		mCustomAtomLabel = null;
		mAllowQueryFeatures = true;
		mAllowFragmentChangeOnPasteOrDrop = false;
		mPendingRequest = cRequestNone;
		mCurrentCursor = GenericCursorHelper.cPointerCursor;
		mAtomKeyStrokeBuffer = new StringBuilder();

		mAllowedPseudoAtoms = DEFAULT_ALLOWED_PSEUDO_ATOMS;

		mTextSizeFactor = 1.0;

		mUIScaling = HiDPIHelper.getUIScaleFactor();
		mMaxAVBL = HiDPIHelper.scale(AbstractDepictor.cOptAvBondLen);

		if ((mMode & (MODE_REACTION | MODE_MARKUSH_STRUCTURE)) != 0) {
			mMode |= (MODE_MULTIPLE_FRAGMENTS);
		}

		if ((mMode & (MODE_DRAWING_OBJECTS | MODE_REACTION)) != 0) {
			mDrawingObjectList = new DrawingObjectList();
		}

		mUpdateMode = UPDATE_SCALE_COORDS;

/*		dumpBytesOfGif("/chainCursor.gif");
		dumpBytesOfGif("/deleteCursor.gif");
		dumpBytesOfGif("/handCursor.gif");
		dumpBytesOfGif("/handPlusCursor.gif");
		dumpBytesOfGif("/fistCursor.gif");
		dumpBytesOfGif("/lassoCursor.gif");
		dumpBytesOfGif("/lassoPlusCursor.gif");
		dumpBytesOfGif("/rectCursor.gif");
		dumpBytesOfGif("/rectPlusCursor.gif");	*/
	}

	/**
	 * @return null or String[n][2] with pairs of reaction name and rxn-idcode
	 */
	public static String[][] getReactionQueryTemplates() {
		return sReactionQueryTemplates;
	}

	/**
	 * @param templates null or String[n][2] with pairs of reaction name and rxn-idcode
	 */
	public static void setReactionQueryTemplates(String[][] templates) {
		sReactionQueryTemplates = templates;
	}

	public GenericUIHelper getUIHelper() {
		return mUIHelper;
	}

	/**
	 * Call this after initialization to get clipboard support
	 *
	 * @param h
	 */
	public void setClipboardHandler(IClipboardHandler h) {
		mClipboardHandler = h;
	}

	private void update(int mode) {
		mUpdateMode = Math.max(mUpdateMode, mode);
		mCanvas.repaint();
	}

	public static void setReactionMapper(IReactionMapper mapper) {
		sMapper = mapper;
	}

	public void paintContent(GenericDrawContext context) {
		if (mWidth != mCanvas.getCanvasWidth() || mHeight != mCanvas.getCanvasHeight()) {
			mWidth = mCanvas.getCanvasWidth();
			mHeight = mCanvas.getCanvasHeight();
			if (mUpdateMode");
				}
			if (validity == KEY_IS_SUBSTITUENT) {
				context.setRGB(RGB_GRAY);
				context.drawString(x+context.getBounds(s).getWidth(), y, mAtomKeyStrokeSuggestion.substring(s.length()));
			}
		}

		context.setRGB(foreground);
		switch (mPendingRequest) {
			case cRequestNewBond:
				int x1, y1, x2, y2, xdiff, ydiff;
				x1 = (int)mX1;
				y1 = (int)mY1;
				if (mCurrentHiliteAtom == -1 || mCurrentHiliteAtom == mAtom1) {
					x2 = (int)mX2;
					y2 = (int)mY2;
				} else {
					x2 = (int)mMol.getAtomX(mCurrentHiliteAtom);
					y2 = (int)mMol.getAtomY(mCurrentHiliteAtom);
				}
				switch (mCurrentTool) {
					case GenericEditorToolbar.cToolStdBond:
						context.drawLine(x1, y1, x2, y2);
						break;
					case GenericEditorToolbar.cToolUpBond:
						xdiff = (y1 - y2) / 9;
						ydiff = (x2 - x1) / 9;
						GenericPolygon p = new GenericPolygon(3);
						p.addPoint( x1, y1);
						p.addPoint(x2 + xdiff, y2 + ydiff);
						p.addPoint(x2 - xdiff, y2 - ydiff);
						context.fillPolygon(p);
						break;
					case GenericEditorToolbar.cToolDownBond:
						int xx1, xx2, yy1, yy2;
						xdiff = x2 - x1;
						ydiff = y2 - y1;
						for (int i = 2; i<17; i += 2) {
							xx1 = x1 + i * xdiff / 17 - i * ydiff / 128;
							yy1 = y1 + i * ydiff / 17 + i * xdiff / 128;
							xx2 = x1 + i * xdiff / 17 + i * ydiff / 128;
							yy2 = y1 + i * ydiff / 17 - i * xdiff / 128;
							context.drawLine(xx1, yy1, xx2, yy2);
						}
						break;
				}
				break;
			case cRequestNewChain:
				if (mChainAtoms>0) {
					context.drawLine((int)mX1, (int)mY1, (int)mChainAtomX[0], (int)mChainAtomY[0]);
				}
				if (mChainAtoms>1) {
					for (int i = 1; i l) {
		mListeners.add(l);
	}

	protected void buttonPressed(int button) {
		switch (button) {
			case GenericEditorToolbar.cButtonClear:
				clearAll();
				return;
			case GenericEditorToolbar.cButtonCleanStructure:
				storeState();
				updateAndFireEvent(UPDATE_INVENT_COORDS);
				return;
			case GenericEditorToolbar.cButtonUndo:
				restoreState();
				updateAndFireEvent(UPDATE_CHECK_VIEW);
				return;
		}
	}

	public void clearAll() {
		if (mDrawingObjectList != null) {
			mDrawingObjectList.clear();
			update(UPDATE_REDRAW);
		}
		storeState();
		boolean isFragment = mMol.isFragment();
		mMol.clear();
		mMol.setFragment(isFragment);
		if (mUndoMol.getAllAtoms() != 0)
			updateAndFireEvent(UPDATE_REDRAW);
		else
			update(UPDATE_REDRAW);
	}

	public void toolChanged(int newTool) {
		if (mCurrentTool != newTool) {
			if (mCurrentTool == GenericEditorToolbar.cToolMapper
					|| newTool == GenericEditorToolbar.cToolMapper) {
				mCurrentTool = newTool;
				update(UPDATE_REDRAW);
			} else {
				mCurrentTool = newTool;
			}
		}
	}

	public void setCustomAtom(int atomicNo, int mass, int valence, int radical, String customLabel) {
		mCustomAtomicNo = atomicNo;
		mCustomAtomMass = mass;
		mCustomAtomValence = valence;
		mCustomAtomRadical = radical;
		mCustomAtomLabel = customLabel;
	}

	@Override
	public void eventHappened(GenericEvent e) {
		if (e instanceof GenericActionEvent)
			eventHappened((GenericActionEvent)e);
		else if (e instanceof GenericKeyEvent)
			eventHappened((GenericKeyEvent)e);
		else if (e instanceof GenericMouseEvent)
			eventHappened((GenericMouseEvent)e);
	}

	private void eventHappened(GenericActionEvent e) {
		String command = e.getMessage();
		if (command.equals(ITEM_COPY_STRUCTURE) || command.equals(ITEM_COPY_REACTION)) {
			copy();
		} else if (command.equals(ITEM_PASTE_REACTION)) {
			pasteReaction();
		} else if (command.startsWith(ITEM_USE_TEMPLATE)) {
			useTemplate(command.substring(ITEM_USE_TEMPLATE.length()));
		} else if (command.startsWith(ITEM_PASTE_STRUCTURE)) {
			pasteMolecule();
		} else if (command.equals(ITEM_LOAD_REACTION)) {
			openReaction();
		} else if (command.equals(ITEM_ADD_AUTO_MAPPING)) {
			autoMapReaction();
			updateAndFireEvent(Math.max(mUpdateMode, UPDATE_REDRAW));
		} else if (command.equals(ITEM_REMOVE_MAPPING)) {
			removeManualMapping();
		} else if (command.equals(ITEM_FLIP_HORIZONTALLY)) {
			flip(true);
		} else if (command.equals(ITEM_FLIP_VERTICALLY)) {
			flip(false);
		} else if (command.equals(ITEM_FLIP_ROTATE180)) {
			rotate180();
		} else if (command.equals(ITEM_SHOW_HELP)) {
			showHelpDialog();
		} else if (command.startsWith("atomColor")) {
			int index = command.indexOf(':');
			int atom = Integer.parseInt(command.substring(9, index));
			int color = Integer.parseInt(command.substring(index + 1));
			if (mMol.isSelectedAtom(atom)) {
				for (int i = 0; i=originalAtoms);

			updateAndFireEvent(UPDATE_CHECK_COORDS);
			}

		return true;
		}

	private void useTemplate(String rxncode) {
		storeState();
		setReaction(ReactionEncoder.decode(rxncode, true));
		}

	private boolean atomCoordinatesCollide(StereoMolecule mol, double tolerance) {
		int count = 0;
		tolerance *= tolerance;
		for (int i=0; i {
			try {
				Thread.sleep(WARNING_MILLIS);
			} catch (InterruptedException ie) {
			}
			mWarningMessage = null;
			mCanvas.repaint();
		}).start();
	}

	private void eventHappened(GenericMouseEvent e) {
		if (e.getWhat() == GenericMouseEvent.MOUSE_PRESSED) {
			if (mCurrentHiliteAtom != -1 && mAtomKeyStrokeBuffer.length() != 0)
				expandAtomKeyStrokes(mAtomKeyStrokeBuffer.toString());

			mAtomKeyStrokeBuffer.setLength(0);

			if (e.isPopupTrigger()) {
				handlePopupTrigger(e.getX(), e.getY());
				return;
			}

			if (e.getButton() == 1) {
				if (e.getClickCount() == 2) {
					return;
				}

				mMouseIsDown = true;
				updateCursor();
				mousePressedButton1(e);
			}
		}

		if (e.getWhat() == GenericMouseEvent.MOUSE_RELEASED) {
			if (e.isPopupTrigger()) {
				handlePopupTrigger(e.getX(), e.getY());
				return;
			}

			if (e.getButton() == 1) {
				if (e.getClickCount() == 2) {
					handleDoubleClick(e.getX(), e.getY());
					return;
				}

				mMouseIsDown = false;
				updateCursor();
				mouseReleasedButton1();
			}
		}

		if (e.getWhat() == GenericMouseEvent.MOUSE_ENTERED) {
			mUIHelper.grabFocus();
			updateCursor();
		}

		if (e.getWhat() == GenericMouseEvent.MOUSE_MOVED) {
			mMouseIsDown = false;
			int x = e.getX();
			int y = e.getY();

			if (trackHiliting(x, y, false)) {
				mCanvas.repaint();
			}

			updateCursor();
		}

		if (e.getWhat() == GenericMouseEvent.MOUSE_DRAGGED) {
			mMouseIsDown = true;
			mX2 = e.getX();
			mY2 = e.getY();

			boolean repaintNeeded = trackHiliting(mX2, mY2, true);

			switch (mPendingRequest) {
				case cRequestNewChain:
					double lastX, lastY;
					if (mChainAtoms>0) {
						lastX = mChainAtomX[mChainAtoms - 1];
						lastY = mChainAtomY[mChainAtoms - 1];
					} else {
						lastX = 0;
						lastY = 0;
					}
					double avbl = getScaledAVBL();
					double s0 = (int)avbl;
					double s1 = (int)(0.866 * avbl);
					double s2 = (int)(0.5 * avbl);
					double dx = mX2 - mX1;
					double dy = mY2 - mY1;
					if (Math.abs(dy)>Math.abs(dx)) {
						mChainAtoms = (int)(2 * Math.abs(dy) / (s0 + s2));
						if (Math.abs(dy) % (s0 + s2)>s0) {
							mChainAtoms++;
						}
						mChainAtomX = new double[mChainAtoms];
						mChainAtomY = new double[mChainAtoms];
						if (mX20) {
						mChainAtom = new int[mChainAtoms];
						for (int i = 0; i='4' && ch<='7') {
					if (mMol.addRingToBond(mCurrentHiliteBond, ch - '0', false, getScaledAVBL()))
						updateAndFireEvent(UPDATE_CHECK_COORDS);
				} else if (ch == 'a' || ch == 'b') {    // ChemDraw uses 'a', we use 'b' since a long time
					if (mMol.addRingToBond(mCurrentHiliteBond, 6, true, getScaledAVBL()))
						updateAndFireEvent(UPDATE_CHECK_COORDS);
				} else {
					boolean bondChanged =
							(ch == '0') ? changeHighlightedBond(Molecule.cBondTypeMetalLigand)
						  : (ch == '1') ? changeHighlightedBond(Molecule.cBondTypeSingle)
						  : (ch == '2') ? changeHighlightedBond(Molecule.cBondTypeDouble)
						  : (ch == '3') ? changeHighlightedBond(Molecule.cBondTypeTriple)
						  : (ch == 'u') ? changeHighlightedBond(Molecule.cBondTypeUp)
						  : (ch == 'd') ? changeHighlightedBond(Molecule.cBondTypeDown)
						  : (ch == 'c') ? changeHighlightedBond(Molecule.cBondTypeCross)
						  : (ch == 'm') ? changeHighlightedBond(Molecule.cBondTypeMetalLigand)
						  : false;
					if (bondChanged)
						updateAndFireEvent(UPDATE_REDRAW);
				}
			} else if (mCurrentHiliteAtom != -1) {
				int ch = e.getKey();
				boolean isFirst = (mAtomKeyStrokeBuffer.length() == 0);
				if (isFirst)
					mFirstAtomKey = ch;
				else {
					if (mFirstAtomKey == 'l') { // if we don't want first 'l' to be a chlorine
						mAtomKeyStrokeBuffer.setLength(0);
						mAtomKeyStrokeBuffer.append('L');
						}
					mFirstAtomKey = -1;
					}

				if (isFirst && ch == 'l') { // if no chars are following, we interpret 'l' as chlorine analog to ChemDraw
					mAtomKeyStrokeBuffer.append("Cl");
					update(UPDATE_REDRAW);
				} else if (isFirst && (ch == '+' || ch == '-')) {
					storeState();
					if (mMol.changeAtomCharge(mCurrentHiliteAtom, ch == '+'))
						updateAndFireEvent(UPDATE_CHECK_COORDS);
				} else if (isFirst && ch == '.') {
					storeState();
					int newRadical = (mMol.getAtomRadical(mCurrentHiliteAtom) == Molecule.cAtomRadicalStateD) ?
							0 : Molecule.cAtomRadicalStateD;
					mMol.setAtomRadical(mCurrentHiliteAtom, newRadical);
					updateAndFireEvent(UPDATE_CHECK_COORDS);
				} else if (isFirst && ch == ':') {
					storeState();
					int newRadical = (mMol.getAtomRadical(mCurrentHiliteAtom) == Molecule.cAtomRadicalStateT) ? Molecule.cAtomRadicalStateS
							: (mMol.getAtomRadical(mCurrentHiliteAtom) == Molecule.cAtomRadicalStateS) ? 0 : Molecule.cAtomRadicalStateT;
					mMol.setAtomRadical(mCurrentHiliteAtom, newRadical);
					updateAndFireEvent(UPDATE_CHECK_COORDS);
				} else if (isFirst && ch == 'q' && mMol.isFragment()) {
					showAtomQFDialog(mCurrentHiliteAtom);

				} else if (isFirst && mMol.isFragment() && (ch == 'x' || ch == 'X')) {
					int[] list = { 9, 17, 35, 53 };
					mMol.setAtomList(mCurrentHiliteAtom, list);
					updateAndFireEvent(UPDATE_CHECK_COORDS);
				} else if (isFirst && ch == '?') {
					storeState();
					if (mMol.changeAtom(mCurrentHiliteAtom, 0, 0, -1, 0)) {
						updateAndFireEvent(UPDATE_CHECK_COORDS);
					}
				} else if (isFirst && ch>48 && ch<=57) {
					if (mMol.getFreeValence(mCurrentHiliteAtom)>0) {
						storeState();
						int chainAtoms = ch - 47;
						int atom1 = mCurrentHiliteAtom;
						int hydrogenCount = mMol.getAllAtoms() - mMol.getAtoms();
						for (int i = 1; i=65 && ch<=90)
						|| (ch>=97 && ch<=122)
						|| (ch>=48 && ch<=57)
						|| (ch == '-')) {
					mAtomKeyStrokeBuffer.append((char)ch);
					update(UPDATE_REDRAW);
				} else if (ch == '\n' || ch == '\r') {
					expandAtomKeyStrokes(mAtomKeyStrokeBuffer.toString());
				}
			} else {
				if ((mMode & (MODE_REACTION | MODE_MARKUSH_STRUCTURE | MODE_MULTIPLE_FRAGMENTS)) == 0) {
					int ch = e.getKey();
					if (ch == 'h')
						flip(true);
					if (ch == 'v')
						flip(false);
				}
			}
		}
		if (e.getWhat() == GenericKeyEvent.KEY_RELEASED) {
			if (e.getKey() == GenericKeyEvent.KEY_SHIFT) {
				mShiftIsDown = false;
				updateCursor();
			}
			if (e.getKey() == GenericKeyEvent.KEY_ALT) {
				mAltIsDown = false;
				updateCursor();
			}
			if (e.getKey() == GenericKeyEvent.KEY_CTRL) {
				updateCursor();
			}
		}
	}

	private boolean changeHighlightedBond(int type) {
		storeState();
		return mMol.changeBond(mCurrentHiliteBond, type);
		}

	private void handlePopupTrigger(int x, int y) {
		GenericPopupMenu popup = null;

		if (mClipboardHandler != null) {
			popup = mUIHelper.createPopupMenu(this);

			String item1 = analyseCopy(false) ? ITEM_COPY_REACTION : ITEM_COPY_STRUCTURE;
			popup.addItem(item1, null, mMol.getAllAtoms() != 0);

			String item3 = (StructureNameResolver.getInstance() == null) ? ITEM_PASTE_STRUCTURE : ITEM_PASTE_WITH_NAME;
			popup.addItem(item3, null, true);

			if ((mMode & MODE_REACTION) != 0) {
				popup.addItem(ITEM_PASTE_REACTION, null, true);
				popup.addSeparator();
				if (sReactionQueryTemplates != null && mMol.isFragment()) {
					boolean isSubMenu = false;
					for (String[] template: sReactionQueryTemplates) {
						if (TEMPLATE_SECTION_KEY.equals(template[0])) {
							if (isSubMenu)
								popup.endSubMenu();

							popup.startSubMenu("Use " + template[1] + " Template");
							isSubMenu = true;
							continue;
							}

						if (!isSubMenu) {
							popup.startSubMenu("Use Template");
							isSubMenu = true;
							}

						popup.addItem(template[0], ITEM_USE_TEMPLATE + template[1], true);
						}
					popup.endSubMenu();
					}
				}

			if ((mMode & MODE_REACTION) != 0)
				popup.addItem(ITEM_LOAD_REACTION, null, true);
			}

		if ((mMode & MODE_REACTION) != 0 && mCurrentTool == GenericEditorToolbar.cToolMapper) {
			if (popup == null)
				popup = mUIHelper.createPopupMenu(this);
			else
				popup.addSeparator();

			popup.addItem(ITEM_ADD_AUTO_MAPPING, null, true);
			popup.addItem(ITEM_REMOVE_MAPPING, null, true);
			}

		if (mCurrentTool == GenericEditorToolbar.cToolZoom) {
			if (popup == null)
				popup = mUIHelper.createPopupMenu(this);
			else
				popup.addSeparator();

			popup.addItem(ITEM_FLIP_HORIZONTALLY, null, true);
			popup.addItem(ITEM_FLIP_VERTICALLY, null, true);
			popup.addItem(ITEM_FLIP_ROTATE180, null, true);
			}

		if (mAtomColorSupported && mCurrentHiliteAtom != -1) {
			int atomColor = mMol.getAtomColor(mCurrentHiliteAtom);
			if (popup == null)
				popup = mUIHelper.createPopupMenu(this);
			else
				popup.addSeparator();

			popup.startSubMenu("Set Atom Color");
			popup.addRadioButtonItem("	  ", "atomColor" + mCurrentHiliteAtom + ":" + Molecule.cAtomColorNone, RGB_BLACK, atomColor == Molecule.cAtomColorNone);
			popup.addRadioButtonItem("	  ", "atomColor" + mCurrentHiliteAtom + ":" + Molecule.cAtomColorBlue, AbstractDepictor.COLOR_BLUE, atomColor == Molecule.cAtomColorBlue);
			popup.addRadioButtonItem("	  ", "atomColor" + mCurrentHiliteAtom + ":" + Molecule.cAtomColorDarkRed, AbstractDepictor.COLOR_DARK_RED, atomColor == Molecule.cAtomColorDarkRed);
			popup.addRadioButtonItem("	  ", "atomColor" + mCurrentHiliteAtom + ":" + Molecule.cAtomColorRed, AbstractDepictor.COLOR_RED, atomColor == Molecule.cAtomColorRed);
			popup.addRadioButtonItem("	  ", "atomColor" + mCurrentHiliteAtom + ":" + Molecule.cAtomColorDarkGreen, AbstractDepictor.COLOR_DARK_GREEN, atomColor == Molecule.cAtomColorDarkGreen);
			popup.addRadioButtonItem("	  ", "atomColor" + mCurrentHiliteAtom + ":" + Molecule.cAtomColorGreen, AbstractDepictor.COLOR_GREEN, atomColor == Molecule.cAtomColorGreen);
			popup.addRadioButtonItem("	  ", "atomColor" + mCurrentHiliteAtom + ":" + Molecule.cAtomColorMagenta, AbstractDepictor.COLOR_MAGENTA, atomColor == Molecule.cAtomColorMagenta);
			popup.addRadioButtonItem("	  ", "atomColor" + mCurrentHiliteAtom + ":" + Molecule.cAtomColorOrange, AbstractDepictor.COLOR_ORANGE, atomColor == Molecule.cAtomColorOrange);
			popup.endSubMenu();
			}

		if (popup == null)
			popup = mUIHelper.createPopupMenu(this);
		else
			popup.addSeparator();
		popup.addItem(ITEM_SHOW_HELP, null, true);

		if (System.getProperty("development") != null) {
			if (popup == null)
				popup = mUIHelper.createPopupMenu(this);
			else
				popup.addSeparator();

			popup.addItem(ITEM_SHOW_ATOM_BOND_NUMBERS, null, true);
			popup.addItem(ITEM_SHOW_SYMMETRY, null, true);
			popup.addItem(ITEM_SHOW_NORMAL, null, true);
			}

		if (popup != null)
			popup.show(x, y);
		}

	private void handleDoubleClick(int x, int y) {
		int atom = mMol.findAtom(x, y);
		int bond = mMol.findBond(x, y);

		if (mCurrentTool == GenericEditorToolbar.cToolLassoPointer) {
			if (mMol.isFragment()) {
				if (atom != -1) {
					showAtomQFDialog(atom);
				} else if (bond != -1) {
					showBondQFDialog(bond);
				} else if (mCurrentHiliteObject != null) {
					if (!mShiftIsDown) {
						for (int i = 0; imMol.getAtomX(i)) {
							minX = mMol.getAtomX(i);
						}
						if (maxXminX) {
					double centerX = (maxX + minX) / 2;
					for (int i = 0; i move atom (and if atom is selected then all selected stuff)
				mAtom1 = mMol.findAtom(mX1, mY1);
				if (mAtom1 != -1) {
					mAtom2 = -1;
					mX1 = mMol.getAtomX(mAtom1);
					mY1 = mMol.getAtomY(mAtom1);
					if (mMol.isSelectedAtom(mAtom1)) {
						mPendingRequest = mShiftIsDown ? cRequestCopySelected : cRequestMoveSelected;
					} else {
						mPendingRequest = cRequestMoveSingle;
					}
				}

				// if bond was hit -> move bond (and if atom is selected then all selected stuff)
				if (mPendingRequest == cRequestNone) {
					int bondClicked = mMol.findBond(mX1, mY1);
					if (bondClicked != -1) {
						mAtom1 = mMol.getBondAtom(0, bondClicked);
						mAtom2 = mMol.getBondAtom(1, bondClicked);
						if (mMol.isSelectedBond(bondClicked)) {
							mPendingRequest = mShiftIsDown ? cRequestCopySelected : cRequestMoveSelected;
						} else {
							mPendingRequest = cRequestMoveSingle;
						}
					}
				}

				// if object was hit -> move object (and if atom is selected then all selected stuff)
				if (mPendingRequest == cRequestNone) {
					if (mCurrentHiliteObject != null) {
						if (mCurrentHiliteObject.isSelected()) {
							mPendingRequest = mShiftIsDown ? cRequestCopySelected : cRequestMoveSelected;
						} else {
							mPendingRequest = (mShiftIsDown && !(mCurrentHiliteObject instanceof ReactionArrow)) ?
									cRequestCopyObject : cRequestMoveObject;
						}
					}
				}

				if (mPendingRequest != cRequestNone) {
					mX = new double[mMol.getAllAtoms()];
					mY = new double[mMol.getAllAtoms()];
					for (int i = 0; i0) {
					if (mAtom1 == -1) {
						mAtom1 = mMol.addAtom(mX1, mY1);
					}

					if (mChainAtom[0] == -1) {
						mChainAtom[0] = mMol.addAtom(mChainAtomX[0],
								mChainAtomY[0]);
					}

					if (mChainAtom[0] != -1) {
						mMol.addBond(mAtom1, mChainAtom[0]);
					}
				}

				if (mChainAtoms>1) {
					for (int i = 1; i %d (%d)\n", mAtom1, mAtom2, atom2);
				int mapNoAtom1 = mMol.getAtomMapNo(mAtom1);
				if (atom2 == -1) {
					storeState();
					if (mapNoAtom1 != 0) {
						mapNoChanged = true;
						for (int atom = 0; atom oldToNewMapNo = new TreeMap<>();
		int nextMapNo = 1;

		final int fakeAtomMassBase = 512;

		// Mark the manually mapped atoms such that the mapper uses them first priority and
		// to be able to re-assign them later as manually mapped.
		int[] fragmentAtom = new int[mFragment.length];
		for (int atom = 0; atomfakeAtomMassBase);
				if (hasFakeAtomMass) {
					// rescue new mapNo
					int newMapNo = mFragment[fragment].getAtomMass(fragmentAtom[fragment]) - fakeAtomMassBase;

					// repair fake atom mass
					mFragment[fragment].setAtomMass(fragmentAtom[fragment], mMol.getAtomMass(atom));

					mMol.setAtomMapNo(atom, newMapNo, false);
					mFragment[fragment].setAtomMapNo(fragmentAtom[fragment], newMapNo, false);
				} else {
					// take generated mapNo from reaction
					int generatedMapNo = mFragment[fragment].getAtomMapNo(fragmentAtom[fragment]);

					Integer newMapNo = 0;
					if (generatedMapNo != 0) {
						newMapNo = oldToNewMapNo.get(generatedMapNo);
						if (newMapNo == null)
							oldToNewMapNo.put(generatedMapNo, newMapNo = nextMapNo++);
					}

					mMol.setAtomMapNo(atom, newMapNo, true);
					mFragment[fragment].setAtomMapNo(fragmentAtom[fragment], newMapNo, true);
				}
				fragmentAtom[fragment]++;
			}
		} else {
			// restore original atom masses in fragments and copy molecule's mapping number into fragments
			fragmentAtom = new int[mFragment.length];
			for (int atom = 0; atom l = m.map(rxn);
//		if (l != null && l.size() > 0) {
//			AStarReactionMapper.SlimMapping sm = l.get(0);
////			int[] mps = sm.getMapping();
////			for (int i = 0; i < mps.length; i++) {
////				System.out.printf("Maps %d -> %d\n", i, mps[i]);
////			}
//			m.activateMapping(sm);
////			for (int i = 0; i < mFragment.length; i++) {
////				StereoMolecule mol = mFragment[i];
////				for (int a = 0; a < mol.getAllAtoms(); a++) {
////					System.out.printf("T Map %d = %d\n", a, mol.getAtomMapNo(a));
////				}
////			}
//
//			int[] fragmentAtom = new int[mFragment.length];
//			for (int atom = 0; atom < mMol.getAllAtoms(); atom++) {
//				int fragment = mFragmentNo[atom];
//				if (mMol.getAtomMapNo(atom) == 0)
//					mMol.setAtomMapNo(atom, mFragment[fragment].getAtomMapNo(fragmentAtom[fragment]), true);
//				fragmentAtom[fragment]++;
//			}
//		}
	}

	/**
	 * Checks whether this bond is a stereo bond and whether it refers to a
	 * stereo center or BINAP bond, making it eligible for ESR information.
	 *
	 * @param stereoBond the up/down stereo bond
	 * @return
	 */
	private boolean qualifiesForESR(int stereoBond){
		return mMol.isStereoBond(stereoBond) && (getESRAtom(stereoBond) != -1 || getESRBond(stereoBond) != -1);
	}

	/**
	 * Locates the stereo center with parity 1 or 2 that is defined by the stereo bond.
	 *
	 * @param stereoBond
	 * @return stereo center atom or -1 if no stereo center found
	 */
	private int getESRAtom(int stereoBond){
		int atom = mMol.getBondAtom(0, stereoBond);
		if (mMol.getAtomParity(atom) != Molecule.cAtomParityNone) {
			return (mMol.isAtomParityPseudo(atom)
					|| (mMol.getAtomParity(atom) != Molecule.cAtomParity1
					&& mMol.getAtomParity(atom) != Molecule.cAtomParity2)) ? -1 : atom;
		}
		if (mMol.getAtomPi(atom) == 1) {
			for (int i = 0; idistance) {
				minDistance = distance;
				fragment = mFragmentNo[atom];
			}
		}

		return fragment;
	}

	/**
	 * requires cHelperNeighbours
	 *
	 * @param atom
	 */
	private void suggestNewX2AndY2(int atom)
	{
		double newAngle = Math.PI * 2 / 3;
		if (atom != -1) {
			double angle[] = new double[MAX_CONNATOMS + 1];
			for (int i = 0; i0; i--) {    // bubble sort
					for (int j = 0; jangle[j + 1]) {
							double temp = angle[j];
							angle[j] = angle[j + 1];
							angle[j + 1] = temp;
						}
					}
				}
				angle[mMol.getAllConnAtomsPlusMetalBonds(atom)] = angle[0] + Math.PI * 2;

				int largestNo = 0;
				double largestDiff = 0.0;
				for (int i = 0; i=mMol.getAtoms()) {
				theAtom = -1;
			}
		}

		if (theBond == -1
				&& theAtom == -1
				&& mCurrentTool != GenericEditorToolbar.cToolChain
				&& mCurrentTool != GenericEditorToolbar.cToolMapper
				&& mCurrentTool != GenericEditorToolbar.cToolUnknownParity
				&& mCurrentTool != GenericEditorToolbar.cToolPosCharge
				&& mCurrentTool != GenericEditorToolbar.cToolNegCharge
				&& mCurrentTool != GenericEditorToolbar.cToolAtomH
				&& mCurrentTool != GenericEditorToolbar.cToolAtomC
				&& mCurrentTool != GenericEditorToolbar.cToolAtomN
				&& mCurrentTool != GenericEditorToolbar.cToolAtomO
				&& mCurrentTool != GenericEditorToolbar.cToolAtomSi
				&& mCurrentTool != GenericEditorToolbar.cToolAtomP
				&& mCurrentTool != GenericEditorToolbar.cToolAtomS
				&& mCurrentTool != GenericEditorToolbar.cToolAtomF
				&& mCurrentTool != GenericEditorToolbar.cToolAtomCl
				&& mCurrentTool != GenericEditorToolbar.cToolAtomBr
				&& mCurrentTool != GenericEditorToolbar.cToolAtomI
				&& mCurrentTool != GenericEditorToolbar.cToolCustomAtom) {
			theBond = mMol.findBond(x, y);
		}

		if (theBond != -1
				&& (mCurrentTool == GenericEditorToolbar.cToolESRAbs
				|| mCurrentTool == GenericEditorToolbar.cToolESRAnd
				|| mCurrentTool == GenericEditorToolbar.cToolESROr)
				&& !qualifiesForESR(theBond)) {
			theBond = -1;
		}

		// don't change object hiliting while dragging
		AbstractDrawingObject hiliteObject = mCurrentHiliteObject;
		if (!isDragging && mDrawingObjectList != null) {
			hiliteObject = null;
			if (theAtom == -1 && theBond == -1
					&& (mCurrentTool == GenericEditorToolbar.cToolLassoPointer
					|| mCurrentTool == GenericEditorToolbar.cToolDelete
					|| mCurrentTool == GenericEditorToolbar.cToolText)) {
				for (AbstractDrawingObject theObject : mDrawingObjectList) {
					if (mCurrentTool == GenericEditorToolbar.cToolLassoPointer
							|| (mCurrentTool == GenericEditorToolbar.cToolDelete && !(theObject instanceof ReactionArrow))
							|| (mCurrentTool == GenericEditorToolbar.cToolText && theObject instanceof TextDrawingObject)) {
						if (theObject.checkHiliting(x, y)) {
							hiliteObject = theObject;
							if (mCurrentHiliteObject != null && mCurrentHiliteObject != theObject) {
								mCurrentHiliteObject.clearHiliting();
							}
							break;
						}
					}
				}
			}
		}

		boolean repaintNeeded = (mCurrentHiliteAtom != theAtom
				|| mCurrentHiliteBond != theBond
				|| mCurrentHiliteObject != hiliteObject
				|| hiliteObject != null);

		if (mCurrentHiliteAtom != theAtom) {
			if (mCurrentHiliteAtom != -1 && mAtomKeyStrokeBuffer.length() != 0)
				expandAtomKeyStrokes(mAtomKeyStrokeBuffer.toString());

			mCurrentHiliteAtom = theAtom;
			mAtomKeyStrokeBuffer.setLength(0);
			fireEventLater(new EditorEvent(this, EditorEvent.WHAT_HILITE_ATOM_CHANGED, true));
		}
		if (mCurrentHiliteBond != theBond) {
			mCurrentHiliteBond = theBond;
			fireEventLater(new EditorEvent(this, EditorEvent.WHAT_HILITE_BOND_CHANGED, true));
		}
		mCurrentHiliteObject = hiliteObject;

		return repaintNeeded;
	}

	private int getAtomKeyStrokeValidity(String s){
		if (Molecule.getAtomicNoFromLabel(s, mAllowedPseudoAtoms) != 0)
			return KEY_IS_ATOM_LABEL;
		mAtomKeyStrokeSuggestion = NamedSubstituents.identify(s);
		if (mAtomKeyStrokeSuggestion == null)
			return isValidAtomKeyStrokeStart(s) ? KEY_IS_VALID_START : KEY_IS_INVALID;
		if (mAtomKeyStrokeSuggestion.isEmpty())
			return KEY_IS_VALID_START;
		else
			return KEY_IS_SUBSTITUENT;
	}

	/**
	 * @param s
	 * @return true if adding one or more chars may still create a valid key stroke sequence
	 */
	private boolean isValidAtomKeyStrokeStart(String s){
		if (s.length()<3)
			for (int i=1; i=0; i--) {
				AbstractDrawingObject object = mDrawingObjectList.get(i);
				if (object.isSelected() && !(object instanceof ReactionArrow)) {
					mDrawingObjectList.add(object.clone());
				}
			}
		}
	}

	private void fireEventLater(EditorEvent e) {
		final int what = e.getWhat();
		if ((what & mEventsScheduled) == 0) {
			mUIHelper.runLater(() -> {
				mEventsScheduled &= ~what;
				for (GenericEventListener l : mListeners)
					l.eventHappened(e);
			} );
			mEventsScheduled |= what;
		}
	}

	/**
	 * Redraws the molecule(s) or the reaction after scaling coordinates.
	 * Then analyses fragment membership and recreate individual molecules, reaction, or markush structure
	 * Then, fires molecule change events with userChange=false, i.e. external change.
	 */
	public void moleculeChanged() {
		update(UPDATE_SCALE_COORDS);
		fireEventLater(new EditorEvent(this, EditorEvent.WHAT_MOLECULE_CHANGED, false));
	}

	/**
	 * Redraws the molecule(s) or the reaction after scaling coordinates.
	 * Then analyses fragment membership and recreate individual molecules, reaction, or markush structure
	 * Then, fires molecule change events with userChange=false, i.e. external change.
	 * @param updateMode
	 */
	private void updateAndFireEvent(int updateMode) {
		update(updateMode);
		fireEventLater(new EditorEvent(this, EditorEvent.WHAT_MOLECULE_CHANGED, true));
	}

	public StereoMolecule getMolecule()
	{
		return mMol;
	}

	public void setMolecule (StereoMolecule theMolecule){
		if (mMol == theMolecule) {
			return;
		}
		storeState();
		mMol = theMolecule;
		mMode = 0;
		mDrawingObjectList = null;
		moleculeChanged();
	}

	public StereoMolecule[] getFragments() {
		return mFragment;
	}

	public void setFragments(StereoMolecule[]fragment) {
		mMol.clear();
		mFragment = fragment;
		for (int i = 0; ifragment2) {
							mergeFragments[fragment1][fragment2] = true;
						} else {
							mergeFragments[fragment2][fragment1] = true;
						}
					}
				}
			}
		}

		int[] newFragmentIndex = new int[fragments];
		for (int fragment = 0; fragmentmaxIndex) {
								newFragmentIndex[k]--;
							}
						}
					}
				}
			}
		}

		for (int atom = 0; atom {
			if ((mMode & (MODE_REACTION | MODE_MARKUSH_STRUCTURE)) != 0) {
				if (fragmentDescriptor1[1] != fragmentDescriptor2[1]) {
					return (fragmentDescriptor1[1] == 0) ? -1 : 1;
				}
			}

			return Double.compare(cog[fragmentDescriptor1[0]].x + cog[fragmentDescriptor1[0]].y,
								  cog[fragmentDescriptor2[0]].x + cog[fragmentDescriptor2[0]].y);
		});

		int[] newFragmentIndex = new int[fragments];
		for (int fragment = 0; fragment1 ? new Point2D.Double(sumx / atoms, sumy / atoms) : null;
	}

	private void rotate180() {
		boolean selectedOnly = false;
		for (int atom = 0; atom




© 2015 - 2024 Weber Informatics LLC | Privacy Policy