#! /usr/bin/python3 -t MODULE_NAME = "mformula_svg" MODULE_DESC = "Generates an SVG picture of a 'roadkill' molecular diagram" MODULE_VERS = "1.0" MODULE_COPYRIGHT = "Copyright © 2009-07-06 by the State University of Campinas (UNICAMP)" import sys import os import copy import math from math import sqrt,sin,cos,pi,ceil,trunc # sys.path[1:0] = [ sys.path[0] + '/../lib', os.path.expandvars('${STOLFIHOME}/lib'), '.' ] # sys.stderr.write("%s.py: path = %r\n" % (MODULE_NAME, sys.path)); import mformula import rn import argparser # Just for the "no warranty" statements. from datetime import date class obj: """A device for plotting an {mformula.obj} instance.""" def __init__(svg, wr,style,back): svg.wr = wr # Output file svg.style = style # Formula render style: 'B1','B2' for balls, 'S1' for symbols. svg.back = back # True to plot a backing plane. # Note that atom and label coordinates in formula are interpreted as multiples of the {bondlength}, # whereas the atom radii are interpreted as multiples of the {atomradius}, # and font sizes are multiples of {atomftheight} # Initialize the element tables: svg.nelems = 0 # Number of distinct elements. svg.elem_symbol = [] # Symbol of each element (string). svg.elem_radius = [] # Radius of each element's atom/label (float), rel. to {atomradius}. # Relevant for 'B' style only: svg.elem_color = [] # Color of each atom/label (RGB tuple, 0-1 range). # Relevant for 'S' style only: svg.elem_label = [] # Label to print inside atom. svg.elem_fsize = [] # Font size reduction factor. svg.elem_labdx = [] # Horizontal adjustment for label. svg.elem_labdy = [] # Vertical adjustment for label. # Bounding box of the formula (in pixels) not including {margin_wx,margin_wy}. # Initialize the minimum formula extents to always contain the origin. # The client may change these parameters later to enforce a prescribed minimum size. svg.molec_min_x = 0 svg.molec_max_x = 0 svg.molec_min_y = 0 svg.molec_max_y = 0 svg.set_style_params() #---------------------------------------------------------------------- def set_style_params(svg): """Assumes {style} is "{genstyle}{substyle}" where the {genstyle} is 'B' for balls, 'S' for symbols, and {substyle} is an integer that defines minor variations such as atom sizes etc.""" if svg.style == 'B1': svg.set_style_params_B() svg.set_elem_style_B1() elif svg.style == 'B2': svg.set_style_params_B() svg.set_elem_style_B2() elif svg.style == 'S1': svg.set_style_params_S() svg.set_elem_style_S1() elif svg.style == 'S2': svg.set_style_params_S() svg.set_elem_style_S2() else: assert False, "invalid style \"" + svg.style + "\"" def set_style_params_B(svg): """General style parameters for 'B1' and 'B2' styles.""" svg.bondlength = 30.00 # Bond length (float, in pixels). svg.dashlength = 2.50 # Nominal dash length for 1.5 bonds (float, in pixels). svg.bondwidth = 1.25 # Width of bond lines (float, in pixels). svg.dotsdist = 3.00 # Distance from atom edge to unfilled-bond dots (float, in pixels). svg.dotwidth = 2.50 # Width of each unfilled-bond dot (float, in pixels). svg.chargedist = None # Distance from atom edge to charge symbol (float, in pixels). svg.ringwidth = 0.50 # Width of atom outlines (float, in pixels). svg.maxdepth = 5 # Max layer depth (0 = atoms, 1=bonds, 2,..=charge). #---------------------------------------------------------------------- def set_style_params_S(svg): """General style parameters for 'S1' style.""" svg.bondlength = 18.00 # Bond length (float, in pixels). svg.dashlength = 1.50 # Nominal dash length for 1.5 bonds (float, in pixels). svg.bondwidth = 0.90 # Width of bond lines (float, in pixels). svg.dotsdist = 2.00 # Distance from atom edge to unfilled-bond dots (float, in pixels). svg.dotwidth = 1.50 # Width of each unfilled-bond dot (float, in pixels). svg.chargedist = 2.00 # Distance from atom edge to charge symbol (float, in pixels). svg.ringwidth = 0.0 # Width of atom outlines (float, in pixels). svg.maxdepth = 3 # Max layer depth (0 = atoms, 1=bonds, 2,..=charge). #---------------------------------------------------------------------- def set_elem_style_B1(svg): """Sizes and colors of atoms for 'B1' style. Atoms are represented by big colored balls, of distinctly variable sizes. Multibonds are shorter.""" svg.atomradius = 8.00 # Max atom radius (float, in pixels). svg.atomftheight = 10.00 # Max font height for atom symbols (float, in pixels). svg.add_elem("C", 1.00, [0.000, 0.000, 0.000], None, 1.00, None, None) svg.add_elem("O", 0.90, [0.900, 0.200, 0.100], None, 0.90, None, None) svg.add_elem("S", 0.90, [0.950, 1.000, 0.100], None, 0.90, None, None) svg.add_elem("P", 0.90, [1.000, 0.700, 0.100], None, 0.90, None, None) svg.add_elem("N", 0.90, [0.250, 0.500, 1.000], None, 0.90, None, None) svg.add_elem("H", 0.50, [0.800, 0.800, 0.800], None, 0.50, None, None) svg.add_elem(".", 0.00, [1.000, 1.000, 1.000], None, 0.00, None, None) # Invisible atom (bond break). svg.add_elem("+", 0.00, [1.000, 1.000, 1.000], None, 0.00, None, None) # Disembodied charge (+ or -) svg.doublebondlength = 0.85 # Length of double bonds relative to single bonds. svg.CHbondlength = 0.60 # Rel. length of single C-H bonds. svg.CObondlength = 0.80 # Rel. length of single C-O bonds (also C-N, C-F, etc.) svg.OObondlength = 0.80 # Rel. length of single O-O bonds (also O-N, S-O, etc.) svg.OHbondlength = 0.60 # Rel. length of single O-H bonds (also N-H, S-H, etc.) #---------------------------------------------------------------------- def set_elem_style_B2(svg): """Sizes and colors of atoms for 'B2' style. Atoms are represented by colored balls, but with smaller atoms of mostly uniform sizes. Multibonds are shorter.""" svg.atomradius = 6.50 # Max atom radius (float, in pixels). svg.atomftheight = 8.00 # Max font height for atom symbols (float, in pixels). svg.add_elem("C", 1.00, [0.000, 0.000, 0.000], None, 1.00, None, None) svg.add_elem("O", 1.00, [0.900, 0.200, 0.100], None, 1.00, None, None) svg.add_elem("S", 1.00, [0.950, 1.000, 0.100], None, 1.00, None, None) svg.add_elem("P", 1.00, [1.000, 0.700, 0.100], None, 1.00, None, None) svg.add_elem("N", 1.00, [0.250, 0.500, 1.000], None, 1.00, None, None) svg.add_elem("H", 0.65, [0.800, 0.800, 0.800], None, 0.65, None, None) svg.add_elem(".", 0.00, [1.000, 1.000, 1.000], None, 0.00, None, None) # Invisible atom (bond break). svg.add_elem("+", 0.00, [1.000, 1.000, 1.000], None, 0.00, None, None) # Disembodied charge (+ or -) svg.doublebondlength = 0.85 # Length of double bonds relative to single bonds. svg.CHbondlength = 0.60 # Rel length of single C-H bonds. svg.CObondlength = 0.80 # Rel. length of single C-O bonds (also C-N, C-F, etc.) svg.OObondlength = 0.80 # Rel. length of single O-O bonds (also O-N, S-O, etc.) svg.OHbondlength = 0.60 # Rel. length of single O-H bonds (also N-H, S-H, etc.) #---------------------------------------------------------------------- def set_elem_style_S1(svg): """Sizes, labels, and label position adjustments of atoms for 'S' style. Atoms are represented by their symbols. Multiple bonds have the same length as single bonds.""" svg.atomradius = 4.50 # Max atom radius (float, in pixels). svg.atomftheight = 9.00 # Max font height for atom symbols (float, in pixels). svg.add_elem("C", 1.00, None, "C", 1.00, -0.004, -0.350) svg.add_elem("O", 1.00, None, "O", 1.00, -0.004, -0.350) svg.add_elem("S", 1.00, None, "S", 1.00, -0.004, -0.350) svg.add_elem("P", 1.00, None, "P", 1.00, -0.004, -0.350) svg.add_elem("N", 1.00, None, "N", 1.00, -0.004, -0.350) svg.add_elem("H", 0.85, None, "H", 0.80, -0.004, -0.350) svg.add_elem(".", 0.00, None, "", 0.00, 00.000, 00.000) # Invisible atom (invisible bond break). svg.add_elem("+", 0.00, None, "+", 0.75, -0.004, -0.300) # Disembodied charge (+ or -) svg.doublebondlength = 1.00 # Length of double bonds relative to single bonds. svg.CHbondlength = 0.70 # Length of single bonds with hydrogen. svg.CObondlength = 0.90 # Rel. length of single C-O bonds (also C-N, C-F, etc.) svg.OObondlength = 0.90 # Rel. length of single O-O bonds (also O-N, S-O, etc.) svg.OHbondlength = 0.70 # Rel. length of single O-H bonds (also N-H, S-H, etc.) #---------------------------------------------------------------------- def set_elem_style_S2(svg): """Sizes, labels, and label position adjustments of atoms for 'S' style. Atoms are represented by their symbols, except C that are represented by kinks. Multiple bonds have the same length as single bonds.""" svg.atomradius = 4.50 # Max atom radius (float, in pixels). svg.atomftheight = 9.00 # Max font height for atom symbols (float, in pixels). svg.add_elem("C", 0.00, None, "", 0.00, 00.000, 00.000) svg.add_elem("O", 1.00, None, "O", 1.00, -0.004, -0.350) svg.add_elem("S", 1.00, None, "S", 1.00, -0.004, -0.350) svg.add_elem("P", 1.00, None, "P", 1.00, -0.004, -0.350) svg.add_elem("N", 1.00, None, "N", 1.00, -0.004, -0.350) svg.add_elem("H", 0.85, None, "H", 0.80, -0.004, -0.350) svg.add_elem(".", 0.00, None, "", 0.00, 00.000, 00.000) # Invisible atom (invisible bond break). svg.add_elem("+", 0.00, None, "+", 0.75, -0.004, -0.300) # Disembodied charge (+ or -) svg.doublebondlength = 1.00 # Length of double bonds relative to single bonds. svg.CHbondlength = 0.70 # Length of single bonds with hydrogen. svg.CObondlength = 0.90 # Rel. length of single C-O bonds (also C-N, C-F, etc.) svg.OObondlength = 0.90 # Rel. length of single O-O bonds (also O-N, S-O, etc.) svg.OHbondlength = 0.70 # Rel. length of single O-H bonds (also N-H, S-H, etc.) #---------------------------------------------------------------------- def add_elem(svg, sym,rad,col,lab,fntsz,labdx,labdy): """Defines the appearance of an element for use in formula {svg}. The element has the identifying symbol {sym}, and will be assumed to occupy circle or sphere with radius {rad}. If style is 'B', the atom will be plotted as a solid circle of that radius and color {col}. If {style} is 'S', that cicle will not be painted; instead, the string {lab}, if not empty, will be written over it, with font size reduced by factor {fntsz}, centered but displaced by {labdx,labdy} times the nominal font size. The circle will still be used to define the endpoints of bonds.""" sys.stderr.write("! adding elem symbol = %r radius = %r color = %r" % (sym,rad,col)) sys.stderr.write(" label = '%s' labdx = %r labdy = %r\n" % (lab,labdx,labdy)) svg.elem_symbol.append(sym) svg.elem_radius.append(rad) svg.elem_color.append(copy.copy(col)) svg.elem_label.append(lab) svg.elem_fsize.append(fntsz) svg.elem_labdx.append(labdx) svg.elem_labdy.append(labdy) svg.nelems += 1 assert len(svg.elem_symbol) == svg.nelems #---------------------------------------------------------------------- def compute_derived_dims(svg, fm): """(Re)computes derived dimensions. Recomputes several dimensions that are derived from the formula {fm} and the main style parameters. Must be called just before plotting the formula.""" # {svg.bindatradius} is the distance from atom center to start of bond lines. # It needs to be scaled by the atom's radius. if svg.style[0] == 'B': svg.bindatradius = 0.0 elif svg.style[0] == 'S': svg.bindatradius = 1.0 # {svg.totbondwidth} is the Width of nominal rectangle enclosing all bond lines. if svg.style[0] == 'B': svg.totbondwidth = 0.40*svg.bondlength elif svg.style[0] == 'S': svg.totbondwidth = 1.75*svg.atomradius # Plot displacement from edges: svg.margin_wx = 0.5*svg.atomradius # Horiz. margin. svg.margin_wy = 0.5*svg.atomradius # Vert. margin. # Update the coordinate range of molecule's plot: svg.output_figure_body(fm,True) # Figure dimensions (unscaled, including margins): svg.fig_wx = 2*svg.margin_wx + (svg.molec_max_x - svg.molec_min_x) # Total figure width. svg.fig_wy = 2*svg.margin_wy + (svg.molec_max_y - svg.molec_min_y) # Total figure height. # Overall scale factor (can be changed without changing any other parameter). svg.scale = 2.0 #---------------------------------------------------------------------- def include_in_bounds (svg, ctrx,ctry,radx,rady): """Includes {ctrx,ctry} plus/minus {radx,rady} in the molecule's plot coordinate range. Expands {svg.molec_min_x,svg.molec_min_y,svg.molec_max_x,svg.molec_max_y} so as to include the box from {(ctrx-radx,ctry-rady)} to {(ctrx+radx,ctry+rady)}.""" if ctrx - radx < svg.molec_min_x: svg.molec_min_x = ctrx - radx if ctrx + radx > svg.molec_max_x: svg.molec_max_x = ctrx + radx if ctry - rady < svg.molec_min_y: svg.molec_min_y = ctry - rady if ctry + rady > svg.molec_max_y: svg.molec_max_y = ctry + rady #---------------------------------------------------------------------- def output_figure(svg, fm): """Writes the figure {fm} to {svg.wr}.""" # Recompute derived dims,in case the client has changed the main params: svg.compute_derived_dims(fm) sys.stderr.write("molecule ranges (pixels) = {%d .. %d} × {%d .. %d}\n" % \ (svg.molec_min_x, svg.molec_max_x, svg.molec_min_y, svg.molec_max_y)) # Output the figure: svg.output_figure_preamble(fm) svg.wr.write('\n') svg.output_figure_obj_defs(fm) svg.wr.write('\n') # DEBUG: # b = 0.55*svg.bondlength # c = 0.10*svg.bondlength # svg.draw_segment(-b,0*c,+b,0*c,1.00000,svg.bondwidth) # svg.draw_segment(-b,1*c,+b,1*c,0.25000,svg.bondwidth) # svg.draw_segment(-b,2*c,+b,2*c,0.50000,svg.bondwidth) # svg.draw_segment(-b,3*c,+b,3*c,0.75000,svg.bondwidth) svg.output_figure_body(fm,False) svg.wr.write('\n') svg.output_figure_postamble(fm) sys.stderr.write("done.\n") #---------------------------------------------------------------------- def output_figure_preamble(svg, fm): """Writes the SVG preamble for {fm} to {svg.wr}.""" svg.wr.write( \ '\n' \ '\n' \ '\n' \ '\n' \ '\n' \ '\n' \ '\n' \ '\n' \ '\n' \ '\n' \ ) svg.wr.write('\n') if (svg.back): svg.wr.write( \ ' \n' \ ' \n' \ ) svg.wr.write('\n') # Scale everything and position the diagram: svg.wr.write( \ ' \n' \ ) #---------------------------------------------------------------------- def output_figure_obj_defs(svg, fm): """Writes the main object definitions for {fm} to {svg.wr}.""" svg.wr.write( \ ' \n' \ ' \n' \ ) #---------------------------------------------------------------------- def output_figure_body(svg, fm, update_bounds): """Writes the body of the figure {fm} to {svg.wr}. If {update_bounds} is true, does not draw anything and updates {svg}'s bounding box to enclose the entire figure. Does not depend on svg.{fig_wx,fig_wy,margin_wx,margin_wy,scale}.""" # Paint, draw, and label the charge clouds: depth = svg.maxdepth while (depth >= 2): for i in range(fm.natoms): svg.output_atom(fm,i,depth,update_bounds) depth = depth - 1 # Plot the bonds: for j in range(fm.nbonds): svg.output_bond(fm,j,update_bounds) # Paint, draw, and write the atoms minus charge clouds: for i in range(fm.natoms): svg.output_atom(fm,i,0,update_bounds) # Write the labels: for i in range(fm.nlabels): svg.output_label(fm,i,0,update_bounds) #---------------------------------------------------------------------- def output_figure_postamble(svg, fm): """Writes the SVG postamble for {fm} to {svg.wr}.""" svg.wr.write( \ ' \n' \ '\n' \ ) def output_bond(svg, fm,j,update_bounds): """Draws bond number {j} of formula {fm} (which may be unfilled orbitals). If {update_bounds} is true, does not draw anything and updates {svg}'s bounding box to enclose the bond. """ i1 = fm.bond_atom_1_index[j] i2 = fm.bond_atom_2_index[j] val = fm.bond_valency[j] if (i2 != None): svg.output_bond_as_lines(fm,i1,i2,val,update_bounds) else: ang = fm.bond_angle[j] svg.output_bond_as_dots(fm,i1,ang,val,update_bounds) #---------------------------------------------------------------------- def output_atom(svg, fm,i,depth,update_bounds): """Draws atom number {i} of formula {fm}. If {depth} is 2 or more, outputs a layer of the atom's charge cloud (if any). If {depth} is 1, does nothing. If {depth} is zero, outputs only the atom without the charge cloud. If {update_bounds} is true, does not draw anything and updates {svg}'s bounding box to enclose the entire atom.""" if (svg.style[0] == 'B'): svg.output_atom_style_B(fm,i,depth,update_bounds) elif (svg.style[0] == 'S'): svg.output_atom_style_S(fm,i,depth,update_bounds) else: assert False #---------------------------------------------------------------------- def output_atom_style_B(svg, fm,i,depth,update_bounds): """Draws atom number {i} of formula {fm} in style 'B'. If {depth} is 2 or more, outputs a layer of the atom's charge cloud (if any). If {depth} is 1, does nothing. If {depth} is zero, outputs only the atom without the charge cloud. If {update_bounds} is true, does not draw anything and updates {svg}'s bounding box.""" # Get the element index {k}: sym = fm.atom_symbol[i] k = svg.elem_symbol.index(sym) # Get the atom's center SVG coordinates {(cx,cy)}: cx, cy = rn.scale(svg.bondlength, fm.atom_ctr[i][0:2]) # Get the atom's relative radius {rrad}: rrad = svg.elem_radius[k] # Radius relative to {svg.atomradius} # Get the atom's color {R,G,B} and label {lab}: if (depth >= 2): svg.output_atom_style_B_charge(fm,i,depth,update_bounds) elif (depth == 1): # Bond layer, do nothing return elif (depth == 0): # Draw and fill the element's atom proper: # Color is that of the element: RGB = svg.elem_color[k] # Get atom radius: rad = svg.atomradius * rrad # Draw ball if appropriate, and note its color {fillRGB}: if update_bounds: svg.include_in_bounds (cx,cy,rad,rad) # Assume that the label is inside the atom. else: if rad > 0: # svg.draw_rectangle(cx,cy,rad,rad,1.0,[1,0,1],False,True) # DEBUG svg.draw_solid_ball(cx,cy,rad,svg.ringwidth,RGB,True,True) fillRGB = RGB else: fillRGB = [1,1,1] else: assert False #---------------------------------------------------------------------- def output_atom_style_B_charge(svg, fm,i,depth,update_bounds): """Draws layer {depth} of the charge cloud of atom number {i} of formula {fm} in style 'B'. If {update_bounds} is true, does not draw anything and updates {svg}'s bounding box.""" # Get the element index {k}: sym = fm.atom_symbol[i] k = svg.elem_symbol.index(sym) # Get the atom's SVG center coords {(cx,cy)}: cx, cy = rn.scale(svg.bondlength, fm.atom_ctr[i][0:2]) # Get the atom's relative radius {rrad} and center {(x,y)}:w rrad = svg.elem_radius[k] # Radius relative to {svg.atomradius} # Paint the charge cloud: if (fm.atom_chnum[i] == 0): return # Compute fractional charge {q}: q = (fm.atom_chnum[i] + 0.0)/(fm.atom_chden[i] + 0.0) # Compute the color mixing factor {h}: qabs = math.fabs(q) if (qabs < 1.0): h = 0.25 + 0.75*qabs # Fainter color. else: h = 1.0 # Full color. # Compute color {RGB} of charge cloud: RGB_v = [1.000, 1.000, 1.000] # Color of vacuum. if q < 0.0: RGB_n = [0.400, 0.700, 1.000] # Color of one electron. RGB = rn.mix(h, RGB_n, 1-h, RGB_v) else: RGB_p = [1.000, 0.700, 0.400] # Color of one proton. RGB = rn.mix(h, RGB_p, 1-h, RGB_v) # Compute inner radius {rad0} and outer radius {rad1} from {area}: rad0 = svg.atomradius * rrad rad1 = svg.charge_radius(rrad,q) if update_bounds: radm = max(max(rad0,rad1), svg.atomradius) svg.include_in_bounds (cx,cy,radm,radm) else: # Draw fuzzy ball appropriate: if rad1 > 0: # svg.draw_rectangle(cx,cy,rad1,rad1,1.0,[1,0,1],False,True) # DEBUG svg.draw_fuzzy_ball(cx,cy,rad0,rad1,RGB,depth) #---------------------------------------------------------------------- def output_atom_style_S(svg, fm,i,depth,update_bounds): """Draws atom number {i} of formula {fm} in style 'S'. If {depth} is 1 or more, does nothing. If {depth} is zero, outputs only the atom without the charge cloud. If {update_bounds} is true, does not draw anything and updates {svg}'s bounding box.""" # Get the element index {k}: sym = fm.atom_symbol[i] k = svg.elem_symbol.index(sym) # Get the atom's center {(cx,cy)}: cx, cy = rn.scale(svg.bondlength, fm.atom_ctr[i][0:2]) if (depth >= 2): # Charge cloud layer, do nothing for now. return elif (depth == 1): # Bond layer, do nothing return elif (depth == 0): # Define {fh} the nominal font height to use to draw the label, {fwx,fwy} the assumed letter dims. fh = svg.atomftheight # Get the label {lab} (either the element's symbol or the charge) and its visible length {labLen}. lab = svg.elem_label[k] fntsz = svg.elem_fsize[k] # Relative font size to use. fh = fntsz*svg.atomftheight if (lab == '+'): # Make up the label from the charge,reduce font size: lab = svg.atom_charge_string(fm,i) # The circle-signs are 7 bytes in HTML enity code so: labLen = max(1, len(lab)-6) else: labLen = len(lab) # Estimated letter width and height for bounding box: fwx = 0.75*fh # Est letter width in pixels. fwy = 1.00*fh # Est letter height in pixels. # Get the estimated radii {radx,rady} of the label and {radc} of the circle (pixels). if (lab == ''): # No label, no disk: radx = 0 rady = 0 radc = 0 else: # Assume that the letters have aspect {3:4}: radx = 0.75*labLen*0.5*fwx rady = 0.5*fwy # Assume all atoms except '.' have the same ghost radius: radc = svg.atomradius sys.stderr.write(" atom %d at [%.2f %.2f] label radii [%.2f %.2f]" % (i,cx,cy,radx,rady)) sys.stderr.write(" disk radius %.2f\n" % radc) if update_bounds: svg.include_in_bounds (cx,cy,max(radc,radx),max(radc,rady)) else: # Draw label: if lab != '': # Print label string with proper font formatting: txt = make_roman_style(lab) tx = cx + fh*svg.elem_labdx[k] ty = cy + fh*svg.elem_labdy[k] bold = False; svg.draw_label(tx,ty,txt,fh,bold,[0,0,0]) else: assert False #---------------------------------------------------------------------- def atom_charge_string(svg, fm,i): """Converts the charge of atom number {i} of formula {fm} to a string for style 'S'.""" # Get the element index {k}: sym = fm.atom_symbol[i] k = svg.elem_symbol.index(sym) # Get the atom's charge {qn/qd}: qn = fm.atom_chnum[i] qd = fm.atom_chden[i] if (qn == 0): return '' assert (qd > 0) # Format charge as string: if (qn > 0): chs = "⊕" # Unicode circle plus, U+2295 else: chs = "⊖" # Unicode circle minus, U+2296 qn = abs(qn) if ((qn % qd) == 0): # Charge is integer. if (qn > qd): chs = "%d%s" % (qn,chs) else: # Charge is fractional: # Reduce fraction to lowest terms qo = gcd(qn,qd) qn = qn/qo qd = qd/qo chs = "%d/%d%s" % (qn,qd,chs) return chs #---------------------------------------------------------------------- def output_label(svg, fm,i,depth,update_bounds): """Draws label number {i} of formula {fm}. If {depth} is 1 or more, does nothing. If {depth} is zero, outputs the label. If {update_bounds} is true, does not draw anything and updates {svg}'s bounding box to enclose the entire label.""" # Get the text {txt}. txt = fm.label_text[i] if (txt == ''): # Empty label, nothing to do: return elif (depth >= 1): # Charge or bond layer, do nothing return elif (depth == 0): # Font size: fh = fm.label_ftsize[i]*svg.atomftheight bold = fm.label_bold[i] fwy = fh # Assumed font height in pixels. fwx = 0.75*fwy # Est font width in pixels, assuming aspect ratio {3:4}. fRGB = fm.label_color[i] # Get the label's center {(x,y)}: cx, cy = rn.scale(svg.bondlength, fm.label_ctr[i]) # Compute the visible length {labLen}. # Also define {fh} the nominal font height to use # to draw the text, {fwx,fwy} the assumed letter dims. labLen = len(txt) # Get the estimated radii {radx,rady} of the text (pixels). # Assume that the letters have aspect {3:4}: radx = 0.5*labLen*fwx rady = 0.5*fwy sys.stderr.write(" text %d at [%.2f %.2f] text radii [%.2f %.2f]\n" % (i,cx,cy,radx,rady)) if update_bounds: svg.include_in_bounds (cx,cy,radx,rady) else: # Print text string with proper font formatting: stxt = make_roman_style(txt) tx = cx ty = cy - 0.35*fwy svg.draw_label(tx,ty,stxt,fh,bold,fRGB) else: assert False #---------------------------------------------------------------------- def output_bond_as_lines(svg, fm,i1,i2,val,update_bounds): """Draws a set of bond lines from atom {i1} to atom {i2} with valence {val}. The integer part of {val} specifies the number of solid lines. If the fractional part of {val} is not zero, adds a dashed line. If {update_bounds} is true, does not draw anything and updates {svg}'s bounding box. """ # Assume that bond lines are always inside the # bounding box of the two atoms: if val > 0 and not update_bounds: # sys.stderr.write("drawing %r bond lines from atom %r to atom %r\n" % (val,i1,i2)) # Get the atom centers {c1,c2} (relative to {svg.bondlength}) and {a1,a2} (scaled): c1 = rn.scale(svg.bondlength, fm.atom_ctr[i1]) c2 = rn.scale(svg.bondlength, fm.atom_ctr[i2]) a1 = c1[0:2] a2 = c2[0:2] # Get the atom's radii {r1,r2} (relative to {svg.atomradius}) and {s1,s2} (scaled): sym1 = fm.atom_symbol[i1] k1 = svg.elem_symbol.index(sym1) r1 = svg.elem_radius[k1] sym2 = fm.atom_symbol[i2] k2 = svg.elem_symbol.index(sym2) r2 = svg.elem_radius[k2] s1 = r1*svg.atomradius s2 = r2*svg.atomradius # Number of lines, total and solid: nlines = int(val + 0.999) nsolid = int(val) assert(nlines > 0) # Fraction of last line: frline = val - nsolid # Compute longitudinal {X,Y} unit vector {dir12} from atom 1 to atom 2: dir12, dist12 = rn.dir(rn.sub(a2, a1)) # Compute perpendicular {X,Y} unit vector {dirp}: dirp = [+dir12[1],-dir12[0]] # Compute "perspective" bond width scaling at each end: rwdmax = 3.0*3/(nlines+2); rwdmin = 0.6; if c1[2] > c2[2]: # Atom 1 is closer: twd1 = 1.2*svg.totbondwidth; wd1 = rwdmax*svg.bondwidth twd2 = 0.4*svg.totbondwidth; wd2 = rwdmin*svg.bondwidth elif c1[2] < c2[2]: # Atom 2 is closer: twd1 = 0.4*svg.totbondwidth; wd1 = rwdmin*svg.bondwidth twd2 = 1.2*svg.totbondwidth; wd2 = rwdmax*svg.bondwidth else: # Same depth: twd1 = svg.totbondwidth; wd1 = svg.bondwidth twd2 = svg.totbondwidth; wd2 = svg.bondwidth # Compute end points {p1,p2} of bond axis: bdist1 = max(svg.bindatradius*s1, svg.bondwidth) # Safe bdist2 = max(svg.bindatradius*s2, svg.bondwidth) # Safe if svg.style == "S2" and nlines == 1: # In style "S2", single bonds to a carbon atom extend # all the way to the atom's center. Triple bonds # and double bonds for now will stop at some distance # from it, as usual if sym1 == "C": bdist1 = 0 if sym2 == "C": bdist2 = 0 p1 = rn.add(a1, rn.scale(+bdist, dir12)) p2 = rn.add(a2, rn.scale(-bdist, dir12)) for j in range(nlines): # Draw line {j}: if (j >= nsolid): frdash = frline else: frdash = 1.0 fr = (j + 1)/(nlines+1) - 0.5 # Get {XYZ} coordinates of line endpoints. Use same {Z} as the atom centers. q1 = rn.add(p1, rn.scale(fr*twd1, dirp)) q2 = rn.add(p2, rn.scale(fr*twd2, dirp)) svg.draw_bond_line(q1,q2,wd1,wd2,frdash) #---------------------------------------------------------------------- def draw_bond_line(svg, q1,q2,wd1,wd2,frdash): """Draws a bond line between 2D points {q1} and {q2} with width {wd1} at {q1} and width {wd2} at {q2}. The {frdash} parameter is passed to {svg.segment}.""" assert len(q1) == 2 and len(q2) == 2, "invalid coords" if wd1 == wd2: svg.draw_segment(q1[0],q1[1],q2[0],q2[1],frdash,wd1) else: dxy, dlen = rn.dir([ q2[1]-q1[1], q1[0]-q2[0] ]) # Perp direction vector. wdmin = min(wd1,wd2) wdmax = max(wd1,wd2) n = int(math.ceil(1.1*wdmax/wdmin)) assert n >= 2 d1 = wd1 - wdmin d2 = wd2 - wdmin for k in range(n): r = k/(n-1) - 0.5; x1 = q1[0] + r*d1*dxy[0] y1 = q1[1] + r*d1*dxy[1] x2 = q2[0] + r*d2*dxy[0] y2 = q2[1] + r*d2*dxy[1] svg.draw_segment(x1,y1,x2,y2,frdash,wdmin) #---------------------------------------------------------------------- def output_bond_as_dots(svg, fm,i,ang,nf,update_bounds): """Draws a set of {nf} dots towards angle {ang} on atom at {(x1,y1)} (px, user axes). The parameter {nf} is rounded up to integer. If {update_bounds} is true, does not draw anything and updates {svg}'s bounding box.""" n = trunc(ceil(nf)) # sys.stderr.write("drawing %r bond dots on atom %r at angle %r\n" % (n,i,ang)) a1 = rn.scale(svg.bondlength, fm.atom_ctr[i])[0:2] # sys.stderr.write(" atom center = %r\n" % p1) # Compute unit direction vector {ex,ey}: ex = math.cos(ang) ey = math.sin(ang) e = [ex, ey] # Compute lateral displacement vector {d} between dots: d = rn.scale(svg.totbondwidth/(n+1), [+ey,-ex]) # Compute end points {p1,p2} of dots (actually fat segments): p1 = rn.add(a1, rn.scale(svg.atomradius + svg.dotsdist, e)) p2 = rn.add(p1, rn.scale(0.05*svg.dotwidth, e)) hn = 0.5*(n - 1.0) if update_bounds: ctrx = 0.5*(p1[0] + p2[0]) ctry = 0.5*(p1[1] + p2[1]) radx = abs(hn*d[0]) + abs(0.5*(p1[0] - p2[0])) + 0.5*svg.dotwidth rady = abs(hn*d[1]) + abs(0.5*(p1[1] - p2[1])) + 0.5*svg.dotwidth svg.include_in_bounds (ctrx,ctry,radx,rady) else: sys.stderr.write(" @@ output_bond_as_dots: %r --> %r x %d\n" % (p1,p2,n)) for j in range(n): # Draw dot {j}: fr = (j - hn) q1 = rn.add(p1, rn.scale(fr, d)) q2 = rn.add(p2, rn.scale(fr, d)) svg.draw_big_dot(q1[0],q1[1],q2[0],q2[1]) #---------------------------------------------------------------------- def draw_solid_ball(svg, cx,cy,rad,penWd,RGB,fill,draw): """Paints a disk with color {RGB}, center {(cx,cy)}, and radius {rad} (in px, user axes). The color {RGB} is a triplet of floats in [0_1]. If {fill} is 1 the disk will be filled with color {RGB}, otherwise it will be left unfilled. Then, if {draw} is 1 the disk's outline will be stroked with a burned-out version of {RGB} and pen width {penWd}, otherwise it will be left unstroked. The stroked outline will be on the inside of the disk so that the overall radius will be {rad}.""" # Compute fill option {fillOp}: if fill: # Fill with solid color: fillRGB = RGB fillOp = 'fill="rgb(' + rgbF(fillRGB) + ')"' else: # No filling: fillOp = 'fill="none"' # Compute stroke option {drawOp} and adjusted radius {radp}: if draw: # Solid color, with border: drawRGB = rn.mix(0.3, [0,0,0], 0.7, RGB) drawOp = 'stroke-width="' + wdF(penWd) + 'px" stroke="rgb(' + rgbF(drawRGB) + ')"' radp = rad - 0.5*penWd else: # Solid color, no border: drawOp = 'stroke="none"' radp = rad # Convert to image axes: cxp = (cx - svg.molec_min_x) + svg.margin_wx cyp = (svg.molec_max_y - cy) + svg.margin_wy if rad > 0: svg.wr.write( \ ' \n' \ ' \n' \ ' \n' \ ) #---------------------------------------------------------------------- def draw_rectangle(svg, cx,cy,rx,ry,penWd,RGB,fill,draw): """Paints a rectangle with color {RGB}, center {(cx,cy)}, and radiii {rx,ry} (in px, user axes). The color {RGB} is a triplet of floats in [0_1]. If {fill} is 1 the rectangle will be filled with color {RGB}, otherwise it will be left unfilled. Then, if {draw} is 1 the rectangle's outline will be stroked with a burned-out version of {RGB} and pen width {penWd}, otherwise it will be left unstroked. The stroked outline will be on the inside of the rectangle so that the overall radii will be {rx,ry}.""" # Compute fill option {fillOp}: if fill: # Fill with solid color: fillRGB = RGB fillOp = 'fill="rgb(' + rgbF(fillRGB) + ')"' else: # No filling: fillOp = 'fill="none"' # Compute draw option {drawOp}: if draw: # Solid color, with border: drawRGB = rn.mix(0.3, [0,0,0], 0.7, RGB) drawOp = 'stroke-width="' + wdF(penWd) + 'px" stroke="rgb(' + rgbF(drawRGB) + ')"' rxp = rx - 0.5*penWd ryp = ry - 0.5*penWd else: # Solid color, no border: drawOp = 'stroke="none"' rxp = rx ryp = ry # Convert center coords to image axes: cxp = (cx - svg.molec_min_x) + svg.margin_wx cyp = (svg.molec_max_y - cy) + svg.margin_wy if (rx > 0) and (ry > 0): svg.wr.write( \ ' \n' \ ' \n' \ ' \n' \ ) #---------------------------------------------------------------------- def draw_fuzzy_ball(svg, cx,cy,rad0,rad1,RGB,depth): """Paints layer {depth} of a fuzzy disk with color {RGB}, center {(cx,cy)} and radius {rad1} (in px, user axes). The color will be {RGB} inside a circle with radius {rad0} and will fade to white at radius {rad1}. The {depth} depth should decrease from 2 to {svg.maxdepth}.""" if rad1 <= 0: return # Convert to image axes: cxp = (cx - svg.molec_min_x) + svg.margin_wx cyp = (svg.molec_max_y - cy) + svg.margin_wy # Get the number {n} of charge layers: n = svg.maxdepth - 1 # Get the index {i} of the layer, from bottom (0) to top {n-1}: i = svg.maxdepth - depth # Compute the disk radius {rad} so that the annuli have the same area: frad = (i + 0.0)/(n + 0.0) rad = sqrt(rad1*rad1 - frad*(rad1*rad1 - rad0*rad0)) # Compute the color of the disk: fcol = (i + 1.0)/(n + 0.0) dkRGB = rn.mix(1-fcol, [1,1,1], fcol, RGB) if rad > 0: svg.wr.write( \ ' ' \ ' \n' \ ' \n' \ ) #---------------------------------------------------------------------- def draw_label(svg, cx,cy,txt,fh,bold,fRGB): """Ouputs a label {txt} at {(cx,cy)} (in px, user axes) with font height {fh} and color {fRGB}. The reference point is at bottom center of text.""" if (txt != ''): # Convert to image axes: cxp = (cx - svg.molec_min_x) + svg.margin_wx cyp = (svg.molec_max_y - cy) + svg.margin_wy # text fill and outline color: fill_color = "rgb(" + rgbF(fRGB) + ")" svg.wr.write( \ ' ' + txt + '\n' \ ) #---------------------------------------------------------------------- def draw_segment(svg, x1,y1,x2,y2,frdash,bwid): """Draws a straight line from {(x1,y1)} to {(x2,y2)} (in px, user axes). The {frdash} parameter is the fraction of dashes in line: 0.0 is blank, 1.0 is solid. The line width is {bwid}.""" # Convert to pixel coordinates: x1p = (x1 - svg.molec_min_x) + svg.margin_wx y1p = (svg.molec_max_y - y1) + svg.margin_wy x2p = (x2 - svg.molec_min_x) + svg.margin_wx y2p = (svg.molec_max_y - y2) + svg.margin_wy # Fix dash fraction: if (frdash > 0.99): # Practically solid, use solid: frdash = 1.0 elif (frdash < 0.01): # Practically blank, use blank: frdash = 0.0 else: # Round slightly awy from 0 and 1: frdash = 0.05 + 0.90*frdash # Decide line width {bwid}, line cap style {bcap} and lengthening of dashes due to it. if svg.style[0] == 'B': bcap = "round" ebut = 0.7*bwid elif svg.style[0] == 'S': bcap = "butt" ebut = 0 else: assert False if frdash > 0.0: # Define dash pattern: if (frdash == 1.0): dash_style = '' else: peri = 2*svg.dashlength # Length of dash + gap. # Adjust {peri} so that there is an odd integer number of periods in line: dist = math.hypot(x1p-x2p, y1p-y2p) nper = 2*int(dist/peri/2 - 0.5) + 1 # There must be at least two dashes: if (nper < 2): nper = 2 peri = dist/(nper + 0.0) # Compute lengths {dash} of dashes and {gapp} of gaps: dash = peri*frdash - ebut # Nominal length of dash. if dash <= 0.001: dash = 0.001 gapp = peri-dash # Length of gap. # Compute offset so that the dashes are centered in the periods doff = peri-(gapp + dist - nper*peri)/2.0 # Dash offset. # doff = 0.0 dash_style = 'stroke-dasharray="' + dsF(dash) + ',' + dsF(gapp) + '" stroke-dashoffset="' + dsF(doff) + '"' svg.wr.write( \ ' \n' \ ) #---------------------------------------------------------------------- def draw_big_dot(svg, x1,y1,x2,y2): """Draws a big dot, actually a fat segment from {(x1,y1)} to {(x2,y2)} (in px, user axes).""" # Convert to pixel coordinates: x1p = (x1 - svg.molec_min_x) + svg.margin_wx y1p = (svg.molec_max_y - y1) + svg.margin_wy x2p = (x2 - svg.molec_min_x) + svg.margin_wx y2p = (svg.molec_max_y - y2) + svg.margin_wy # Decide line width {bwid}, line cap style {bcap} and lengthening of dashes due to it. bwid = svg.dotwidth bcap = "round" svg.wr.write( \ ' \n' \ ) #---------------------------------------------------------------------- def charge_radius(svg, rrad,q): """Computes the radius of the cloud charge for an atom with relative radius {rrad} and charge {q}. The radius {rrar} is relative to {svg.atomradius}. If the {charge} is zero, returns {rrad*svg.atomradius}.""" if q == 0: return rrad*svg.atomradius qabs = math.fabs(q) # Compute the relative area {area} of the cloud: wmin = 0.75 # Min annulus width rel {atomradius} if {rad = atomradius}. amin = (1 + wmin)*(1 + wmin) - 1 # Min area of annulus rel. {pi*atomradius^2} if (qabs < 1.0): # Minimum area: area = amin else: # Area proportional to qabs: area = amin*qabs return svg.atomradius * sqrt(amin + rrad*rrad) #---------------------------------------------------------------------- def rel_bond_length(svg, v) : """Computes the relative bond length for a bond with valency {v}.""" return math.pow(svg.doublebondlength, v-1) #---------------------------------------------------------------------- #---------------------------------------------------------------------- def rgbF(RGB): """Formats {RGB} as an SVG color triplet '{R},{G},{B}' in 0..255 range.""" return "%d,%d,%d" % (int(255*RGB[0]+0.5),int(255*RGB[1]+0.5),int(255*RGB[2]+0.5)) #---------------------------------------------------------------------- def ptF(x,y): """Formats {x,y} as two coordinates separated by a comma.""" return "%+06.1f,%+06.1f" % (x,y) #---------------------------------------------------------------------- def radF(r): """Formats {r} as a radius.""" return "%06.1f" % r #---------------------------------------------------------------------- def xyF(x,y,tag): """Formats {x,y} as '{tag}x=\"{x}\" {tag}y=\"{y}\"'.""" return "%sx=\"%+06.1f\" %sy=\"%+06.1f\"" % (tag,x,tag,y) #---------------------------------------------------------------------- def wdF(wd): """Formats {wd} as a line width.""" return "%.2f" % wd #---------------------------------------------------------------------- def dsF(wd): """Formats {wd} as a dash/gap length.""" return "%.2f" % wd #---------------------------------------------------------------------- def intF(x): """Converts the decimal number {x} to string.""" return ("%r" % x) #---------------------------------------------------------------------- def make_italic_style(txt): """Packages a string with italic style markup.""" if (txt != ''): return '' + txt + '' else: return '' #---------------------------------------------------------------------- def make_roman_style(txt): """Packages a string with normal style (non-italic) markup.""" if (txt != ''): return '' + txt + '' else: return '' #---------------------------------------------------------------------- def gcd(x,y): """Greatest common divisor of integers {x} and {y}.""" while y != 0: r = x % y; x = y; y = r return x #---------------------------------------------------------------------- def luminance(RGB): """CIE luminance of color {RGB}.""" return 0.298911*RGB[0] + 0.586611*RGB[1] + 0.114478*RGB[2] #---------------------------------------------------------------------- def style_descr(style): """Long description of style {style}.""" if style == 'B1': return "'Large balls' style" elif style == 'B2': return "'Smaller balls' style" elif style == 'S1': return "Letter symbol style" else: assert False #----------------------------------------------------------------------