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	)