''' My name is Bond. JAMES Bond. ''' from copy import deepcopy, copy from math import atan, tan, pi import chemfig_mappings as cfm from common import debug # bond stereo properties and valences from indigo import Indigo # Indigo.UP : stereo "up" bond # Indigo.DOWN : stereo "down" bond # Indigo.EITHER : stereo "either" bond # Indigo.CIS : "Cis" double bond # Indigo.TRANS : "Trans" double bond # zero : not a stereo bond of any kind # bond_type: 1,2,3,4 for single, double, triple, aromatic # map indigo's bond specifiers to m2cf custom ones. bond_mapping = { 1 : 'single', 2 : 'double', 3 : 'triple', 4 : 'aromatic', # not really used Indigo.UP : 'upto', Indigo.DOWN : 'downto', Indigo.EITHER : 'either' } def compare_positions(x1, y1, x2, y2): ''' calculate distance and angle between the coordinates of two atoms. is the same as atan2 from math? ''' xdiff = x2 - x1 ydiff = y2 - y1 length = (xdiff**2 + ydiff**2) ** 0.5 if xdiff == 0: if ydiff < 0: angle = 270 else: angle = 90 else: raw_angle = atan(abs(ydiff / xdiff)) * 180/pi if ydiff >= 0: if xdiff > 0: angle = raw_angle else: angle = 180 - raw_angle else: if xdiff > 0: angle = -raw_angle else: angle = 180 + raw_angle return length, angle class Bond(object): ''' helper class for molecule.Molecule a bond connects two atoms and computes its angle and length from those. It knows how to render itself to chemfig. Bonds can be hooks. On instantiation, the bond is not part of a hierarchy yet, so we can assign a parent. This has to occur later. So, initially we just know the start and the end atom. ''' is_last = False # flag for bond that is the last descendant of # the exit bond - needed in rare case in # cases for bond formatting. to_phantom = False # flag for bonds that should render their end atoms # as phantoms: Ring closures and cross bonds is_trunk = False # by default, bonds are not part of the trunk parent = None # will be assigned when bonds are added to the tree. clockwise = 0 # only significant in double bonds in rings that are # not drawn with aromatic circles def __init__(self, options, start_atom, end_atom, bond_type=None, stereo=0): self.options = options self.start_atom = start_atom self.end_atom = end_atom # special styles that get rendered via tikz self.tikz_styles = set() self.tikz_values = {} if stereo in (Indigo.UP, Indigo.DOWN): if self.options['flip_vertical'] != self.options['flip_horizontal']: stereo = Indigo.UP + Indigo.DOWN - stereo if stereo in (Indigo.UP, Indigo.DOWN, Indigo.EITHER): # implies single bond self.bond_type = bond_mapping[stereo] else: # no interesting stereo property - simply go with bond valence # or else keep passed-in string specifier self.bond_type = bond_mapping.get(bond_type, bond_type) # bonds now are also the nodes in the molecule tree. These two # attributes must be set later, when the tree is created. self.descendants = [] self.length, angle = self.bond_dimensions() # length is adjusted and rounded later, after all is parsed # apply molecule rotation angle += self.options['rotate'] self.angle = angle # define marker marker = self.options.get('markers', None) if marker is not None: ids = [self.start_atom.idx +1, self.end_atom.idx +1] ids.sort() self.marker = '%s%s-%s' % (marker, ids[0], ids[1]) else: self.marker = "" def bond_dimensions(self): ''' determine bond angle and distance between two atoms ''' return compare_positions( self.start_atom.x, self.start_atom.y, self.end_atom.x, self.end_atom.y ) def is_clockwise(self, center_x, center_y): ''' determine whether the bond will be drawn clockwise or counterclockwise relative to center ''' if self.clockwise: # assign only once return center_dist, center_angle = compare_positions( self.end_atom.x, self.end_atom.y, center_x, center_y) # bond is already rotated at this stage, so we need to # rotate the ring center also center_angle += self.options['rotate'] center_kink = (center_angle - self.angle) % 360 if center_kink > 180: self.clockwise = 1 else: self.clockwise = -1 def clone(self): ''' deepcopy but keep original atoms ''' c = copy(self) # c.start_atom, c.and_atom = self.start_atom, self.end_atom return c def invert(self): ''' draw a bond backwards. ''' c = deepcopy(self) c.start_atom, c.end_atom = self.end_atom, self.start_atom c.angle = (c.angle + 180) % 360 if self.bond_type == 'upto': c.bond_type = 'upfrom' elif self.bond_type == 'downto': c.bond_type = 'downfrom' return c def set_link(self): ''' make this bond an invisible link. this also cancels any other tikz styles, and removes the marker. ''' self.bond_type = "link" self.tikz_styles = set() self.tikz_values = {} self.marker = "" def set_cross(self, last=False): ''' draw this bond crossing over another. ''' # debug(self.start_atom.idx, self.end_atom.idx, self.tikz_styles, self.tikz_values) start_angles = self.upstream_angles() end_angles = self.downstream_angles() start_angle = min(start_angles.values()) start = max(10, self.cotan100(start_angle)) end_angle = min(end_angles.values()) end = max(10, self.cotan100(end_angle)) self.tikz_styles.add("cross") self.tikz_values.update( dict(bgstart=start, bgend=end)) self.is_last = last def _adjoining_angles(self, atom, inversion_angle=0): ''' determine the narrowest upstream or downstream angles on the left and the right. ''' raw_angles = atom.bond_angles[:] raw_angles = [int(round(a)) % 360 for a in raw_angles] reference_angle = int(round(self.angle - inversion_angle)) % 360 # debug(atom.idx, inversion_angle, reference_angle, raw_angles) raw_angles.remove(reference_angle) if not raw_angles: # no other bonds attach to start atom return None, None angles = [(a - reference_angle) % 360 for a in raw_angles] angles.sort() return int(round(angles[0])), int(round(angles[-1] )) def upstream_angles(self): ''' determine the narrowest upstream left and upstream right angle. ''' first, last = self._adjoining_angles(self.start_atom) # for the right angle, convert outer to inner if last is not None: last = 360-last return dict(left=first, right=last) def downstream_angles(self): ''' determine the narrowest downstream left and downstream right angle. ''' first, last = self._adjoining_angles(self.end_atom, 180) if last is not None: # for the left angle, convert outer to inner last = 360-last return dict(left=last, right=first) def angle_penalty(self, angle): ''' scoring function used in picking sides for second stroke of double bond ''' if angle is None: return 0 return (angle - 105) ** 2 def cotan100(self,angle): ''' 100 times cotan of angle, rounded ''' _tan = tan(angle * pi/180) return int(round(100/_tan)) def shorten_stroke(self, same_angle, other_angle): ''' determine by how much to shorten the second stroke of a double bond. ''' if same_angle is None: # other_angle will be, too; don't shorten. return 0 if same_angle <= 180: angle = 0.5 * same_angle else: if 210 < same_angle < 270: angle = same_angle - 180 elif 210 < other_angle < 270: angle = other_angle - 180 else: angle = 90 return self.cotan100(angle) def fancy_double(self): ''' work out the parameters for rendering a fancy double bond. we need to decide whether the second stroke should be to the left or the right of the main stroke, and also by how much to shorten the start and and of the second stroke. ''' # if we are in a ring, the second stroke should be inside. start_angles = self.upstream_angles() end_angles = self.downstream_angles() # outside rings and if the double bond connects to explicit atoms, # plain symmetric double bonds tend to look better. if not self.clockwise and \ (self.start_atom.explicit or self.end_atom.explicit): if self.start_atom.explicit and self.end_atom.explicit: return None elif self.start_atom.explicit and \ (end_angles['left'] is None or \ (90 <= abs(end_angles['left']) <= 135 and \ 90 <= abs(end_angles['right']) <= 135)): return None elif self.end_atom.explicit and \ (start_angles['left'] is None or \ (90 <= abs(start_angles['left']) <= 135 and \ 90 <= abs(start_angles['right']) <= 135)): return None # at this point we are looking at either only implicit atoms # or extreme angles. if self.clockwise == -1: side = "left" elif self.clockwise == 1: side = "right" else: # not in a ring. use scoring function to pick sides. _ap = self.angle_penalty left_penalty = _ap(start_angles['left']) + _ap(end_angles['left']) right_penalty = _ap(start_angles['right']) + _ap(end_angles['right']) if left_penalty < right_penalty: side = "left" elif left_penalty > right_penalty: side = "right" else: # penalties equal - try to pick sides consistently if abs(self.angle - 44.5) < 90: side = "left" else: side = "right" if self.start_atom.explicit: start = 0 else: if side == 'left': start = self.shorten_stroke(start_angles['left'], start_angles['right']) else: start = self.shorten_stroke(start_angles['right'], start_angles['left']) if self.end_atom.explicit: end = 0 else: if side == 'left': end = self.shorten_stroke(end_angles['left'], end_angles['right']) else: end = self.shorten_stroke(end_angles['right'], end_angles['left']) return side, start, end def fancy_triple(self): ''' work out parameters for fancy triple bond. We don't need to choose sides here, just calculate the required line shortening. ''' end_angles = self.downstream_angles() if self.start_atom.explicit: start = 0 else: start_angles = self.upstream_angles().values() if start_angles[0] is not None: start = self.cotan100(0.5 * min(start_angles)) else: start = 0 if self.end_atom.explicit: end = 0 else: end_angles = self.downstream_angles().values() if end_angles[0] is not None: end = self.cotan100(0.5 * min(end_angles)) else: end = 0 return start, end def bond_to_chemfig(self): ''' delegate to chemfig_mappings module to render the bond code, without the atom ''' if self.to_phantom: end_string_pos = self.end_atom.phantom_pos else: end_string_pos = self.end_atom.string_pos if self.options['fancy_bonds'] \ and self.bond_type in ('double', 'triple'): # debug("b2c before ", self.start_atom.idx, self.end_atom.idx, self.tikz_styles, self.tikz_values) if self.bond_type == 'double': fd = self.fancy_double() if fd is not None: side, start, end = fd self.tikz_styles.add("double") self.tikz_styles.add(side) self.tikz_values.update( dict(start=start, end=end) ) self.bond_type = 'decorated' elif self.bond_type == 'triple': self.tikz_styles.add('triple') start, end = self.fancy_triple() self.tikz_values.update( dict(start=start, end=end) ) self.bond_type = 'decorated' # debug("b2c after ", self.start_atom.idx, self.end_atom.idx, self.tikz_styles, self.tikz_values) code = cfm.format_bond( self.options, self.angle, self.parent.angle, self.bond_type, self.clockwise, self.is_last, self.length, self.start_atom.string_pos, end_string_pos, self.tikz_styles, self.tikz_values, self.marker ) return code def indent(self, level, bond_code, atom_code='', comment_code=''): stuff = ' ' * self.options['indent'] * level \ + bond_code.rjust(cfm.BOND_CODE_WIDTH) \ + atom_code if comment_code: stuff += "% " + comment_code return stuff.rstrip() def render(self, level): ''' render bond and trailing atom. ''' if not self.to_phantom: atom_code, comment_code = self.end_atom.render() else: atom_code, comment_code = self.end_atom.render_phantom() bond_code = self.bond_to_chemfig() return self.indent(level, bond_code, atom_code, comment_code) class DummyFirstBond(Bond): ''' semi-dummy class that only takes an endatom, wich is the first atom in the molecule, and just renders that. The other dummy attributes only exist to play nice with the molecule class. ''' def __init__(self, options, end_atom): self.options = options self.end_atom = end_atom self.angle = None self.descendants = [] self.length = None def bond_to_chemfig(self): return '' # empty bond code before first atom class AromaticRingBond(Bond): ''' A gross hack to render the circle inside an aromatic ring as a node in the regular bond hierarchy. ''' descendants = [] scale = 1.5 # 1.5 corresponds to chemfig's ring size def __init__(self, options, parent, angle, length, inner_r): self.options = options self.angle = cfm.num_round(angle,1) % 360 if parent is not None: self.parent_angle = parent.angle else: self.parent_angle = None self.length = cfm.num_round(length, 2) self.radius = cfm.num_round(self.scale * inner_r, 2) def render(self, level): ''' there is no atom to render, so we just call chemfig_mapping on our own attributes. ''' ring_bond_code, ring_code, comment = cfm.format_aromatic_ring( self.options, self.angle, self.parent_angle, self.length, self.radius) return self.indent(level, ring_bond_code, ring_code, comment)