1""" 2Generates basic square and circle selection overlay textures by parsing all the entity XML files and reading 3their Footprint components. 4 5For usage, invoke this script with --help. 6""" 7 8# This script uses PyCairo for plotting, since PIL (Python Imaging Library) is absolutely horrible. On Linux, 9# this should be merely a matter of installing a package (e.g. 'python-cairo' for Debian/Ubuntu), but on Windows 10# it's kind of tricky and requires some Google-fu. Fortunately, I have saved the working instructions below: 11# 12# Grab a Win32 binary from http://ftp.gnome.org/pub/GNOME/binaries/win32/pycairo/1.8/ and install PyCairo using 13# the installer. The installer extracts the necessary files into Lib\site-packages\cairo within the folder where 14# Python is installed. There are some extra DLLs which are required to make Cairo work, so we have to get these 15# as well. 16# 17# Head to http://ftp.gnome.org/pub/gnome/binaries/win32/dependencies/ and get the binary versions of Cairo 18# (cairo_1.8.10-3_win32.zip at the time of writing), Fontconfig (fontconfig_2.8.0-2_win32.zip), Freetype 19# (freetype_2.4.4-1_win32.zip), Expat (expat_2.0.1-1_win32.zip), libpng (libpng_1.4.3-1_win32.zip) and zlib 20# (zlib_1.2.5-2_win32.zip). Version numbers may vary, so be adaptive! Each ZIP file will contain a bin subfolder 21# with a DLL file in it. Put the following DLLs in Lib\site-packages\cairo within your Python installation: 22# 23# freetype6.dll (from freetype_2.4.4-1_win32.zip) 24# libcairo-2.dll (from cairo_1.8.10-3_win32.zip) 25# libexpat-1.dll (from expat_2.0.1-1_win32.zip) 26# libfontconfig-1.dll (from fontconfig_2.8.0-2_win32.zip) 27# libpng14-14.dll (from libpng_1.4.3-1_win32.zip) 28# zlib1.dll (from zlib_1.2.5-2_win32.zip). 29# 30# Should be all set now. 31 32import optparse 33import sys, os 34import math 35import operator 36import cairo # Requires PyCairo (see notes above) 37from os.path import * 38from xml.dom import minidom 39 40def geqPow2(x): 41 """Returns the smallest power of two that's equal to or greater than x""" 42 return int(2**math.ceil(math.log(x, 2))) 43 44def generateSelectionTexture(shape, textureW, textureH, outerStrokeW, innerStrokeW, outputDir): 45 46 outputBasename = "%dx%d" % (textureW, textureH) 47 48 # size of the image canvas containing the texture (may be larger to ensure power-of-two dimensions) 49 canvasW = geqPow2(textureW) 50 canvasH = geqPow2(textureH) 51 52 # draw texture 53 texture = cairo.ImageSurface(cairo.FORMAT_ARGB32, canvasW, canvasH) 54 textureMask = cairo.ImageSurface(cairo.FORMAT_RGB24, canvasW, canvasH) 55 56 ctxTexture = cairo.Context(texture) 57 ctxTextureMask = cairo.Context(textureMask) 58 59 # fill entire image with transparent pixels 60 ctxTexture.set_source_rgba(1.0, 1.0, 1.0, 0.0) # transparent 61 ctxTexture.rectangle(0, 0, textureW, textureH) 62 ctxTexture.fill() # fill current path 63 64 ctxTextureMask.set_source_rgb(0.0, 0.0, 0.0) # black 65 ctxTextureMask.rectangle(0, 0, canvasW, canvasH) # (!) 66 ctxTextureMask.fill() 67 68 pasteX = (canvasW - textureW)//2 # integer division, floored result 69 pasteY = (canvasH - textureH)//2 # integer division, floored result 70 ctxTexture.translate(pasteX, pasteY) # translate all drawing so that the result is centered 71 ctxTextureMask.translate(pasteX, pasteY) 72 73 # outer stroke width should always be >= inner stroke width, but let's play it safe 74 maxStrokeW = max(outerStrokeW, innerStrokeW) 75 76 if shape == "square": 77 78 rectW = textureW 79 rectH = textureH 80 81 # draw texture (4px white outline, then overlay a 2px black outline) 82 ctxTexture.rectangle(maxStrokeW/2, maxStrokeW/2, rectW - maxStrokeW, rectH - maxStrokeW) 83 ctxTexture.set_line_width(outerStrokeW) 84 ctxTexture.set_source_rgba(1.0, 1.0, 1.0, 1.0) # white 85 ctxTexture.stroke_preserve() # stroke and maintain path 86 ctxTexture.set_line_width(innerStrokeW) 87 ctxTexture.set_source_rgba(0.0, 0.0, 0.0, 1.0) # black 88 ctxTexture.stroke() # stroke and clear path 89 90 # draw mask (2px white) 91 ctxTextureMask.rectangle(maxStrokeW/2, maxStrokeW/2, rectW - maxStrokeW, rectH - maxStrokeW) 92 ctxTextureMask.set_line_width(innerStrokeW) 93 ctxTextureMask.set_source_rgb(1.0, 1.0, 1.0) 94 ctxTextureMask.stroke() 95 96 elif shape == "circle": 97 98 centerX = textureW//2 99 centerY = textureH//2 100 radius = textureW//2 - maxStrokeW/2 # allow for the strokes to fit 101 102 # draw texture 103 ctxTexture.arc(centerX, centerY, radius, 0, 2*math.pi) 104 ctxTexture.set_line_width(outerStrokeW) 105 ctxTexture.set_source_rgba(1.0, 1.0, 1.0, 1.0) # white 106 ctxTexture.stroke_preserve() # stroke and maintain path 107 ctxTexture.set_line_width(innerStrokeW) 108 ctxTexture.set_source_rgba(0.0, 0.0, 0.0, 1.0) # black 109 ctxTexture.stroke() 110 111 # draw mask 112 ctxTextureMask.arc(centerX, centerY, radius, 0, 2*math.pi) 113 ctxTextureMask.set_line_width(innerStrokeW) 114 ctxTextureMask.set_source_rgb(1.0, 1.0, 1.0) 115 ctxTextureMask.stroke() 116 117 finalOutputDir = outputDir + "/" + shape 118 if not isdir(finalOutputDir): 119 os.makedirs(finalOutputDir) 120 121 print "Generating " + os.path.normcase(finalOutputDir + "/" + outputBasename + ".png") 122 123 texture.write_to_png(finalOutputDir + "/" + outputBasename + ".png") 124 textureMask.write_to_png(finalOutputDir + "/" + outputBasename + "_mask.png") 125 126 127def generateSelectionTextures(xmlTemplateDir, outputDir, outerStrokeScale, innerStrokeScale, snapSizes = False): 128 129 # recursively list XML files 130 xmlFiles = [] 131 132 for dir, subdirs, basenames in os.walk(xmlTemplateDir): 133 for basename in basenames: 134 filename = join(dir, basename) 135 if filename[-4:] == ".xml": 136 xmlFiles.append(filename) 137 138 textureTypesRaw = set() # set of (type, w, h) tuples (so we can eliminate duplicates) 139 140 # parse the XML files, and look for <Footprint> nodes that are a child of <Entity> and 141 # that do not have the disable attribute defined 142 for xmlFile in xmlFiles: 143 xmlDoc = minidom.parse(xmlFile) 144 rootNode = xmlDoc.childNodes[0] 145 146 # we're only interested in entity templates 147 if not rootNode.nodeName == "Entity": 148 continue 149 150 # check if this entity has a footprint definition 151 rootChildNodes = [n for n in rootNode.childNodes if n.localName is not None] # remove whitespace text nodes 152 footprintNodes = filter(lambda x: x.localName == "Footprint", rootChildNodes) 153 if not len(footprintNodes) == 1: 154 continue 155 156 footprintNode = footprintNodes[0] 157 if footprintNode.hasAttribute("disable"): 158 continue 159 160 # parse the footprint declaration 161 # Footprints can either have either one of these children: 162 # <Circle radius="xx.x" /> 163 # <Square width="xx.x" depth="xx.x"/> 164 # There's also a <Height> node, but we don't care about it here. 165 166 squareNodes = footprintNode.getElementsByTagName("Square") 167 circleNodes = footprintNode.getElementsByTagName("Circle") 168 169 numSquareNodes = len(squareNodes) 170 numCircleNodes = len(circleNodes) 171 172 if not (numSquareNodes + numCircleNodes == 1): 173 print "Invalid Footprint definition: insufficient or too many Square and/or Circle definitions in %s" % xmlFile 174 175 texShape = None 176 texW = None # in world-space units 177 texH = None # in world-space units 178 179 if numSquareNodes == 1: 180 texShape = "square" 181 texW = float(squareNodes[0].getAttribute("width")) 182 texH = float(squareNodes[0].getAttribute("depth")) 183 184 elif numCircleNodes == 1: 185 texShape = "circle" 186 texW = float(circleNodes[0].getAttribute("radius")) 187 texH = texW 188 189 textureTypesRaw.add((texShape, texW, texH)) 190 191 # endfor xmlFiles 192 193 print "Found: %d footprints (%d square, %d circle)" % ( 194 len(textureTypesRaw), 195 len([x for x in textureTypesRaw if x[0] == "square"]), 196 len([x for x in textureTypesRaw if x[0] == "circle"]) 197 ) 198 199 textureTypes = set() 200 201 for type, w, h in textureTypesRaw: 202 if snapSizes: 203 # "snap" texture sizes to close-enough neighbours that will still look good enough so we can get away with fewer 204 # actual textures than there are unique footprint outlines 205 w = 1*math.ceil(w/1) # round up to the nearest world-space unit 206 h = 1*math.ceil(h/1) # round up to the nearest world-space unit 207 208 textureTypes.add((type, w, h)) 209 210 if snapSizes: 211 print "Reduced: %d footprints (%d square, %d circle)" % ( 212 len(textureTypes), 213 len([x for x in textureTypes if x[0] == "square"]), 214 len([x for x in textureTypes if x[0] == "circle"]) 215 ) 216 217 # create list from texture types set (so we can sort and have prettier output) 218 textureTypes = sorted(list(textureTypes), key=operator.itemgetter(0,1,2)) # sort by the first tuple element, then by the second, then the third 219 220 # ------------------------------------------------------------------------------------ 221 # compute the size of the actual texture we want to generate (in px) 222 223 scale = 8 # world-space-units-to-pixels scale 224 for type, w, h in textureTypes: 225 226 # if we have a circle, update the w and h so that they're the full width and height of the texture 227 # and not just the radius 228 if type == "circle": 229 assert w == h 230 w *= 2 231 h *= 2 232 233 w = int(math.ceil(w*scale)) 234 h = int(math.ceil(h*scale)) 235 236 # apply a minimum size for really small textures 237 w = max(24, w) 238 h = max(24, h) 239 240 generateSelectionTexture(type, w, h, w/outerStrokeScale, innerStrokeScale * (w/outerStrokeScale), outputDir) 241 242 243if __name__ == "__main__": 244 245 parser = optparse.OptionParser(usage="Usage: %prog [filenames]") 246 247 parser.add_option("--template-dir", type="str", default=None, help="Path to simulation template XML definition folder. Will be searched recursively for templates containing Footprint definitions. If not specified and this script is run from its directory, it will be automatically determined.") 248 parser.add_option("--output-dir", type="str", default=".", help="Output directory. Will be created if it does not already exist. Defaults to the current directory.") 249 parser.add_option("--oss", "--outer-stroke-scale", type="float", default=12.0, dest="outer_stroke_scale", metavar="SCALE", help="Width of the outer (white) stroke, as a divisor of each generated texture's width. Defaults to 12. Larger values produce thinner overall outlines.") 250 parser.add_option("--iss", "--inner-stroke-scale", type="float", default=0.5, dest="inner_stroke_scale", metavar="PERCENTAGE", help="Width of the inner (black) stroke, as a percentage of the outer stroke's calculated width. Must be between 0 and 1. Higher values produce thinner black/player color strokes inside the surrounding outer white stroke. Defaults to 0.5.") 251 252 (options, args) = parser.parse_args() 253 254 templateDir = options.template_dir 255 if templateDir is None: 256 257 scriptDir = dirname(abspath(__file__)) 258 259 # 'autodetect' location if run from its own dir 260 if normcase(scriptDir).replace('\\', '/').endswith("source/tools/selectiontexgen"): 261 templateDir = "../../../binaries/data/mods/public/simulation/templates" 262 else: 263 print "No template dir specified; use the --template-dir command line argument." 264 sys.exit() 265 266 # check if the template dir exists 267 templateDir = abspath(templateDir) 268 if not isdir(templateDir): 269 print "No such template directory: %s" % templateDir 270 sys.exit() 271 272 # check if the output dir exists, create it if needed 273 outputDir = abspath(options.output_dir) 274 print outputDir 275 if not isdir(outputDir): 276 print "Creating output directory: %s" % outputDir 277 os.makedirs(outputDir) 278 279 print "Template directory:\t%s" % templateDir 280 print "Output directory: \t%s" % outputDir 281 print "------------------------------------------------" 282 283 generateSelectionTextures( 284 templateDir, 285 outputDir, 286 max(0.0, options.outer_stroke_scale), 287 min(1.0, max(0.0, options.inner_stroke_scale)), 288 )